@rui.branco/revit-mcp 1.0.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.
@@ -0,0 +1,1256 @@
1
+ # Revit MCP Bridge
2
+
3
+ A Revit add-in that runs a small HTTP server **inside Revit's own process** and dispatches requests
4
+ to the Revit API. It is the Revit half of `revit-mcp`: the Node MCP server talks to it over
5
+ loopback HTTP, and this add-in does the part that can only happen inside Revit.
6
+
7
+ - Endpoint base: `http://127.0.0.1:48884/revit-mcp/`
8
+ - Target: Revit **2025, 2026, 2027** (`net8.0-windows`, x64)
9
+ - Runtime dependencies: none beyond Revit itself and the in-box .NET base class library
10
+ (`System.Net.HttpListener`, `System.Text.Json`)
11
+
12
+ ---
13
+
14
+ ## Install
15
+
16
+ One command, from this directory. No Visual Studio, no admin rights, **no .NET SDK**:
17
+
18
+ ```powershell
19
+ .\install.ps1
20
+ ```
21
+
22
+ That detects every installed Revit, copies the add-in to
23
+ `%APPDATA%\Autodesk\Revit\Addins\<version>\RevitMcpBridge\`, and writes the `.addin` manifest next
24
+ to it. Then **restart Revit to load the bridge**.
25
+
26
+ The add-in comes from `dist\` — the prebuilt DLL the npm package ships, which is why an end user
27
+ needs neither the SDK nor a build step. One binary covers 2025, 2026 and 2027 (see
28
+ [Build](#build)). Only a source checkout with no `dist\` falls through to `dotnet build`, and if
29
+ the SDK is missing there too, the installer says so and names both ways out rather than failing
30
+ obscurely.
31
+
32
+ | Flag | Effect |
33
+ | --- | --- |
34
+ | `-RevitVersion 2026` | Only that version. Works even if Revit is not in the default Program Files location. |
35
+ | `-Build` | Force a fresh `dotnet build -c Release` and install that instead of `dist\`. Needs the SDK; the contributor path. |
36
+ | `-SkipBuild` | Never build. Already the default whenever `dist\` is present, so it only matters in a checkout without one. |
37
+ | `-Uninstall` | Remove the manifest and the install folder. |
38
+ | `-Json` | Emit a single JSON result object as the **only** stdout output. |
39
+
40
+ Re-running is safe. The install folder is deleted and recreated rather than merged into, so a file
41
+ that disappeared from a newer build cannot linger.
42
+
43
+ ### Uninstall
44
+
45
+ ```powershell
46
+ .\install.ps1 -Uninstall
47
+ ```
48
+
49
+ Uninstall also cleans versions whose Revit has since been removed but whose add-ins folder is still
50
+ there, so it can finish the job after Revit is gone.
51
+
52
+ ### Calling the installer from a tool
53
+
54
+ Every human-readable line is prefixed and greppable:
55
+
56
+ ```
57
+ [revit-bridge] INFO action=install addin=RevitMcpBridge minimumRevit=2025
58
+ [revit-bridge] DETECTED version=2026 hasExe=True path="C:\Program Files\Autodesk\Revit 2026"
59
+ [revit-bridge] BUILD status=skipped reason=prebuilt-dist
60
+ [revit-bridge] OUTPUT dir="...\revit-bridge\dist"
61
+ [revit-bridge] INSTALLED version=2026 files=2 dir="..." manifest="..."
62
+ [revit-bridge] RESULT status=ok action=install versions=2026
63
+ [revit-bridge] DONE Restart Revit to load the bridge
64
+ ```
65
+
66
+ Levels are `INFO`, `DETECTED`, `BUILD`, `OUTPUT`, `INSTALLED`, `REMOVED`, `RESULT`, `DONE`, `WARN`,
67
+ `ERROR`.
68
+
69
+ For programmatic use prefer `-Json`, which suppresses all of the above and prints one object:
70
+
71
+ ```jsonc
72
+ {
73
+ "ok": true,
74
+ "action": "install",
75
+ "exitCode": 0,
76
+ "message": "Restart Revit to load the bridge",
77
+ "addInName": "RevitMcpBridge",
78
+ "addInId": "8f2c7a41-3e6b-4d19-9c05-1b7a52e4d83f",
79
+ "fullClassName": "RevitMcpBridge.BridgeApplication",
80
+ "vendorId": "com.github.revit-mcp",
81
+ "buildStatus": "prebuilt",
82
+ "outputDir": "...",
83
+ "versions": [ { "version": "2026", "installDir": "...", "manifest": "...", "status": "installed" } ],
84
+ "warnings": [],
85
+ "errors": [],
86
+ "log": [ "[revit-bridge] INFO ..." ]
87
+ }
88
+ ```
89
+
90
+ Exit codes:
91
+
92
+ | Code | Meaning |
93
+ | --- | --- |
94
+ | 0 | Success |
95
+ | 1 | Unhandled failure |
96
+ | 2 | No Revit installation detected |
97
+ | 3 | Requested version unsupported (2024 or earlier), or bad `-RevitVersion` |
98
+ | 4 | `dotnet build` failed, or the .NET SDK is missing and there was no `dist\` to fall back on |
99
+ | 5 | Nothing to install: no `dist\` and no Release build output |
100
+
101
+ The installer runs under **Windows PowerShell 5.1** as well as PowerShell 7 — no ternary, no `??`,
102
+ no `&&`/`||`, no `-AsHashtable`.
103
+
104
+ > **Revit 2024 and earlier are refused.** They host add-ins on .NET Framework 4.8 and physically
105
+ > cannot load a `net8.0-windows` assembly. The installer warns and exits non-zero rather than
106
+ > installing something that would fail inside Revit with a confusing load error.
107
+
108
+ ---
109
+
110
+ ## Build
111
+
112
+ Only contributors need this; installing does not.
113
+
114
+ ```powershell
115
+ dotnet build -c Release
116
+ ```
117
+
118
+ From the repo root, `npm run build:bridge` does the same and then refreshes `dist\` from the
119
+ output, which is the only supported way to update the binary that ships on npm.
120
+
121
+ **Revit does not need to be installed to build this.** The Revit API assemblies come from a
122
+ community reference package:
123
+
124
+ ```
125
+ Revit_All_Main_Versions_API_x64 2025.0.0 (ships lib/net8.0/RevitAPI.dll + RevitAPIUI.dll)
126
+ ```
127
+
128
+ Two things about that reference are deliberate and should not be "cleaned up":
129
+
130
+ 1. **It is pinned to 2025, the lowest supported release.** An add-in compiled against 2025 loads
131
+ fine in 2026 and 2027; one compiled against 2027 would break on 2025. Bumping this pin silently
132
+ drops support for older Revit versions.
133
+ 2. **`ExcludeAssets=runtime` / `Private=false`.** The API assemblies are reference-only and must
134
+ never be copied next to the add-in — Revit supplies them at runtime, and shipping a second copy
135
+ makes every type identity check fail. A post-build MSBuild target (`AssertNoRevitApiInOutput`)
136
+ fails the build if one ever leaks into the output folder, and `install.ps1` refuses to copy them
137
+ as a second line of defence.
138
+
139
+ The build output is the two assemblies — `RevitMcpBridge.dll` and `RevitMcpBridge.Handlers.dll` —
140
+ with a `.pdb` and a `.deps.json` each. See [Building both halves](#building-both-halves) for why one
141
+ `dotnet build` produces both, and [Hot reload](#hot-reload) for why there are two at all.
142
+
143
+ ### `dist\` — the binary that ships
144
+
145
+ `dist\` holds both DLLs and both `.deps.json` files copied out of `bin\Release`, and
146
+ it is **committed to git on purpose**: it is what lets the npm package install without a .NET SDK.
147
+ The `.pdb` is left behind — it is debug weight nobody installing from npm can use. `bin\` and
148
+ `obj\` stay ignored; `dist\` must not be. `prepublishOnly` re-runs `build:bridge` and the test
149
+ suite so a stale or broken binary cannot be published.
150
+
151
+ ---
152
+
153
+ ## Endpoints
154
+
155
+ Everything is **POST** with a JSON body, under `http://127.0.0.1:48884/revit-mcp/`. Reads carry
156
+ their filters in the body; there are no GET endpoints and no query strings. An empty body is
157
+ treated as `{}`.
158
+
159
+ | Endpoint | Request | Response |
160
+ | --- | --- | --- |
161
+ | `status` | `{}` | `{bridgeVersion, url, units, revitVersion, revitVersionName, revitVersionBuild, revitSubVersion, username, activeDocument}` — `activeDocument` is `null` when no document is open, otherwise `{title, pathName, isWorkshared, isFamilyDocument, isReadOnly, isModified}` |
162
+ | `levels` | `{}` | `[{id, name, elevation}]` |
163
+ | `categories` | `{}` | `[{category, count}]`, busiest first |
164
+ | `query` | `{category?, level?, typeName?, limit?, offset?}` | `{total, offset, limit, rows: [compact]}` |
165
+ | `elements` | `{ids: [...], params?: ["Comments", ...]}` | `[compact + {found, parameters?}]` |
166
+ | `selection` | `{}` | `{count, ids: [...], elements: [compact]}` |
167
+ | `titleblocks` | `{}` | `[{id, familyName, typeName}]` — an empty array when no title block family is loaded |
168
+ | `sheets` | `{}` | `[{id, number, name}]`, by sheet number |
169
+ | `levels/create` | `[{name?, elevation}]` or `{levels: [...]}` | `[{id, name, elevation}]` |
170
+ | `walls/create` | `{level, wallType?, typeName?, height, curves: [{start:{x,y}, end:{x,y}}]}` | `[{id, name, typeName, level, height}]` |
171
+ | `parameters/set` | `{ids: [...], name, value}` | `{updated, results: [{id, name, value, display}]}` |
172
+ | `parameters/create-project` | `{name, category \| categories: [...], type?, group?, instance?}` | `{name, guid, categories, instance, created, alreadyExisted}` |
173
+ | `sheets/set-parameter` | `{values: [{sheetId, name, value}]}` | `{updated: [{sheetId, number, name, value, display}], failed: [{sheetId, name, code, reason}]}` |
174
+ | `elements/delete` | `{ids: [...]}` | `{requested, deleted, deletedIds: [...]}` |
175
+ | `sheets/create` | `{titleBlockId?, sheets: [{number, name}]}` | `{created: [{id, number, name}], skipped: [{number, reason}]}` |
176
+ | `toposolid/create` | `{points: [{x, y, z}], typeName?, level?}` | `{id, type, typeName, level, points}` — `type` is `"Toposolid"` or `"TopographySurface"` |
177
+ | `toposolid/flatten` | `{points: [{x, y}], elevation, toposolidId?}` | `{id, elevation, added, creases, flattened, offsetMode, residual}` |
178
+ | `floors/create` | `{level, typeName?, boundary: [{x, y}], structural?, offset?}` | `{id, level, typeName, structural, offset, area}` |
179
+ | `families/load` | `{paths: [string]}` or `{path: string}` | `{families: [{path, familyName, loaded, alreadyLoaded, symbols: [{id, typeName}], code?, reason?}], warnings: [...]}` |
180
+ | `families/symbols` | `{category?, familyName?}` | `[{id, familyName, typeName, category}]` — an empty array when nothing has been loaded into the project |
181
+ | `families/place` | `{symbolId, level?, z?, points: [{x, y, z?}], rotation?}` | `{symbolId, familyName, typeName, level, levelElevation, placed: [{id, x, y, z, placedZ}], failed: [{x, y, reason}]}` |
182
+ | `openings/place` | `{symbolId, points: [{x, y, z?}], hostWallId?, level?, sillHeight?}` | `{symbolId, familyName, typeName, category, level, levelElevation, placed: [{id, x, y, z, hostWallId, hostWallType, sillHeight, sillHeightOn?, placedZ}], failed: [{x, y, z, code, reason}]}` — `code` is `NO_HOST_WALL` or `REVIT_API_ERROR` |
183
+ | `views` | `{}` | `[{id, name, viewType, isTemplate, isPlacedOnSheet, viewDirection?, rightDirection?, upDirection?}]` — non-template views, sheets excluded |
184
+ | `views/create-plan` | `{level, name, viewFamilyType?, scale?}` | `{id, name, viewType, viewFamilyType, level, scale}` |
185
+ | `views/create-drafting` | `{name, scale?}` | `{id, name, viewType, scale}` |
186
+ | `views/create-section` | `{name, origin: {x, y, z}, direction: {x, y}, width, height, depth, scale?}` | `{id, name, viewType, scale, viewDirection, rightDirection, upDirection}` |
187
+ | `views/create-3d` | `{name, eye?: {x, y, z}, target?: {x, y, z}, perspective?, scale?}` | `{id, name, viewType, isPerspective, scale, viewDirection, rightDirection, upDirection, modelExtents: {min, max, center} \| null}` |
188
+ | `views/set-style` | `{viewId, style, detailLevel?}` | `{viewId, name, viewType, style, detailLevel}` — `shadows` is redirected with `SHADOWS_HANDLED_ELSEWHERE` to `views/set-graphics` |
189
+ | `views/set-background` | `{viewId, kind, skyColor?, horizonColor?, groundColor?, imagePath?}` | `{viewId, name, viewType, kind, skyColor?, horizonColor?, groundColor?, imagePath?, imageFlags?}` — 3D views only; `kind: "sky"` reads back as `SunAndClouds` and takes no colours |
190
+ | `views/hide-categories` | `{viewId, categories: [string], hidden?}` | `{viewId, name, viewType, categories: [{category, hidden, skipped?, reason?}]}` — `"annotation"` expands to the whole datum/annotation set |
191
+ | `views/set-sun` | `{viewId, azimuth?, altitude?}` in degrees, or `{viewId, date?, time?}` | `{viewId, name, viewType, sunSettingsId, sharesSettings, sunAndShadowType, relativeToView, azimuth, altitude, dateAndTime}` — angles read back, not echoed |
192
+ | `views/export-image` | `{viewId \| viewIds, path?, width?, height?, format?}` | single: `{viewId, viewName, path, requestedPath, bytes, width, height}`; batch: `{exported: [...]}` |
193
+ | `views/legends` | `{}` | `[{id, name, scale}]` — the legend views the document already has, by name |
194
+ | `views/create-legend` | `{name, fromLegendId?, scale?}` | `{id, name, viewType, sourceViewId, scale}`, or `NO_LEGEND_TO_DUPLICATE` when the document has no legend |
195
+ | `views/duplicate` | `{viewId, name, detailing?}` | `{id, name, viewType, sourceViewId, detailing}` |
196
+ | `views/set-scale` | `{viewId, scale}` or `{viewIds: [...], scale}` | `{updated: [{id, name, viewType, scale}], failed: [{viewId, code, reason}]}` — a perspective view fails with `PERSPECTIVE_VIEW_HAS_NO_SCALE` pointing at `views/scale-perspective-crop` |
197
+ | `views/scale-perspective-crop` | `{viewId, multiplier, dryRun?}` | `{dryRun, applied, viewId, name, viewType, multiplier, before, after, cameraUnchanged, note}` — `View3D.ScalePerspectiveCropBox`; `dryRun` defaults to **true** and reports no predicted `after`; `before`/`after` are `{isPerspective, scale, outline, cropBoxActive, cropBox, camera, viewport}` measured after a regeneration, `outline` and `viewport` in PAPER feet; refusals are `NOT_A_3D_VIEW`, `VIEW_IS_TEMPLATE`, `VIEW_NOT_PERSPECTIVE` |
198
+ | `sheets/place-view` | `{sheetId, viewId, x?, y?}` or `{placements: [{sheetId, viewId, x?, y?}]}` | single: `{viewportId, sheetId, viewId, kind, x, y}`; batch: `{placed: [...], failed: [{sheetId, viewId, code, reason}]}` |
199
+ | `schedules/create` | `{category, name, fields: [string], scale?}` | `{id, name, category, fields, skippedFields, availableFields?}` |
200
+ | `views/graphics` | `{viewId}` or `{viewIds: [...]}` | `{views: [{id, name, viewType, style, detailLevel, templateId, templateName, shadowIntensity, sunlightIntensity, ambientLightIntensity, background, shadows: <probe>, exposure: <probe>}], notes}` — a probe is `{parameter, parameterId, available, storageType, readOnly, value, display, on, templateId, templateName, controlledByTemplate, writable, writableReason}` and `on: null` means UNKNOWN |
201
+ | `views/set-graphics` | `{viewId \| viewIds, style?, detailLevel?, shadowIntensity?, sunlightIntensity?, shadows?}` | `{views: [...], notes, shadows?}` — `shadows.method` is `parameter` (written and read back, `verified`), `posted-command` (`posted`, `pending`, `unverified`), `unavailable` or `blocked`; `ambientLightIntensity` is refused `409 AMBIENT_LIGHT_NOT_EXPOSED` |
202
+ | `views/graphics-command-status` | `{}` | `{posted, command, requested, viewId, postedAtUtc, idleSeenAtUtc, pending, probeAtPost, probeAtIdle, probeNow, verified, verifiedBy}` |
203
+ | `views/capture-template` | `{sourceViewId, name, mode?: "graphics" \| "shadows" \| "all", parameterIds?: [...]}` | `{templateId, name, viewType, sourceViewId, sourceViewName, mode, controlled: [{id, name}], notControlled, excluded: [{id, name, reason}], ignoredParameterIds, shadows}` |
204
+ | `views/apply-template` | `{templateId, viewIds, mode?: "apply" \| "assign", dryRun?, replace?}` | `{dryRun, applied, templateId, templateName, mode, plan: [...], views?, shadows, note?}` — `dryRun` defaults to **true**; the batch is validated before any write |
205
+ | `detail/lines` | `{viewId, lines: [{start: {x, y}, end: {x, y}}], lineStyle?}` | `{viewId, elevation, lineStyle, created: [{id, start, end}], failed: [{index, reason}], requestedLineStyle?, availableLineStyles?}` |
206
+ | `detail/text` | `{viewId, notes: [{x, y, text, size?}]}` | `{viewId, elevation, created: [{id, x, y, typeName}], failed: [{index, reason}]}` |
207
+ | `directshape/create` | `{category, name?, typeName?, materialId?, materialName?, comments?, mark?, shapes: [primitive \| group]}` | `{created: [{id, category, name, typeId, typeName, materialId?, comments?, mark?}], failed: [{index, reason}]}` |
208
+ | `planting/place` | `{points: [{x, y, z?, name?, comments?, mark?}], trunkHeight?, trunkRadius?, crownRadius?, name?, typeName?, materialId?, materialName?, comments?, mark?}` | `{created: [{id, category, name, typeId, typeName, materialId?, x, y, comments?, mark?}], failed: [{index, reason}]}` |
209
+ | `pipes/create` | `{category?, name?, typeName?, materialId?, materialName?, comments?, mark?, runs: [{points: [{x, y, z?}], radius?, name?, comments?, mark?}]}` | `{created: [{id, category, name, typeId, typeName, materialId?, comments?, mark?}], failed: [{index, reason}]}` |
210
+ | `sprinklers/place` | `{points: [{x, y, z?, name?, comments?, mark?}], radius?, height?, name?, typeName?, materialId?, materialName?, comments?, mark?}` | `{created: [{id, category, name, typeId, typeName, materialId?, x, y, comments?, mark?}], failed: [{index, reason}]}` |
211
+ | `materials` | `{}` | `[{id, name, colorRgb, appearanceAssetId}]`, by name — `colorRgb` is `{r, g, b}` or `null` |
212
+ | `materials/create` | `{name, color: {r, g, b}, transparency?, shininess?, surfaceForegroundPatternId?, appearanceAssetId?, texturePath?}` | `{id, name, created, colorRgb, transparency, shininess, appearanceAssetId, texture?}` |
213
+ | `materials/set-texture` | `{materialId \| materialName, texturePath, scale?: {x, y}, rotation?, tint?: {r, g, b}}` | `{materialId, materialName, assetAuthored, assetReason, appearanceAssetId, appearanceAssetName, assetSchema, colorRgb, texturePath, diffuseProperty, textureApplied, set: {property: value}, missing: [property], verified: {connectedAsset, bitmap, scaleFeet: {x, y}, rotation}}` |
214
+ | `materials/assign` | `{materialId \| materialName, elementIds: [...]}` | `{materialId, materialName, assigned: [{id, route, appliesToType, typeId?, typeName?, layers?, parameter?}], skipped: [{id, reason}]}` |
215
+ | `walltypes/create` | `{name, basedOnTypeName?, thickness?, materialId?, materialName?}` | `{id, name, created, basedOn, thickness, materialId, materialName, layers}` |
216
+ | `floortypes/create` | `{name, basedOnTypeName?, thickness?, materialId?, materialName?}` | `{id, name, created, basedOn, thickness, materialId, materialName, layers}` |
217
+ | `document/new` | `{templatePath, savePath, overwrite?}` | `{path, title, templatePath}` |
218
+ | `document/open` | `{path}` | `{path, title}` — works with no active document |
219
+ | `document/save` | `{}` | `{path, saved}` |
220
+ | `document/save-as` | `{savePath, overwrite?}` | `{path}` |
221
+ | `document/close` | `{save?}` | `{closed, path?, title?, activePath?, activeTitle?, activeIsScratch?}` — `{closed: false}` when nothing is open |
222
+ | `diagnostics` | `{}` | `{autoDismiss, capacity, dropped, dialogs: [{at, dialogId, message, result, answered}], failures: [{at, transaction, severity, message, action}]}` |
223
+ | `diagnostics/config` | `{autoDismiss?, clear?}` | `{autoDismiss, cleared}` |
224
+ | `reload` | `{}` | `{reloaded, version, loadedAt, loadedFrom, collectible, unloadedPrevious, warning?}` |
225
+
226
+ The **compact** row shape, shared by `query` / `elements` / `selection`:
227
+
228
+ ```json
229
+ { "id": 123456, "name": "Basic Wall", "category": "Walls", "typeName": "Generic - 200mm", "level": "Level 1" }
230
+ ```
231
+
232
+ Notes that matter:
233
+
234
+ - **Element ids are JSON integers** taken from `ElementId.Value` (Int64), never the deprecated
235
+ `ElementId.IntegerValue`.
236
+ - **`query` caps `limit` at 500** (default 100). A larger value is clamped, not rejected.
237
+ - **`elements` returns only the parameters you name** in `params`. It never dumps every parameter
238
+ an element has. Omit `params` and you get identity fields only. A named parameter that does not
239
+ exist comes back as `null`; a missing element id comes back as `{"id": N, "found": false}` rather
240
+ than failing the whole batch.
241
+ - **`parameters/set` writes instance parameters only.** Writing to the type would silently change
242
+ every other instance of that type, which is never what a caller naming specific ids meant.
243
+ - **`elements/delete` can report more deleted than requested** — Revit also removes dependent
244
+ elements.
245
+ - **`sheets/create` skips instead of failing.** A `number` Revit refuses — practically always one
246
+ that already exists — lands in `skipped` with Revit's own reason, and the rest of the batch still
247
+ gets created, so re-running the same call after a partial run is safe. `titleBlockId` is optional
248
+ and defaults to the first loaded title block; a document with **none** loaded is a `NO_TITLEBLOCK`
249
+ failure rather than a pile of sheets with no title block. An inactive `FamilySymbol` is activated
250
+ first, because `ViewSheet.Create` throws on one that has never been used.
251
+ - **`toposolid/create` prefers Toposolid and says when it did not.** `Autodesk.Revit.DB.Toposolid`
252
+ is Revit 2024+ and is present in the 2025 reference assemblies this add-in compiles against, so
253
+ the Toposolid path is the one that is taken whenever the document has a `ToposolidType` — which
254
+ every 2024+ template does. The legacy `TopographySurface` is used **only** when the document has
255
+ no toposolid type at all, and the response says which element kind you got in `type`. `level` is
256
+ optional because `Toposolid.Create` needs one and the caller usually does not care: omitted, it is
257
+ the lowest level in the document. Point `z` is the surface elevation and is never flattened.
258
+ - **`floors/create` closes the boundary for you** when the last point is not the first, and takes
259
+ every point at the level's elevation so the floor lands on the level rather than at an accidental
260
+ offset. `area` is `HOST_AREA_COMPUTED` read after a `Regenerate()`, so it is the real computed
261
+ area and not zero. `offset` is the floor's height offset from that level in feet, written to
262
+ `FLOOR_HEIGHTABOVELEVEL_PARAM` - the enum name behind Revit's "Height Offset From Level"; there is
263
+ no `FLOOR_HEIGHTOFFSET_PARAM` in the API - and defaulting to 0, which is exactly what the endpoint
264
+ did before it took one. The response reports the value **read back off the parameter**, not the
265
+ one that was asked for.
266
+ - **`toposolid/flatten` is the other half of putting paving on graded terrain.** A floor laid over a
267
+ sloped toposolid gives Revit's "Highlighted toposolid and floor overlap" warning, and there are
268
+ only two honest answers: level the surface under the paving, or lift the paving off it with
269
+ `floors/create`'s `offset`. This endpoint does the first. It adds the ring to the toposolid's
270
+ shape at `elevation` (`SlabShapeEditor.AddPoints`), creases the ring
271
+ (`SlabShapeEditor.AddSplitLine` - `DrawSplitLine` is deprecated in 2025) so the flat region ends
272
+ at its boundary instead of sloping on into the terrain, and moves every existing shape vertex
273
+ inside the ring to the same elevation. Only a Revit 2024+ `Toposolid` can be flattened; a legacy
274
+ `TopographySurface` answers `NO_TOPOSOLID`. `toposolidId` is only needed when the document has
275
+ more than one.
276
+
277
+ `SlabShapeEditor.ModifySubElement` takes "the new value of the vertex offset" and the API
278
+ documents **no datum for it**. Rather than guess, the endpoint measures: one vertex is offset by
279
+ 1 ft, then by 2 ft, and where it lands answers the question outright - a relative offset stacks
280
+ (the second lands 2 ft above the first), an absolute one does not (it lands 1 ft above), and the
281
+ datum falls out of the first measurement. `offsetMode` says which it found. `residual` is the
282
+ largest distance any vertex in the region is **still** off the target, measured after the fact: a
283
+ caller asserts that rather than trusting any of this. A crease Revit refuses is counted in
284
+ `creases`, not thrown - the region is still flattened, it just meets the terrain across triangles
285
+ instead of along an edge.
286
+
287
+ It is worth saying plainly: this is the one endpoint here whose Revit-side behaviour is not
288
+ pinned down by the API documentation, which is exactly why it measures itself. **Read `residual`
289
+ before believing the region is flat.** If it comes back large, the working fallback is the one
290
+ that needs no sub-element editing at all: author the terrain flat under the paved areas
291
+ (`toposolid/create` with the paved region's points already at the paving level) and use
292
+ `floors/create`'s `offset` for the rest.
293
+ - **`families/load` is what makes any of the family endpoints useful.** Autodesk's library is an
294
+ optional download that lives outside the project, under
295
+ `C:\ProgramData\Autodesk\RVT <year>\Libraries\<language>\`; until an `.rfa` has been loaded
296
+ into *this* document it does not exist to `families/symbols` or `families/place`.
297
+
298
+ It calls **`Document.LoadFamily(string, IFamilyLoadOptions, out Family)`** — the three-argument
299
+ overload, never the bare `LoadFamily(path)`. That is the whole reason a family already in the
300
+ project does not throw: without an `IFamilyLoadOptions` there is nowhere for Revit to ask its
301
+ "this family already exists" question and it raises instead. What the bridge answers:
302
+
303
+ | callback | answer | why |
304
+ | --- | --- | --- |
305
+ | `OnFamilyFound` | `true`, `overwriteParameterValues = false` | Reload the definition — that is what the caller asked for — but keep the parameter values **this project** has set on the types. A reload that silently reverts a tuned type to library defaults is worse than one that leaves it. Same as Revit's own "Overwrite the existing version" button. |
306
+ | `OnSharedFamilyFound` | `true`, `source = FamilySource.Project`, `overwriteParameterValues = false` | A nested shared family the project already has stays as the project has it; taking the incoming file's copy would change instances that are already placed and that nobody in the request mentioned. |
307
+
308
+ **A library one release behind loads.** Measured, not assumed: `RVT 2026` families
309
+ (`M_RPC Tree - Deciduous`, `M_RPC Shrub`, `M_Park Bench`, `M_Bollard Light`) loaded into Revit
310
+ 2027 upgrade on the way in and produce **no** dialog and **no** transaction warning —
311
+ `warnings` came back `[]` and the bridge log recorded nothing. `warnings` carries whatever
312
+ `BridgeDiagnostics` did record during the call, so an upgrade that *does* warn is reported rather
313
+ than left for a caller who would have to know to go and look at `diagnostics`.
314
+
315
+ **Read `loaded` and `alreadyLoaded` together.** `alreadyLoaded` is whether the project had the
316
+ family before the call; `loaded` is whether Revit actually wrote it in. `loaded: false` with
317
+ `alreadyLoaded: true` is **not** a failure — Revit found its copy identical to the file and did
318
+ nothing — and that row's `familyName` and `symbols` are still the ones to place with. One
319
+ `TransactionGroup` for the batch, one transaction per file, and a per-file catch: a missing path
320
+ is `FILE_NOT_FOUND` on its own row, an `.rfa` Revit refuses is `LOAD_FAILED` on its own row, and
321
+ every other file in the request still loads.
322
+ - **`families/place` catches per point, and does something with `z` you have to know about.** The
323
+ symbol is activated first if it has never been used (its own inner transaction, like
324
+ `sheets/create`), then one inner transaction per point inside the one group: a point Revit refuses
325
+ lands in `failed` and the other 199 still assimilate into a single undo entry. `rotation` is
326
+ **radians** about the vertical axis — radians is Revit's internal angle unit exactly as feet is
327
+ its internal length unit.
328
+
329
+ **Which overload, and the elevation trap in it.** Point-based placement uses
330
+ `Document.Create.NewFamilyInstance(XYZ, FamilySymbol, Level, StructuralType)`. Planting and site
331
+ families are `OneLevelBased`, **not** `OneLevelBasedHosted` — a tree is not hosted by the terrain
332
+ it stands on, it sits at an elevation — so the overloads taking an `Element host` or a `Face` are
333
+ the wrong ones for them.
334
+
335
+ That overload reads `location.Z` as **the offset from the level, not as a model elevation.**
336
+ Measured against Revit 2027 with a real `M_RPC Tree - Deciduous`:
337
+
338
+ | asked | level elevation | `LocationPoint.Z` Revit gave |
339
+ | --- | --- | --- |
340
+ | `z: 0` on `Cota do jardim` | `-1.476378` | `-1.476378` |
341
+ | `z: 0` on `Piso 1` | `9.84252` | `9.84252` |
342
+ | `z: 5` on `Cota do jardim` | `-1.476378` | `3.523622` |
343
+
344
+ So a caller passing the absolute elevations every other endpoint here takes would have had every
345
+ instance pushed up or down by the level elevation, in silence. **This endpoint therefore takes
346
+ `z` as an absolute model elevation like the rest of the bridge and subtracts the level elevation
347
+ before handing the point to Revit**, and reports `placedZ` read back off each instance so the
348
+ result is never a matter of trust. `level` is optional and defaults to the lowest level in the
349
+ document; the top-level `z` is an extra offset added to every point.
350
+
351
+ One caveat on `placedZ`: it is `LocationPoint.Point.Z`, which for some families is not the base of
352
+ the object. `M_Bollard Light` placed at `-1.476` reports `placedZ` `1.024` while its
353
+ `Elevation from Level` is `0` and it stands correctly on the ground — that family's location
354
+ point is simply defined above its base.
355
+ - **`views/create-*` and `views/duplicate` never fail on a name.** View names are unique
356
+ document-wide; a taken one gets `" 2"`, `" 3"`, ... appended and the response says what the view
357
+ is actually called. Losing a batch of plans to one collision is worse than a suffix.
358
+ - **`views/create-section` geometry.** `origin` is the model point the section is centred on and the
359
+ cut plane passes through it. **`direction` is the horizontal direction the section LOOKS TOWARD** -
360
+ `{x: 0, y: 1}` is a section looking north. It is what the view faces, not where the viewer stands;
361
+ its `z` is ignored and it is normalised. `width` is the extent across the view along the section
362
+ line, `height` the vertical extent (up is always +Z), both centred on `origin`. `depth` is how far
363
+ in front of `origin` the view sees, and is where the far clip lands.
364
+
365
+ **The transform is settled by measurement against a live Revit 2027, not by reading
366
+ `RevitAPI.xml`.** Three sections were created with `BasisZ = -direction`, `BasisX = direction × Z`,
367
+ and the created views' own frames read back off the views:
368
+
369
+ | asked `direction` | `viewDirection` | `rightDirection` | `upDirection` |
370
+ | --- | --- | --- | --- |
371
+ | `{x: 0, y: 1}` | `{0, 1, 0}` | `{-1, 0, 0}` | `{0, 0, 1}` |
372
+ | `{x: 0, y: -1}` | `{0, -1, 0}` | `{1, 0, 0}` | `{0, 0, 1}` |
373
+ | `{x: 1, y: 0}` | `{1, 0, 0}` | `{0, 1, 0}` | `{0, 0, 1}` |
374
+
375
+ So Revit hands back `viewDirection = -BasisZ` and `rightDirection = -BasisX`: it turns the frame
376
+ it is given by 180° about up. `View.ViewDirection` is "the direction towards the viewer", so a
377
+ `viewDirection` **equal** to the asked direction means those sections looked the opposite way -
378
+ backwards from what this endpoint promises. The right directions say it independently: asked
379
+ `{0, 1}`, Revit reported right `{-1, 0, 0}`, west, which is the right hand of a viewer facing
380
+ **south**. That was a real bug, and it is fixed by negating the direction that goes into the
381
+ transform - the parameter still means what it always said it meant.
382
+
383
+ Measured rule, and the one this endpoint is built on: **the view looks along the box's `BasisZ`
384
+ and reports `viewDirection = -BasisZ`.** To look toward `direction`, hand it `BasisZ = direction`.
385
+ `BoundingBoxXYZ.Transform` remarks that "the transform must always be right-handed and
386
+ orthonormal", which then fixes `BasisX = BasisY × BasisZ = Z × direction`; Revit reports
387
+ `rightDirection = -BasisX = direction × Z`, east for a section looking north, which is what is on
388
+ the right of a viewer facing north. `Min`/`Max` are `(-width/2, -height/2, -depth)` /
389
+ `(width/2, height/2, 0)` in that frame, so the far clip distance is `depth` and the near plane is
390
+ the cut plane through `origin`; being in the box frame, the depth region turns with `BasisZ` and
391
+ stays in front of the way the view looks.
392
+
393
+ Do not re-derive any of this from `RevitAPI.xml`. `ViewSection.CreateSection` remarks that "the
394
+ view direction of the resulting section will be `sectionBox.Transform.BasisZ`", but its "view
395
+ direction" is the way the view **looks**, the opposite of the `View.ViewDirection` property of the
396
+ same name; its other remark, that `(right, up, view direction)` is "left handed", reads either way
397
+ depending on which of the two is meant. Reading those remarks as `View.ViewDirection` is exactly
398
+ what put the sign the wrong way round in the first place. The measurements are the authority.
399
+
400
+ **The created view's own frame is reported precisely so this is assertable.** `views/create-section`
401
+ answers with the view's `viewDirection`, `rightDirection` and `upDirection` as `{x, y, z}`, and so
402
+ does every framed row of `views`: **a caller asserts what Revit did** instead of opening Revit and
403
+ looking at it. For a section looking toward **D**, Revit reports `viewDirection` **-D** - a section
404
+ asked to look at `{x: 0, y: 1}` reports `viewDirection` `{x: 0, y: -1, z: 0}`, and that is the
405
+ assertion, not a bug. A view with no frame at all - a schedule, a legend - simply does not
406
+ carry the three fields.
407
+ - **`views/create-3d` aims the camera for you, and tells you where the model is.**
408
+ `ViewOrientation3D` takes `(eyePosition, upDirection, forwardDirection)` and Revit requires `up`
409
+ perpendicular to `forward`, so neither can be passed through raw. From an `eye` and a `target` the
410
+ bridge builds:
411
+
412
+ ```
413
+ forward = normalize(target - eye) the direction the camera looks
414
+ right = normalize(forward x Z) horizontal; +X when the camera looks north
415
+ up = right x forward world up, tilted with the camera
416
+ ```
417
+
418
+ A camera looking straight down or straight up has no horizontal right vector (`forward x Z` is
419
+ zero), and that one case falls back to `right = +X`, which puts north at the top of the image the
420
+ way a plan does. Verified live: `eye {93, 6, 7} -> target {52, 60, 8}` came back with
421
+ `viewDirection {-0.055, -0.997, -0.055}` (Revit's view direction is `-forward`),
422
+ `rightDirection {0.998, -0.055, 0}` and `upDirection {-0.003, -0.055, 0.998}`; straight down gave
423
+ `viewDirection {0, 0, 1}`, `up {0, 1, 0}`, `right {1, 0, 0}`.
424
+
425
+ Every response also carries **`modelExtents`** - `{min, max, center}` over everything modelled -
426
+ because a camera cannot be aimed without knowing where the model is and no other endpoint says.
427
+ Create a view with no `eye`/`target`, read the extents, create the one you want. Model categories
428
+ only, and two exclusions that were **measured rather than assumed**: a `ViewSheet` reports a Model
429
+ category with a bounding box at `z = -1000`, and a camera (the glyph standing for a 3D view) sits
430
+ above everything built. Levels and grids drop out with the rest of the annotation categories. On
431
+ `moradia.rvt` that is the difference between `{-328, -328, -1000} .. {328, 328, 87}` and the real
432
+ `{-1.39, -2.22, -4.6} .. {100.75, 83.95, 29}`.
433
+
434
+ A perspective view has no view scale - Revit reports `0` and refuses the setter - so `scale` is
435
+ applied only to an isometric.
436
+ - **`views/set-style` sets the display style and the detail level; shadows moved out.**
437
+ `View.DisplayStyle` and `View.DetailLevel` are plain settable properties and both are read back
438
+ off the view after the write, because a view template owns them on the views that use one.
439
+ `"HiddenLine"` is accepted for `DisplayStyle.HLR`, which is named after hidden line removal and is
440
+ not what anybody outside the API calls it.
441
+
442
+ `shadows` is answered `409 SHADOWS_HANDLED_ELSEWHERE` pointing at `views/set-graphics`. This
443
+ endpoint used to answer `SHADOWS_NOT_EXPOSED` and say cast shadows were impossible; that claim was
444
+ too broad and has been withdrawn. What remains true is that `View` has no
445
+ `EnableSunlightAndShadows`, `View.ShadowIntensity` / `View.SunlightIntensity` are the Lighting
446
+ sliders rather than the switch, and `View.SunAndShadowSettings` is read-only and holds the sun
447
+ *position*. What was wrong is the rest: `BuiltInParameter.GRAPHIC_DISPLAY_OPTIONS_SHADOWS` exists,
448
+ and whether it is a usable toggle is a property of the live view. See `GraphicsEndpoints`.
449
+ - **`views/graphics` and `views/set-graphics` probe cast shadows rather than declaring them.**
450
+ The enum member existing proves nothing - Autodesk's own REVIT-222419 is the record of an enum
451
+ member that is not a usable toggle - so `get_Parameter(GRAPHIC_DISPLAY_OPTIONS_SHADOWS)` is asked
452
+ on the live view and everything it answers is reported: `available`, `storageType`, `readOnly`,
453
+ `value`, `on`, whether a view template controls it, and a `writable` verdict with the reason
454
+ behind it. `on: null` means UNKNOWN, never off.
455
+
456
+ `views/set-graphics` then takes one of two routes and says which:
457
+
458
+ - **`method: "parameter"`** when the probe proves all four conditions - present, `Integer`
459
+ storage, not read-only, not template-controlled. It is written inside the same transaction group
460
+ as `style`/`detailLevel`/the intensities and read back afterwards, so this is the only route that
461
+ can report `verified: true`.
462
+ - **`method: "posted-command"`** otherwise. `RevitCommandId.LookupCommandId("ID_IMAGE_SHADOW_ON"
463
+ / "ID_IMAGE_SHADOW_OFF")` - both strings are present in Revit 2027's `UIFrameworkRes.dll`,
464
+ `DesktopMFC.dll` and `Utility.dll` - is gated on `CanPostCommand` and posted with
465
+ `UIApplication.PostCommand`. There is no `PostableCommand` member for shadows, so a `false` from
466
+ `CanPostCommand` is reported as `method: "unavailable"` rather than worked around. A posted
467
+ command acts on the **active** view, so the target view is made active first with
468
+ `UIDocument.ActiveView` - which Revit only allows while the document is not modifiable, hence
469
+ outside the transaction group, after it has closed. It runs when control returns to Revit, so
470
+ the response is `posted: true, pending: true, unverified: true, verified: false` and never says
471
+ success. Revit allows one posted command at a time, so a second while one is outstanding is
472
+ `method: "blocked"`, and a request that needs this route must name a single view.
473
+
474
+ `views/graphics-command-status` reports what became of it, including a fresh live probe. When the
475
+ parameter cannot be read back, `verified` stays `false` with `verifiedBy: null`.
476
+
477
+ The Idling handler that timestamps the first idle after a post is **one-shot and unsubscribes
478
+ itself first thing**. That is not tidiness: these handlers live in a collectible
479
+ `AssemblyLoadContext` and a delegate left on Revit's `Idling` event would pin the context and
480
+ break `/revit-mcp/reload`.
481
+
482
+ `ambientLightIntensity` is refused `409 AMBIENT_LIGHT_NOT_EXPOSED` rather than accepted and
483
+ ignored: checked against both the 2025 reference assembly and the installed 2027 `RevitAPI.dll`,
484
+ `View` has `ShadowIntensity` and `SunlightIntensity` and no ambient equivalent, and there is no
485
+ ambient `BuiltInParameter` on a view either.
486
+ - **`views/capture-template` states what a template controls; `views/apply-template` validates the
487
+ whole batch first.** `View.CreateViewTemplate()` copies the source view and
488
+ `SetNonControlledTemplateParameterIds` is what turns the copy into a template with a deliberate
489
+ scope. `mode: "graphics"` controls everything the template can except `VIEW_GRAPH_SUN*` /
490
+ `VIEW_SOLARSTUDY*` (so each view keeps its own sun), `VIEWER_*` (crop, camera, extents) and
491
+ `VIEW_PHASE` / `VIEW_PHASE_FILTER`; every exclusion comes back with its reason and the controlled
492
+ set is read back off the template rather than echoed.
493
+
494
+ `views/apply-template` checks each target is a view, is not a template or a sheet, and passes
495
+ `View.IsValidViewTemplate` **before** writing anything - one failure fails the request with all
496
+ the reasons rather than leaving half the batch changed. `mode: "apply"` is
497
+ `ApplyViewTemplateParameters`, a one-time copy; `mode: "assign"` sets `ViewTemplateId`, a lasting
498
+ association, and will not detach a template a view already has without `replace: true`. `dryRun`
499
+ defaults to **true**.
500
+
501
+ Neither endpoint pretends a template can conjure state the source did not have: capturing from a
502
+ view whose cast shadows are off produces a template that turns shadows on nowhere, and both
503
+ responses carry the probes that make that visible.
504
+ - **`views/export-image` reports the file Revit wrote, not the one you asked for.** `ExportImage`
505
+ treats `FilePath` as a **stem** and appends ` - <view type> - <view name>` to it: asking for
506
+ `renders\moradia.png` on a view called `MCP Garden Eye` writes
507
+ `renders\moradia - 3D View - MCP Garden Eye.png`. So the directory is listed before and after the
508
+ export and the file that appeared - or, on a re-export, the file whose timestamp moved - is what
509
+ comes back in `path`; `requestedPath` is only there to make the difference visible. A batch is run
510
+ as **one single-view export per view** for exactly this reason: with one view in flight there is
511
+ exactly one file to attribute.
512
+
513
+ `ImageExportOptions.GetFileName` is deliberately **not** used as the stem. It returns the suffix
514
+ Revit is about to append (`" - 3D View - <name>"`, with an empty base), so using it writes the
515
+ suffix twice - `renders\ - 3D View - Iso - 3D View - Iso.png`, which is what it actually did
516
+ before this was fixed. The default stem is the document title, the way Revit's own export dialog
517
+ names a file, and the default folder is `<user profile>\RevitProjects
518
+ enders\`.
519
+
520
+ Size is Revit's model, not a width and a height: one `PixelSize` along one `FitDirection`, with
521
+ the other dimension following the view. `width` fits horizontally (1600 px by default), `height`
522
+ fits vertically, passing both fits the width - and the `width`/`height` in the response are read
523
+ out of the written PNG's IHDR chunk rather than echoed. `HLRandWFViewsFileType` and
524
+ `ShadowViewsFileType` are both set: Revit picks whichever matches how the view is drawn, and a
525
+ shaded view exported with only the first set comes out in the wrong format. Nothing is transacted
526
+ - exporting is a read, and Revit refuses it inside a transaction.
527
+
528
+ **This is not a render.** The Revit API cannot start the photoreal raytracer:
529
+ `View3D.GetRenderingSettings` / `SetRenderingSettings` configure what the Render dialog *would*
530
+ do, `Document` has `ExportImage` and `SaveToProjectAsImage` and no render method at all, and
531
+ `IPhotoRenderContext` is the hook for a *third-party* renderer to receive geometry through
532
+ `CustomExporter`, not a way to run Revit's own. What comes out is the view exactly as drawn on
533
+ screen, which is why `views/set-style` is the setting that matters - a `Realistic` export shows
534
+ materials and RPC content properly, a `HiddenLine` one does not. Same view on `moradia.rvt`:
535
+ 44 KB hidden-line, 457 KB realistic.
536
+ - **`views/create-drafting` is a view of nothing**, which is the point: a drafting view carries
537
+ linework and annotation and no model geometry at all. It is what a detail sheet is made of - fill
538
+ it with `detail/lines` and `detail/text`, then put it on a sheet like any other view.
539
+ - **`views/create-plan` takes a view family type by NAME.** `viewFamilyType` is the name Revit shows
540
+ for a `ViewFamilyType` in this document - `"Site"`, `"Ceiling Plan"`, `"Structural Plan"` - not a
541
+ `ViewFamily` enum name, because a Site plan is an ordinary FloorPlan-family type and only its name
542
+ tells it apart from `"Floor Plan"`. Matching on the family would make a Site plan unreachable.
543
+ Omitted, it is the first FloorPlan type, exactly as before. Only the four families `ViewPlan.Create`
544
+ documents are accepted - "the type needs to be a FloorPlan, CeilingPlan, AreaPlan, or StructuralPlan
545
+ ViewType" - and anything else is a `BAD_REQUEST` carrying the plan type names the document does
546
+ have, rather than Revit's "This view family type is not a plan view type".
547
+ - **Scale is read back off the view, never echoed.** `views/create-plan`, `views/create-drafting`,
548
+ `views/create-section` and `views/set-scale` all report `scale` as `View.Scale` after the write, so
549
+ a view template that owns the scale shows up as a difference between what was asked for and what
550
+ came back instead of being invisible. `views/set-scale` validates once with `View.IsValidViewScale`,
551
+ which documents the range as 1 to 24,000, then works per view: a schedule, a sheet or a template
552
+ lands in `failed` with `VIEW_SCALE_NOT_SETTABLE` while the rest of the batch is still re-scaled and
553
+ still assimilates into one undo entry. Those three are refused up front on purpose - a schedule and
554
+ a sheet have no view scale, and a view template's scale is inherited by every view using it, so
555
+ re-scaling one from a batch would silently re-scale views nobody named. Anything else is attempted
556
+ and Revit's own refusal comes back per view as `REVIT_API_ERROR`.
557
+ - **A perspective view has no view scale, and that is a different endpoint, not a different number.**
558
+ `views/set-scale` refuses one with its own code, `PERSPECTIVE_VIEW_HAS_NO_SCALE`, rather than
559
+ letting Revit's exception on the setter come back as a bare `REVIT_API_ERROR`: 1/X describes a
560
+ projection and a camera is not one. What re-sizes a perspective on its sheet is
561
+ `views/scale-perspective-crop`, which wraps `View3D.ScalePerspectiveCropBox(double)` - Revit's own
562
+ method, present since 2024.1, which scales the crop box on both axes and, in its documentation's
563
+ words, "makes the change analogous to changing the scale of the orthographic view, so that both the
564
+ size and scale of the view on a sheet changes". So `multiplier` 2 doubles the view on the paper and
565
+ 0.5 halves it, with the proportions locked and the framing identical. It is **not** a reframe:
566
+ `views/set-crop` changes what is in shot, this changes the size of the shot, and the camera is never
567
+ touched - which is why the response carries `camera` on both sides and `cameraUnchanged` comparing
568
+ them, with `null` there meaning an orientation could not be read rather than that it moved. The four
569
+ checks - element exists, is a `View3D`, is not a template (Revit's method throws on one), is
570
+ perspective - all run before the transaction opens, so a refusal never half-applies. `dryRun`
571
+ defaults to **true** and reports `before` plus the requested `multiplier` and deliberately no
572
+ predicted `after`: the size Revit lands on is a readback, and the document is regenerated inside the
573
+ transaction before it is taken, because `View.Outline` and the viewport's box on the sheet are
574
+ derived geometry that otherwise still read as the size from before the call. Verified live on view
575
+ 223065 at `multiplier` 5.64896: `outline` 0.492 x 0.369 -> 2.78 x 2.085 paper feet, the viewport
576
+ 0.512 x 0.389 -> 2.8 x 2.105, `cameraUnchanged` true. Two measured nuances came out of that run and
577
+ are worth stating, because both look like faults and neither is: the crop box's **model**
578
+ coordinates did not change at all - composition is kept along with the camera, so `outline` and
579
+ `viewport` are what say the call worked and `cropBox` is not - and Revit left the view **title** at
580
+ its old paper position, so the label offset ended up inside the enlarged image and had to be reset
581
+ through `sheets/set-viewport-position`. That reset is deliberately a separate call: where a view
582
+ title sits is a drawing decision, and an endpoint that re-sizes a view does not get to make it.
583
+ - **`views/create-legend` duplicates, because the Revit API cannot create a legend.** This is a
584
+ limitation, not a design choice, and it is worth stating exactly: the API exposes **no legend view
585
+ type at all** (there is no public `ViewLegend`, and no `LegendComponent`); `ViewPlan.Create` takes
586
+ only the four plan families quoted above; and `ViewDrafting.Create` documents
587
+ `ArgumentException` - "viewFamilyTypeId is not a valid ViewFamilyType for a drafting view" - so the
588
+ Legend `ViewFamilyType` every template carries has nothing that will accept it. The documented way
589
+ through is `View.Duplicate`, which is what this endpoint does: `fromLegendId` picks the source,
590
+ omitted it is the first legend `views/legends` would list, and the copy is made with
591
+ `ViewDuplicateOption.Duplicate` so the new legend comes through **empty** rather than carrying
592
+ someone else's key. A document with no legend at all answers `NO_LEGEND_TO_DUPLICATE` and says so
593
+ in the message; it does **not** quietly substitute a drafting view, because a drafting view cannot
594
+ be placed on many sheets and a legend can. What can then be put in it is `detail/lines` and
595
+ `detail/text`. Legend components cannot - there is no creation API for them - and the bridge does
596
+ not pretend there is.
597
+ - **`parameters/create-project` borrows the shared parameter file and gives it back.** A project
598
+ parameter in Revit is a shared parameter definition plus a binding, and the definition lives in a
599
+ text file named by `Application.SharedParametersFilename` - a user-wide Revit setting, not a
600
+ property of the model. When it is unset (or names a file that is gone) the bridge points Revit at
601
+ its own file in the temp folder, `revit-mcp-shared-parameters.txt`, for the length of the call and
602
+ restores the original value in a `finally`. The file is stable across calls on purpose: the same
603
+ file means the same GUID for a parameter of the same name, so re-running a call cannot mint a
604
+ second definition that Revit would treat as a different parameter. `type` defaults to `Text`
605
+ (`SpecTypeId.String.Text`), `group` to `IdentityData`, and `instance` to true - a `TypeBinding`
606
+ otherwise. A name already bound in the document is **not** an error: nothing is created,
607
+ `alreadyExisted` is true, and `categories` reports what it is really bound to.
608
+ - **`sheets/set-parameter` is not `parameters/set` with a different name.** `parameters/set` writes
609
+ ONE name and ONE value across a list of ids, all-or-nothing. This one writes a **different** value
610
+ per sheet - which is what stamping a phase across 56 sheets is - in one `TransactionGroup` with one
611
+ inner transaction per sheet, so a sheet that has no such parameter lands in `failed` with
612
+ `PARAMETER_NOT_FOUND` while the other 55 are written. Both write instance parameters only; they
613
+ share the same storage-type switch (`WriteEndpoints.ApplyValue`), so a value is coerced identically
614
+ either way.
615
+ - **Sheet grouping in the Project Browser is not settable from the API, so there is no endpoint for
616
+ it.** `BrowserOrganization` is read-only in both the 2025 assemblies this add-in compiles against
617
+ and the installed 2027 ones. The whole public surface is
618
+ `GetCurrentBrowserOrganizationFor{Sheets,Views,Schedules}(document)`, `GetFolderItems(elementId)`,
619
+ `AreFiltersSatisfied(elementId)`, and three **get-only** properties -`SortingParameterId`,
620
+ `SortingOrder` and `Type`. There is no setter, no `Create`, no way to add a folder rule and no way
621
+ to apply one; `FolderItemInfo` is equally a read-only report. Shipping a `browser/organize-sheets`
622
+ that quietly did nothing would be worse than not having one, so the bridge has none. Two things do
623
+ work: sheets sort by sheet number, so a numbering scheme like `L.02.xxx` / `L.03.xxx` already
624
+ groups a set in reading order without any parameter at all; and the folder organisation is a
625
+ one-off UI setting - right-click **Sheets** in the Project Browser > **Browser Organization** -
626
+ pointed at the parameter `parameters/create-project` created and `sheets/set-parameter` filled in.
627
+ It is saved in the model once it is set.
628
+ - **`detail/lines` and `detail/text` supply the view plane themselves.** `NewDetailCurve` refuses a
629
+ curve that is not in the plane of the view, and `View.Origin` is documented as "not meaningful"
630
+ for a plan, so the elevation comes from the plan's own level (a drafting view is simply 0) and the
631
+ response reports it in `elevation`. `x`/`y` are the view's plan coordinates in feet.
632
+ `detail/lines` refuses anything that is not a drafting view, a plan or a legend with
633
+ `VIEW_CANNOT_HOST_DETAIL`; `detail/text` is wider - a section or an elevation may be annotated -
634
+ and only refuses schedules, sheets and templates, with `VIEW_CANNOT_HOST_TEXT`. An unknown
635
+ `lineStyle` is **not** a failure: the lines are drawn in the view's default style and the response
636
+ carries `requestedLineStyle` plus `availableLineStyles`, the same shape `schedules/create` uses
637
+ for fields. Text size lives on the type rather than on the note, so a `size` no loaded type
638
+ carries means a duplicated `TextNoteType` - once per distinct size, in its own transaction,
639
+ reusing any existing type whose `TEXT_SIZE` already matches.
640
+ - **`sheets/place-view` branches on the view kind so the caller does not have to.** A `ViewSchedule`
641
+ goes on a sheet through `ScheduleSheetInstance.Create`, everything else through `Viewport.Create`;
642
+ `kind` in the response says which was used. A view already on a sheet is `VIEW_ALREADY_PLACED`
643
+ (Revit allows a view on exactly one sheet — duplicate it to show it twice); a view
644
+ `Viewport.CanAddViewToSheet` refuses is `CANNOT_PLACE`. A schedule is the exception to the first
645
+ rule: it may legitimately appear on several sheets, so only a repeat of the *same* sheet/schedule
646
+ pair is refused. The **legend** case is a known sharp edge: Revit allows one legend on many sheets
647
+ and the bridge does not, because there is one rule for viewports. `x`/`y` are sheet coordinates in
648
+ feet on the paper — an A1 sheet is 1.95 × 1.38 — and default to the middle of `ViewSheet.Outline`.
649
+ The array form (`placements`) catches per placement and answers `{placed, failed}`; the single
650
+ form throws, so the codes surface as real errors.
651
+ - **`schedules/create` reports unknown fields instead of failing.** Names are matched against
652
+ `ScheduleDefinition.GetSchedulableFields()` for that category; anything unmatched goes into
653
+ `skippedFields`, and the response then also carries `availableFields` — the exact names that
654
+ category does offer — so the caller can correct itself without a second round trip.
655
+ - **`directshape/create`, `planting/place`, `pipes/create` and `sprinklers/place` exist because
656
+ family content is optional.** They also carry `comments` and `mark` onto every element they
657
+ build, give every element a real `DirectShapeType`, and build its solids with the material named
658
+ in `materialId`. See [Geometry without families](#geometry-without-families).
659
+ - **`materials`, `materials/create`, `materials/assign`, `walltypes/create` and `floortypes/create`
660
+ are the only way anything here stops being grey.** Which route applies depends on the element and
661
+ the two are not interchangeable — see [Materials](#materials). `materials/set-texture` is the
662
+ step after that: a colour is flat paint, a bitmap is a texture.
663
+ - **The whole `document/*` family is deliberately not transacted.** Creating, opening, saving and
664
+ closing a document are not transactable model edits and Revit throws if they are attempted inside
665
+ a transaction, so none of them goes through `RevitWrite`. They are also where the
666
+ no-active-document rule is least uniform, on purpose: `document/new` and `document/open` work with
667
+ nothing open (that is the state Revit is in when a caller asks for a new project); `document/save`
668
+ and `document/save-as` require an active document; `document/close` requires nothing and answers
669
+ `{"closed": false}` when there is nothing to close, so a caller tidying up never has to ask first.
670
+ - **Neither `document/new` nor `document/save-as` overwrites unless it is told to.** An existing
671
+ path is a `FILE_EXISTS` failure; both take `{"overwrite": true}` to replace it, and a missing
672
+ parent directory is created by both. `document/new`'s overwrite is the *rebuild in place* route,
673
+ and it is a real teardown: the file is closed in Revit if it is open (same detour as
674
+ `document/close`), then it and the `<name>.0001.rvt` backups Revit wrote beside it are deleted,
675
+ then the project is built again at that same path. A project is meant to live at one path and be
676
+ rebuilt there — not versioned into a new filename per attempt, which leaves the user a folder of
677
+ junk. Anything still holding a file is a `FILE_LOCKED` naming that exact path; the bridge never
678
+ falls back to building under a different name.
679
+ - **`document/save` refuses a model that has never been saved** with `NOT_SAVEABLE` rather than
680
+ letting Revit open its Save As file browser — which, on an unattended session, is a parked main
681
+ thread and a `REVIT_BUSY` for every request after it. Use `document/save-as`.
682
+ - **`document/close` discards unsaved changes** unless `{"save": true}`, and it can close the
683
+ *active* document, which `Document.Close` on its own cannot: Revit throws "The active document
684
+ may not be closed from the API". So the endpoint gives Revit something else to be active on
685
+ first — another open document, activated with `UIApplication.OpenAndActivateDocument` on its
686
+ `PathName` (on a file Revit already has open that is an activation, not a second open), or, when
687
+ this is the only document, a blank scratch project created with
688
+ `Application.NewProjectDocument(UnitSystem.Metric)`, saved under `%TEMP%` and activated the same
689
+ way. The response says what took over in `activePath` / `activeTitle` / `activeIsScratch`. The
690
+ scratch is left open on purpose: it costs nothing and the next close finds it as the stand-in
691
+ instead of making another. If the scratch cannot be stood up at all, the answer is
692
+ `LAST_DOCUMENT_CANNOT_CLOSE` carrying Revit's own reason — never a pretend `{"closed": true}`.
693
+ - **`diagnostics` is a read, `diagnostics/config` is the switch.** Neither needs an active document.
694
+ See [Unattended operation](#unattended-operation) for why a caller that writes should be reading
695
+ the first one.
696
+ - **`reload` is answered by the loader, not by this route table.** It replaces the assembly every
697
+ other endpoint lives in — see [Hot reload](#hot-reload).
698
+ - Any request may carry a top-level **`timeoutMs`** to override the server-side wait for that call.
699
+
700
+ ### Geometry without families
701
+
702
+ `directshape/create`, `planting/place`, `pipes/create` and `sprinklers/place` build solids straight
703
+ into the project document, with no family and no family template behind them.
704
+
705
+ They exist because **family content is an optional Autodesk download**, and a perfectly working
706
+ Revit install can have none of it. When the library is genuinely absent there is no `.rfa` to load
707
+ and no `.rft` family template to author a replacement from either, so `families/symbols` answers
708
+ `[]` and `families/place` has nothing to place.
709
+
710
+ **This is the fallback, not the default.** `families/symbols` coming back empty almost always means
711
+ the library is installed and simply has not been loaded into *this* project yet — the library sits
712
+ outside the model, under `C:\ProgramData\Autodesk\RVT <year>\Libraries\<language>\`, and
713
+ `families/load` is what brings it in. Check there before reaching for these four: a garden built out
714
+ of spheres on cylinders looks exactly like a garden built out of spheres on cylinders.
715
+
716
+ `DirectShape` needs neither. `GeometryCreationUtilities` builds the solid,
717
+ `DirectShape.CreateElement(doc, new ElementId(BuiltInCategory.X))` hangs it off a real Revit
718
+ category, and the result is a proper element — visible, selectable, categorised, and schedulable by
719
+ `schedules/create`.
720
+
721
+ `category` is a `BuiltInCategory` name **without** the `OST_` prefix (`Planting`,
722
+ `LightingFixtures`, `Furniture`, `Site`, `Walls`, `GenericModel`), matched case-insensitively; the
723
+ prefixed form is accepted too. A name that is not a category, or one `DirectShape.IsValidCategoryId`
724
+ rejects, is a `BAD_REQUEST` listing examples.
725
+
726
+ Each entry in `shapes` is one element. The primitives, all in decimal feet:
727
+
728
+ | `kind` | Fields | Built with |
729
+ | --- | --- | --- |
730
+ | `cylinder` | `base: {x,y,z}`, `radius`, `height` | circle (two half arcs) extruded +Z |
731
+ | `box` | `min: {x,y,z}`, `max: {x,y,z}` | rectangle at `min.z` extruded +Z by `max.z - min.z` |
732
+ | `sphere` | `center: {x,y,z}`, `radius` | half-disc in the XZ plane revolved a full turn about +Z |
733
+ | `cone` | `base: {x,y,z}`, `radius`, `height` | triangle revolved a full turn about +Z |
734
+ | `extrusion` | `profile: [{x,y}]`, `baseZ?`, `height` | closed polygon at `baseZ` extruded +Z |
735
+
736
+ An entry may instead be `{kind: "group", name?, parts: [primitive, ...]}`, which produces **one**
737
+ element carrying several solids — that is how a tree is a trunk plus a crown in a single schedulable
738
+ element rather than two unrelated ones. Groups do not nest.
739
+
740
+ The circle is two half arcs on purpose: `CreateExtrusionGeometry` refuses a `CurveLoop` made of a
741
+ single closed curve.
742
+
743
+ `planting/place` is the convenience wrapper over that: one element per point in the `Planting`
744
+ category, each a trunk cylinder with a crown sphere sitting on top of it (the sphere's underside
745
+ touches the top of the trunk). Defaults in **feet** are `trunkHeight` 8, `trunkRadius` 0.5,
746
+ `crownRadius` 6 — a 20 ft tree.
747
+
748
+ `pipes/create` and `sprinklers/place` are the same idea for irrigation, because there is no MEP
749
+ family content either. A run becomes **one** element built from a cylinder per segment between
750
+ consecutive points — a cylinder per segment on purpose, not a real sweep along the polyline, which
751
+ is a pile of failure modes for a result nobody can tell apart at 1:100. `radius` is per run and
752
+ defaults to 0.08 ft; a sprinkler is a small cylinder, `radius` 0.15 and `height` 0.5 ft by default.
753
+
754
+ Both of those pick their category at runtime:
755
+
756
+ | Endpoint | Preferred category | Fallback |
757
+ | --- | --- | --- |
758
+ | `pipes/create` | `OST_PipeCurves` | `OST_GenericModel` |
759
+ | `sprinklers/place` | `OST_Sprinklers` | `OST_GenericModel` |
760
+
761
+ The preferred one is used **only if `DirectShape.IsValidCategoryId` accepts it in that document**.
762
+ Which categories can hold a `DirectShape` is Revit's own rule, it is not published, and it has
763
+ changed between releases — so the bridge asks rather than assumes, and **every row of the response
764
+ says which category the element actually ended up in**. Read it instead of guessing; a schedule of
765
+ the wrong category is an empty schedule. `pipes/create` takes an explicit `category` to override the
766
+ choice, and that one is validated the same way `directshape/create` validates its own.
767
+
768
+ Every element these four build can carry **`comments`** and **`mark`**, written to
769
+ `ALL_MODEL_INSTANCE_COMMENTS` and `ALL_MODEL_MARK` after creation and read straight back off the
770
+ element into the response. Per entry wins; a top-level `comments`/`mark` is the fallback for every
771
+ entry that does not carry its own. This is the difference between a "Mapa de quantidades
772
+ plantações" that reads `Planting: 38` and one that breaks down by species, because a schedule can
773
+ group by either parameter.
774
+
775
+ All four are one `TransactionGroup` for the whole batch with one inner transaction per entry, so a
776
+ solid Revit refuses rolls back alone and lands in `failed` with its `index`. The one qualification
777
+ is where the solids are built: `directshape/create` builds each entry's inside the per-entry catch,
778
+ while `planting/place`, `pipes/create` and `sprinklers/place` build theirs up front — so a malformed
779
+ run or a non-positive radius is a `BAD_REQUEST` for the whole call rather than one entry in
780
+ `failed`.
781
+
782
+ #### Every element gets a DirectShapeType
783
+
784
+ A `DirectShape` created by `DirectShape.CreateElement` alone has **no type at all**, and that is not
785
+ cosmetic: "Edit Type" in the Properties palette does nothing, the element cannot be scheduled or
786
+ filtered by type, and it has no type parameters to read or write. All four endpoints therefore
787
+ create the element's type as well — `DirectShapeType.Create(doc, name, categoryId)`, then
788
+ `DirectShape.SetTypeId` — and every created row reports the `typeId` and `typeName` it landed on.
789
+
790
+ The type name is `typeName` when the caller gives one; otherwise it follows the element's own
791
+ `name`, and failing that the category. That default is the useful one: a `planting/place` call whose
792
+ points already carry species names gets **a type per species** without asking for anything, and a
793
+ `directshape/create` with no names at all still gets one type named `Site` rather than none.
794
+
795
+ Types are **looked up before they are created**, by name within the category, both across calls and
796
+ within one: planting 140 trees of four species makes four types, not 140. The lookup is a
797
+ `FilteredElementCollector` over `DirectShapeType` the first time a name is seen in a call and a
798
+ dictionary hit after that, and the dictionary is only filled once the inner transaction has
799
+ committed — a type created in a transaction that then rolled back never existed, and caching its id
800
+ would hand the next entry a dead `ElementId`.
801
+
802
+ `SetTypeId` is called **before** `SetShape`, and once: the API documents it as settable a single
803
+ time, and the geometry wanted on the element is the instance's own. The type carries no shape.
804
+
805
+ #### Material comes from the solid, so it is an argument here
806
+
807
+ `materialId` (or `materialName`) on any of the four is applied through
808
+ `SolidOptions(materialId, graphicsStyleId)` handed to `GeometryCreationUtilities`, so every solid in
809
+ the batch is **built** carrying the material. There is no version of this that happens afterwards: a
810
+ `Solid`'s material is fixed at construction, which is why `materials/assign` refuses a `DirectShape`
811
+ and says what to do instead rather than quietly succeeding. The graphics style is
812
+ `InvalidElementId` — the caller asked for a material, not a subcategory.
813
+
814
+ When no material is named, the geometry is built through the **three-argument** overloads exactly as
815
+ it always was. Passing `SolidOptions` carrying `InvalidElementId` would probably mean the same
816
+ thing, and "probably" is not a reason to change how every existing call builds its geometry.
817
+
818
+ ### Materials
819
+
820
+ `materials` reads, `materials/create` authors, `materials/set-texture` gives one a real bitmap (see
821
+ [Textures](#textures)), and `materials/assign` puts one on elements that already exist.
822
+ `walltypes/create` and `floortypes/create` are here too, because for a wall or a floor the material
823
+ is not a property of the element at all.
824
+
825
+ **There are two routes onto an element and they are not interchangeable:**
826
+
827
+ | The element | Where its material lives | How it gets one |
828
+ | --- | --- | --- |
829
+ | Anything from `directshape/create`, `planting/place`, `pipes/create`, `sprinklers/place` | on each `Solid`, fixed at construction | `materialId` on the endpoint that builds it |
830
+ | `Wall`, `Floor`, roof, ceiling — and `Toposolid`, whose `ToposolidType` is a `HostObjAttributes` too | on the **type**, in its `CompoundStructure` | `walltypes/create` / `floortypes/create`, then build with that type — or `materials/assign`, which edits the type |
831
+ | A family instance with a material parameter | on the instance | `materials/assign` |
832
+
833
+ `materials/create` sets `Color`, and `Transparency` (0–100) and `Shininess` (0–128) when they are
834
+ asked for — omitted, Revit's own defaults are left alone rather than overwritten with a zero nobody
835
+ asked for. It also sets **`UseRenderAppearanceForShading` to false** for a colour-only material:
836
+ that property is documented as the switch between "shaded views use the render appearance" and
837
+ "shaded views use `Color` and `Transparency`", and a material created for its colour is no use if
838
+ shaded views ignore it. A material given an `appearanceAssetId` gets `true` instead, because then
839
+ the asset is the point.
840
+
841
+ A name that already exists is **reused**, not overwritten: the response is `{"created": false}` plus
842
+ the material's **current** colour, transparency and shininess. Re-running the same call is therefore
843
+ safe, and a caller that gets back a colour it did not ask for is looking at a material somebody else
844
+ authored — worth knowing rather than trampling.
845
+
846
+ ### Textures
847
+
848
+ `materials/set-texture` connects a real bitmap to a material's appearance asset, and
849
+ `materials/create`'s `texturePath` does the same thing at creation time. The route, all of it
850
+ verified against a live Revit 2027:
851
+
852
+ ```
853
+ AppearanceAssetElement.Create(document, name, <library "Generic" asset>) // the asset to edit
854
+ using (Transaction) // REQUIRED, see below
855
+ using (AppearanceAssetEditScope scope)
856
+ Asset editable = scope.Start(assetElementId)
857
+ AssetProperty diffuse = editable.FindByName("generic_diffuse")
858
+ diffuse.AddConnectedAsset("UnifiedBitmap")
859
+ Asset bitmap = diffuse.GetSingleConnectedAsset()
860
+ bitmap.FindByName("unifiedbitmap_Bitmap") -> AssetPropertyString.Value = path
861
+ bitmap.FindByName("texture_RealWorldScaleX/Y") -> AssetPropertyDistance.Value = size
862
+ bitmap.FindByName("texture_WAngle") -> AssetPropertyDouble.Value = degrees
863
+ scope.Commit(true)
864
+ ```
865
+
866
+ **`AppearanceAssetEditScope.Commit` needs a transaction already open around it.** Without one it
867
+ throws `InvalidOperationException: EditScope cannot be closed, there is no opened transaction` —
868
+ and it throws it at `Commit`, after every edit has apparently succeeded, which is exactly the shape
869
+ of failure that looks like working code. `Start` succeeds either way and `scope.IsActive` is `true`
870
+ either way, so neither tells you. The scope lives inside `RevitWrite.InTransaction` for that reason.
871
+
872
+ **The schema problem, and the answer to it.** An asset's properties come from the schema it was
873
+ built from: `Generic` has `generic_diffuse`, `Ceramic` has `ceramic_color`, `MasonryCMU` has
874
+ `masonrycmu_color`, the Prism schemas have `opaque_albedo`, and `Water` and `Metal` have no
875
+ bitmap-bearing colour property at all. Editing whatever asset a material happens to carry is
876
+ therefore guesswork. Instead the material is **given** an asset created from Revit's own library
877
+ `Generic` asset — `Application.GetAssets(AssetType.Appearance)` returns ~3100 of them on a stock
878
+ 2027 install, and the one whose `Name` is exactly `Generic` yields a full 58-property Generic asset
879
+ — unless the material already has a Generic asset that no other material shares. The report says
880
+ `assetAuthored` and `assetReason` so which of those happened is never a mystery. Only if the
881
+ library has no `Generic` to offer does it fall back to the asset in hand, trying each schema's
882
+ diffuse property in turn. Anything the asset turns out not to carry goes into `missing` rather than
883
+ throwing.
884
+
885
+ **Units.** `texture_RealWorldScaleX/Y` are `AssetPropertyDistance`, and on this machine
886
+ `GetUnitTypeId()` is `autodesk.unit.unit:inches` — a fresh bitmap defaults to `12`. `scale` is taken
887
+ in feet like every other length in this bridge and converted with `UnitUtils.ConvertFromInternalUnits`
888
+ rather than written raw, and `verified.scaleFeet` converts back so the caller reads what it asked
889
+ in. `texture_ScaleLock` ships `true`; an `x` and `y` that differ set it `false`, because different
890
+ sizes with the lock on is a state the UI would not let anyone author.
891
+
892
+ **Two side effects worth knowing.** Assigning a new appearance asset makes Revit repaint the
893
+ material's shading `Color` to match it — black, for a freshly created Generic one — so the colour
894
+ the material had is written back afterwards and reported in `colorRgb`: this endpoint textures a
895
+ material, it does not repaint it. And a material being textured gets
896
+ `UseRenderAppearanceForShading = true`, because a texture a shaded view ignores is not worth much.
897
+
898
+ **The bitmap is referenced, not embedded.** `unifiedbitmap_Bitmap` holds a path, so a path that does
899
+ not exist is an asset that renders nothing — `File.Exists` is checked up front and a miss is
900
+ `TEXTURE_NOT_FOUND` (404), before a material is created in the `materials/create` case. Revit ships
901
+ its own texture library under `C:\Program Files\Common Files\Autodesk Shared\Materials\Textures\`
902
+ (`1\Mats\`, `2\Mats\`, `3\Mats\` — around 5400 files: `grass_color.jpg`, `fieldstone_bump.jpg`,
903
+ `Finishes.Flooring.Wood.Plank.jpg`, `Sitework.Planting.Soil.jpg`, `water_calm.png`).
904
+
905
+ `materials/create`'s `texturePath` takes the bitmap only — scale, rotation and tint are
906
+ `materials/set-texture`'s business — and it does nothing for a material that already exists, which
907
+ still comes back untouched with `created: false` as it always has.
908
+
909
+ **What `appearanceAssetId` on `materials/create` still is:** the other half of this, and unchanged.
910
+ It **duplicates** an existing asset in the document (`AppearanceAssetElement.Duplicate`, which
911
+ duplicates the asset it holds) and assigns the copy, which is the documented way to give a new
912
+ material an existing material's rendered look without the two sharing one asset. `set-texture`
913
+ follows the same rule for the same reason: a shared asset is duplicated before it is edited, so one
914
+ material's texture can never appear on another's surfaces.
915
+
916
+ `materials/assign` takes the compound-structure route first for anything that has one — an element
917
+ that **is** a `HostObjAttributes` (a wall or floor type named directly) or a `HostObject` whose type
918
+ is one — and sets **every layer** of that structure. That changes every element of the type, which
919
+ is not what "assign to these elements" sounds like, so the row says `"appliesToType": true` and
920
+ names the type it edited. Otherwise it looks for a writable `ElementId` parameter, in order:
921
+ `STRUCTURAL_MATERIAL_PARAM`, `MATERIAL_ID_PARAM`, then a parameter literally called `Material`,
922
+ which is what a family author's own material parameter is usually called.
923
+
924
+ Everything else lands in `skipped` with a reason, and the reason is never a shrug. A `DirectShape`
925
+ gets the specific one — its material is carried by each solid and fixed when the solid was built, so
926
+ the fix is `materialId` on the endpoint that built it. One inner transaction per element, so an
927
+ element Revit refuses rolls back alone.
928
+
929
+ `walltypes/create` and `floortypes/create` **duplicate** an existing type — there is no
930
+ `WallType.Create` — and give the duplicate a single-layer `CompoundStructure`
931
+ (`CompoundStructure.CreateSingleLayerCompoundStructure(MaterialFunctionAssignment.Structure, width,
932
+ materialId)`). An omitted `thickness` keeps the source type's width and an omitted material keeps
933
+ the source's first-layer material, so either can be set without disturbing the other; with neither,
934
+ the call is a plain duplicate and the source's layering is left alone. A name that already exists is
935
+ reused with `{"created": false}` and is **not** re-cut to match the request — the response reports
936
+ the thickness and material the type really carries, read back off its structure. A source with no
937
+ compound structure at all (a curtain or stacked wall type) is a `BAD_REQUEST` naming it.
938
+
939
+ **The end cap condition is set per type, never inherited.** A `CompoundStructure` also carries an
940
+ `EndCap` — which shell layers wrap at the ends — and only a `WallType` may carry a real one.
941
+ `CreateSingleLayerCompoundStructure` hands back a wall's, so passing it straight to a `FloorType`
942
+ fails every time with `Input compound structure has wrong EndCap condition for this element type`.
943
+ Anything that is not a `WallType` therefore gets `EndCap = EndCapCondition.NoEndCap` before
944
+ `SetCompoundStructure`, which is the value the API documents as the one "floors and roofs must use".
945
+ It is **`NoEndCap`, not `None`**: `EndCapCondition.None` is a wall's "none of the shell layers
946
+ participate in end wrapping", still a wall-only condition, and a floor type rejects it just the
947
+ same. Walls keep exactly what Revit built, which is why `walltypes/create` never hit this. Any
948
+ future non-wall host type authored here — a ceiling, a roof, a `ToposolidType` — must say
949
+ `NoEndCap` too.
950
+
951
+ Then `walls/create` takes the new type by name — as `wallType`, or as `typeName`, which is what
952
+ every other type-taking endpoint here calls it — and `floors/create` already took `typeName`, so a
953
+ type authored this way is selected by name with no further work.
954
+
955
+ ### Units
956
+
957
+ **Revit internal units — decimal feet — in both directions, unconverted.** Elevations, heights and
958
+ coordinates are all raw API values. The bridge deliberately converts nothing, so `levels` and
959
+ `levels/create` are symmetric.
960
+
961
+ The one exception is `materials/set-texture`'s `scale`, and it exists to keep that promise rather
962
+ than break it: an appearance asset's `texture_RealWorldScaleX/Y` is an `AssetPropertyDistance` in
963
+ **inches**, not feet, so the value is converted on the way in and back again in `verified.scaleFeet`.
964
+ A caller still speaks feet everywhere.
965
+
966
+ ### Errors
967
+
968
+ Every failure, at any status code, uses one shape:
969
+
970
+ ```json
971
+ { "error": { "code": "NO_ACTIVE_DOCUMENT", "message": "Revit has no active document...", "stack": "..." } }
972
+ ```
973
+
974
+ `message` and `stack` are the contract the Node client reads. `code` is an additive extra so a tool
975
+ can branch on a condition without string-matching prose.
976
+
977
+ | Status | `code` | When |
978
+ | --- | --- | --- |
979
+ | 400 | `BAD_REQUEST`, `BAD_JSON` | Malformed body, missing or wrong-typed field, unknown category/level/wall/floor/toposolid type, unknown material, a colour channel outside 0-255 or a transparency/shininess outside Revit's range, unknown `detailing` or `kind` |
980
+ | 404 | `UNKNOWN_PATH`, `UNKNOWN_ENDPOINT`, `ELEMENT_NOT_FOUND`, `PARAMETER_NOT_FOUND`, `TEMPLATE_NOT_FOUND`, `FILE_NOT_FOUND`, `TEXTURE_NOT_FOUND`, `GENERIC_ASSET_UNAVAILABLE` | No such route, no such element, no template, no file at the path `document/open` was given, no file at the `texturePath` a material was to be textured with, or no `Generic` asset in Revit's library to build an appearance from |
981
+ | 405 | `METHOD_NOT_ALLOWED` | Anything that is not POST |
982
+ | 409 | `NO_ACTIVE_DOCUMENT`, `NOT_A_PROJECT_DOCUMENT`, `FILE_EXISTS`, `FILE_LOCKED`, `LAST_DOCUMENT_CANNOT_CLOSE`, `NO_TITLEBLOCK`, `NOT_SAVEABLE`, `NO_VIEW_FAMILY_TYPE`, `NO_TEXT_NOTE_TYPE`, `NO_TOPOSOLID`, `VIEW_ALREADY_PLACED`, `CANNOT_PLACE`, `CANNOT_DUPLICATE`, `VIEW_CANNOT_HOST_DETAIL`, `VIEW_CANNOT_HOST_TEXT` | No document open, a family document where a project is required, a path that already exists, a file an overwrite could not delete because something still holds it, the only open document asked to close with no stand-in Revit would accept, no title block family loaded, a `document/save` on a model that has never been saved, no floor-plan/section/drafting view family type or no default text type in the template, no toposolid to flatten, a view already on a sheet, a view Revit will not put on that sheet, a view Revit will not duplicate that way, a view that is not a drafting view or a plan asked for detail lines, or a schedule/sheet/template asked for text |
983
+ | 500 | `REVIT_API_ERROR`, `INTERNAL_ERROR`, `RELOAD_FAILED` | The Revit API threw, an unexpected bug, or the handlers assembly could not be reloaded (the previous one is still serving) |
984
+ | 503 | `REVIT_BUSY`, `BRIDGE_NOT_READY`, `BRIDGE_SHUTTING_DOWN` | Revit did not pick the work up in time, or the handlers assembly is not loaded |
985
+
986
+ **No active document returns a structured 409, never a NullReferenceException.** It is the second
987
+ most common real-world failure — a request arrives while Revit sits on the start page or between
988
+ documents. The guard is **not central**: every endpoint opts in by calling
989
+ `RevitFacts.RequireDocument`. `status` does not, since it is how a client discovers the fact, and
990
+ neither does `document/new`, which has to work on a Revit with nothing open.
991
+
992
+ ---
993
+
994
+ ## Why the ExternalEvent pump exists
995
+
996
+ This is the part of the design that is not negotiable, so it is worth understanding before changing
997
+ anything in `RevitApiContext`.
998
+
999
+ **Revit API calls are only legal on Revit's main thread, from inside an event Revit itself raises.**
1000
+ `HttpListener` hands requests to arbitrary thread-pool threads. Touching a `Document` from one of
1001
+ those is undefined behaviour: usually an immediate "attempting to modify the model outside of a
1002
+ transaction" style exception, sometimes a hard crash that takes the user's unsaved model with it.
1003
+
1004
+ So every request crosses the thread boundary through a single pump:
1005
+
1006
+ ```
1007
+ listener thread Revit main thread
1008
+ --------------- -----------------
1009
+ enqueue work item
1010
+ externalEvent.Raise() ───────────────► Execute(UIApplication)
1011
+ block on ManualResetEventSlim drain queue, run each item,
1012
+ (default 30 s) capture result OR exception
1013
+ ◄─────────────────────────────── signal the item
1014
+ read result / rethrow
1015
+ ```
1016
+
1017
+ Details that are load-bearing:
1018
+
1019
+ - **It is a queue, not a single slot.** Two concurrent requests must not clobber each other's
1020
+ result.
1021
+ - **Exceptions are captured and rethrown** on the calling thread via `ExceptionDispatchInfo`, so the
1022
+ original stack survives into the `stack` field of the error response.
1023
+ - **On timeout the bridge answers `503`**, not a hang and not a generic `500`. This is the single
1024
+ most common real-world failure: Revit is showing a modal dialog, so the main thread never becomes
1025
+ idle and never runs our handler. The message says so explicitly, because "request timed out" sends
1026
+ people looking in the wrong place.
1027
+ - **A timed-out work item is marked abandoned and skipped** rather than executed later. Silently
1028
+ mutating the model after the client has already been told the request failed would be the worst
1029
+ possible surprise. If the item had *already started* when the timeout fired it cannot be
1030
+ cancelled — a Revit API call is not interruptible — so the 503 message says the operation may
1031
+ still complete inside Revit.
1032
+ - **Accept and handle are separate threads.** The accept loop hands each request to the thread pool,
1033
+ so one request parked on the pump does not stall the listener for everybody else.
1034
+
1035
+ ### Transactions
1036
+
1037
+ Every write endpoint wraps its work in a **`TransactionGroup` that is `Assimilate()`d on success and
1038
+ `RollBack()`ed on failure**. That is what makes **one request equal exactly one Ctrl+Z**, and what
1039
+ guarantees a request failing half way through leaves no partial edit behind. `parameters/set` over
1040
+ five elements is all-or-nothing. This is a requirement of the bridge, not an optimisation — do not
1041
+ "simplify" a write endpoint down to a bare `Transaction`.
1042
+
1043
+ The group is also what makes a *partially* skippable batch possible: `sheets/create` opens one inner
1044
+ `Transaction` per sheet inside the one group, so the sheet whose number Revit refused rolls back
1045
+ alone while the other 28 still assimilate into a single undo entry.
1046
+
1047
+ The `document/*` endpoints are the exception, and deliberately so: creating, saving, closing and
1048
+ opening a document are not transactable model edits, and Revit throws if they are attempted inside a
1049
+ transaction. That is why they live in `DocumentEndpoints` rather than in `WriteEndpoints`.
1050
+
1051
+ Every transaction opened through `RevitWrite.InTransaction` also gets the bridge's
1052
+ `IFailuresPreprocessor` — see the next section. That is wired in centrally, in one place, precisely
1053
+ so no endpoint can forget it.
1054
+
1055
+ ---
1056
+
1057
+ ## Unattended operation
1058
+
1059
+ The bridge is meant to be driven with nobody at the keyboard, and that takes more than an HTTP
1060
+ listener. Revit stops and asks. A modal dialog parks the main thread inside its own message loop:
1061
+ the `ExternalEvent` pump never runs, and every request from that moment on answers `REVIT_BUSY`
1062
+ until a human clicks a button. A commit-time warning does the same thing from inside the bridge's
1063
+ own write.
1064
+
1065
+ Two pieces deal with it, and one exists to keep them honest.
1066
+
1067
+ **`BridgeDialogWatcher`** subscribes to `UIControlledApplication.DialogBoxShowing` and answers with
1068
+ `OverrideResult`: `1` (IDOK / `TaskDialogResult.Ok`) for an ordinary dialog, `2` (IDCANCEL) for
1069
+ anything whose id or message text matches a destructive-sounding word — delete, remove, unload,
1070
+ discard, overwrite, replace, purge, save, close, erase, detach, relinquish. The heuristic is crude
1071
+ on purpose: cancelling a harmless dialog costs one failed request, OK-ing a destructive one costs
1072
+ the user's model. It is subscribed **after** the listener starts, so the bridge's own "did not
1073
+ start" dialogs are still shown to the user rather than clicked away unseen.
1074
+
1075
+ **`BridgeFailuresPreprocessor`** is installed on every transaction `RevitWrite` opens. In order: a
1076
+ failure that offers a resolution gets `ResolveFailure`; otherwise a warning gets `DeleteWarning`;
1077
+ otherwise — an error with no resolution — it is left alone, because `DeleteWarning` refuses anything
1078
+ that is not a warning and suppressing an error would let a broken edit commit. Revit then fails the
1079
+ transaction and the whole `TransactionGroup` rolls back, exactly as it would have.
1080
+
1081
+ **`BridgeDiagnostics`** is the honesty half. Suppressed is not swallowed: every dismissed dialog
1082
+ (id, message, the result used) and every resolved warning (transaction name, severity, text, what
1083
+ was done) goes into a ring buffer of 200 entries each, served by `diagnostics`. Entries evicted past
1084
+ the cap are counted in `dropped`, so a truncated buffer never reads as a quiet one, and every entry
1085
+ is also written to `bridge.log` with the same timestamp format so the two can be lined up.
1086
+
1087
+ A caller that writes **must** read `diagnostics` afterwards. A silently resolved warning is often
1088
+ the model doing something the caller never asked for; without reading them, the bridge's convenience
1089
+ would be indistinguishable from data loss. `diagnostics/config` takes `{"autoDismiss": false}` to
1090
+ hand the dialogs back to a human — the right setting when somebody is working in Revit at the same
1091
+ time — and `{"clear": true}` to empty the buffer, which is worth doing before a batch so the read
1092
+ afterwards covers only that batch.
1093
+
1094
+ Dialogs seen while auto-dismiss is **off** are recorded too, with `"answered": false`. That is the
1095
+ one thing a caller staring at `REVIT_BUSY` most needs to know.
1096
+
1097
+ ---
1098
+
1099
+ ## Hot reload
1100
+
1101
+ The add-in is two assemblies:
1102
+
1103
+ ```
1104
+ RevitMcpBridge.dll the loader. Revit pins it for the session.
1105
+ IExternalApplication, HttpListener, ExternalEvent pump,
1106
+ dialog watcher, failures preprocessor, diagnostics, JSON.
1107
+ RevitMcpBridge.Handlers.dll the reloadable logic: the route table and every endpoint.
1108
+ ```
1109
+
1110
+ `HandlerAssembly` shadow-copies the handlers DLL to `%LOCALAPPDATA%\RevitMcpBridge\shadow\<stamp>\`
1111
+ and loads it there into a **collectible** `AssemblyLoadContext`. POSTing to `reload` loads a freshly
1112
+ built one, swaps it in, and retires the old context. The model stays open, the listener keeps its
1113
+ port, queued work is untouched — only the routing and endpoint logic changes.
1114
+
1115
+ The rules that make it work, each of which is load-bearing:
1116
+
1117
+ - **The loader never references the handlers assembly at compile time.** The dependency runs the
1118
+ other way: the handlers project references the loader, implements `IBridgeRouter` (declared in the
1119
+ loader) and is found by type name at runtime. A compile-time reference would bind the handlers in
1120
+ the default load context and nothing could ever unload. `InternalsVisibleTo` is what lets the two
1121
+ halves share `BridgeException`, `JsonBody` and the rest without going public.
1122
+ - **It is loaded from a shadow copy.** A loaded file is locked, and a locked file cannot be
1123
+ overwritten by the rebuild the reload exists to pick up. Old shadow directories are pruned
1124
+ best-effort; one still in use simply stays until a later start.
1125
+ - **The load context resolves nothing itself** (`Load` returns `null`), so the loader assembly, the
1126
+ Revit API and the framework all bind to the copies already loaded in the default context. Two
1127
+ copies of `RevitAPI` would break type identity in the most confusing way available.
1128
+ - **`reload` is answered by the loader, before the router is consulted.** It has to be: a router
1129
+ unloading the context its own method is executing in would be sawing the branch it stands on.
1130
+ Measured outside Revit — a frame still holding a reference to collectible code reliably prevents
1131
+ the unload from completing.
1132
+ - **Whether the old context went is measured, not assumed.** `Retire` nulls every field, calls
1133
+ `Unload`, and hands back a `WeakReference`; `WaitForUnload` collects a few times and reports what
1134
+ the `WeakReference` says. If something is still holding the old context — most likely a request
1135
+ that was still running — the response comes back with `"unloadedPrevious": false` and a warning
1136
+ saying the new logic is live but the old context leaked. That is deliberately not smoothed over.
1137
+ - **If the collectible load fails for any reason, the bridge falls back** to loading the handlers in
1138
+ the default context. It then works exactly as before but cannot hot-reload, `reload` says so in
1139
+ `collectible` and `warning`, and the reason is in `bridge.log`. A bridge that works but cannot
1140
+ reload beats a bridge that will not start.
1141
+
1142
+ What is **not** verified: all of the above was exercised outside Revit, against a stub handlers
1143
+ assembly — load, call, shadow-copy-while-loaded, unload, and a deliberate leak to confirm the check
1144
+ detects one. Whether Revit itself ends up holding a reference into the collectible context in some
1145
+ situation is not something a harness can answer. If `unloadedPrevious` comes back `false` on every
1146
+ reload, that is the signal it does.
1147
+
1148
+ ---
1149
+
1150
+ ## Why it binds 127.0.0.1 and not `+`
1151
+
1152
+ The listener prefix is the **`127.0.0.1` literal**, never `http://+:48884/` or `http://*:48884/`.
1153
+
1154
+ 1. **`+` and `*` are "strong" wildcard prefixes and http.sys refuses them to a non-elevated
1155
+ process** unless an administrator registered a URL ACL first (`netsh http add urlacl`). Revit
1156
+ normally runs unelevated, so a wildcard prefix means the bridge just fails to start with
1157
+ *Access is denied* on most machines. Measured on this machine, unelevated:
1158
+ `http://127.0.0.1:48884/` starts fine, while `http://+:48885/` fails with
1159
+ `HttpListenerException ErrorCode=5`.
1160
+ 2. **A wildcard binds every interface**, which publishes an unauthenticated remote-control API for
1161
+ the user's models onto the LAN. The loopback literal needs no admin URL ACL and is simply not
1162
+ reachable off-machine.
1163
+
1164
+ There is a comment saying exactly this in `HttpBridgeServer.cs` so nobody "helpfully" changes it.
1165
+
1166
+ The listener takes the whole port on loopback and the router enforces the `/revit-mcp` base path
1167
+ itself, so an unmatched path returns a structured JSON 404 instead of the raw http.sys 400 that a
1168
+ narrower prefix would produce.
1169
+
1170
+ ### Port already in use
1171
+
1172
+ Only one process can own the port, so a second Revit instance with the bridge installed will fail to
1173
+ bind. That is handled: the add-in **logs it, writes a Revit journal comment, shows a `TaskDialog`,
1174
+ and leaves Revit running normally** with the bridge inert for that session. It never takes Revit
1175
+ down. `HttpListener` reports this as `ErrorCode=183` (verified) or `32`, both of which get a message
1176
+ naming the likely cause.
1177
+
1178
+ ---
1179
+
1180
+ ## Configuration
1181
+
1182
+ | Variable | Default | Effect |
1183
+ | --- | --- | --- |
1184
+ | `REVIT_MCP_BRIDGE_TIMEOUT_MS` | `30000` | How long a request waits for Revit's main thread. Clamped to 1 s – 600 s. |
1185
+
1186
+ Per-request `timeoutMs` in the body overrides it. The port is fixed at 48884 by design.
1187
+
1188
+ Dialog auto-dismiss has no environment variable on purpose: it starts **on** and is changed through
1189
+ `diagnostics/config` at runtime, because whether a human is sitting in front of Revit is not a
1190
+ property of how the process was started.
1191
+
1192
+ **Log file:** `%LOCALAPPDATA%\RevitMcpBridge\bridge.log` (truncated past 1 MB). It is under
1193
+ LocalAppData deliberately — Windows Controlled Folder Access blocks writes to Documents and Pictures
1194
+ and surfaces them as misleading IO errors.
1195
+
1196
+ ---
1197
+
1198
+ ## Layout
1199
+
1200
+ ```
1201
+ revit-bridge/
1202
+ RevitMcpBridge.csproj the loader. net8.0-windows, x64, reference-only Revit API.
1203
+ Builds handlers\ after itself, into the same output folder.
1204
+ install.ps1 detect / install / uninstall, PS 5.1 compatible
1205
+ dist/ prebuilt add-in that ships on npm (committed) - BOTH assemblies
1206
+ src/ the loader assembly: everything that survives a reload
1207
+ BridgeApplication.cs IExternalApplication entry point (thin, and why)
1208
+ BridgeHost.cs owns the pump, the server, the dialog watcher, the handler host
1209
+ RevitApiContext.cs ExternalEvent pump: worker thread <-> Revit main thread
1210
+ HttpBridgeServer.cs HttpListener, 127.0.0.1 binding, port-in-use handling, reload route
1211
+ HandlerHost.cs which generation of the handlers assembly is current; reload
1212
+ HandlerAssembly.cs collectible AssemblyLoadContext, shadow copy, verified unload
1213
+ IBridgeRouter.cs the one thing the loader knows about the reloadable half
1214
+ BridgeDialogWatcher.cs answers Revit's modal dialogs (unattended operation)
1215
+ BridgeFailures.cs IFailuresPreprocessor: resolves commit-time warnings
1216
+ BridgeDiagnostics.cs ring buffers of what was suppressed; the auto-dismiss switch
1217
+ BridgeJson.cs serializer options + the one error shape
1218
+ JsonBody.cs request-body readers
1219
+ BridgeLog.cs rolling log file
1220
+ BridgeException.cs expected failures with an HTTP status + code
1221
+ BridgeInfo.cs assembly version (of either half)
1222
+ AssemblyInfo.cs InternalsVisibleTo the handlers assembly
1223
+ handlers/ the reloadable assembly: routing + endpoints
1224
+ RevitMcpBridge.Handlers.csproj
1225
+ src/
1226
+ RequestRouter.cs route table, JSON in/out, error mapping; implements IBridgeRouter
1227
+ Endpoints/
1228
+ ReadEndpoints.cs status, levels, categories, query, elements, selection, titleblocks, sheets
1229
+ WriteEndpoints.cs levels/create, walls/create, parameters/set, elements/delete, sheets/create
1230
+ ModelEndpoints.cs toposolid/create, floors/create, families/load, families/symbols,
1231
+ families/place, openings/place, walltypes/create, floortypes/create
1232
+ MaterialEndpoints.cs materials, materials/create, materials/set-texture,
1233
+ materials/assign
1234
+ ViewEndpoints.cs views, views/create-plan, views/create-section, views/create-3d,
1235
+ views/set-style, views/set-background, views/hide-categories,
1236
+ views/set-sun, views/export-image, views/duplicate,
1237
+ sheets/place-view, schedules/create
1238
+ DirectShapeEndpoints.cs directshape/create, planting/place, pipes/create, sprinklers/place -
1239
+ geometry with no family behind it, typed and with a material
1240
+ DocumentEndpoints.cs document/new, open, save, save-as, close - never transacted
1241
+ DiagnosticsEndpoints.cs diagnostics, diagnostics/config
1242
+ RevitWrite.cs TransactionGroup helper (one request == one Ctrl+Z) + failures wiring
1243
+ RevitFacts.cs document access, lookups, compact-row shape, point/loop readers
1244
+ ```
1245
+
1246
+ ### Building both halves
1247
+
1248
+ `dotnet build RevitMcpBridge.csproj -c Release` produces **both** assemblies into
1249
+ `bin\Release\net8.0-windows\`. The handlers project is not a `ProjectReference` — it references the
1250
+ loader, so a `ProjectReference` back would be a cycle — and is built instead by a `BuildHandlers`
1251
+ target that runs `Restore;Build` on it after the loader is compiled. That is why one build command
1252
+ still gives you a complete add-in, and why `install.ps1` (which copies every file it finds next to
1253
+ `RevitMcpBridge.dll`) needs no special knowledge of the split.
1254
+
1255
+ Shipping one half without the other is a bridge that does not start, so `scripts/build-bridge.mjs`
1256
+ requires all four files (both DLLs and both `.deps.json`) and fails the build if one is missing.