@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,746 @@
1
+ // Views, schedules, and what puts them on a sheet. This is the set that makes a
2
+ // sheet stop being an empty title block.
3
+ //
4
+ // Model coordinates are Revit internal units (decimal feet). Sheet coordinates
5
+ // are feet on the paper, not model feet — an A1 sheet is 1.95 x 1.38.
6
+ //
7
+ // The placement tool takes the whole batch in one call on purpose: the Revit
8
+ // side wraps it in one transaction group, so a phase of sheets is one undo step.
9
+
10
+ import { z } from "zod";
11
+
12
+ // A colour as Revit stores one: three 0-255 channels, no alpha.
13
+ const rgb = z.object({
14
+ r: z.number().int().min(0).max(255),
15
+ g: z.number().int().min(0).max(255),
16
+ b: z.number().int().min(0).max(255),
17
+ });
18
+
19
+ export function registerViewTools(server, bridge) {
20
+ server.tool(
21
+ "revit_list_views",
22
+ "List the non-template views in the document: id, name, view type, and whether each is already placed on a sheet. A view with a frame also carries viewDirection, rightDirection and upDirection as {x,y,z} — viewDirection is Revit's direction towards the VIEWER, so a section looking north reports {x:0,y:-1,z:0}. Sheets themselves are not listed — use revit_list_sheets for those. A view that is already on a sheet cannot be placed on another one; duplicate it first with revit_duplicate_view.",
23
+ {},
24
+ async () => {
25
+ try {
26
+ const result = await bridge.call("/views");
27
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
28
+ } catch (error) {
29
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
30
+ }
31
+ },
32
+ );
33
+
34
+ server.tool(
35
+ "revit_create_plan_view",
36
+ "Create a plan view on a level — a floor plan unless 'view_family_type' names another one. 'view_family_type' is the NAME Revit shows for a view family type in this document ('Site', 'Ceiling Plan', 'Structural Plan'), not a family enum: 'Site' and 'Floor Plan' are both floor-plan-family types and only the name tells them apart. An unknown name comes back as a BAD_REQUEST listing the plan type names this document does have, so you can correct it without guessing. 'scale' is the denominator X in 1:X and the response reports the scale the view actually ended up with, which is not always the one asked for when a view template controls it. If the name is already taken, a numeric suffix is appended rather than failing — the response says what the view is actually called. One call is one undo step.",
37
+ {
38
+ level: z.string().min(1).describe("Level name the plan is cut on"),
39
+ name: z.string().min(1).describe("View name. A taken name gets ' 2', ' 3', ... appended."),
40
+ view_family_type: z
41
+ .string()
42
+ .min(1)
43
+ .optional()
44
+ .describe(
45
+ "View family type name, e.g. 'Site' or 'Ceiling Plan'. Omit it for the first floor plan type. Only floor plan, ceiling plan, area plan and structural plan types are accepted — that is what ViewPlan.Create takes.",
46
+ ),
47
+ scale: z
48
+ .number()
49
+ .int()
50
+ .positive()
51
+ .optional()
52
+ .describe("View scale denominator, e.g. 100 for 1:100. Omit it to keep the type's default."),
53
+ },
54
+ async ({ level, name, view_family_type, scale }) => {
55
+ try {
56
+ // The tool arguments stay snake_case like every other tool's; the bridge
57
+ // reads viewFamilyType — map it here rather than on the C# side.
58
+ const result = await bridge.call("/views/create-plan", {
59
+ level,
60
+ name,
61
+ viewFamilyType: view_family_type,
62
+ scale,
63
+ });
64
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
65
+ } catch (error) {
66
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
67
+ }
68
+ },
69
+ );
70
+
71
+ server.tool(
72
+ "revit_create_drafting_view",
73
+ "Create a drafting view: a sheet of linework and notes that is not a view of the model at all. This is what a detail sheet is made of — fill it with revit_draw_detail_lines and revit_add_text_notes, then put it on a sheet with revit_place_views_on_sheets. If the name is already taken, a numeric suffix is appended rather than failing. One call is one undo step.",
74
+ {
75
+ name: z.string().min(1).describe("View name. A taken name gets ' 2', ' 3', ... appended."),
76
+ scale: z
77
+ .number()
78
+ .int()
79
+ .positive()
80
+ .optional()
81
+ .describe("View scale denominator, e.g. 20 for 1:20. Omit it to keep the type's default."),
82
+ },
83
+ async ({ name, scale }) => {
84
+ try {
85
+ const result = await bridge.call("/views/create-drafting", { name, scale });
86
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
87
+ } catch (error) {
88
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
89
+ }
90
+ },
91
+ );
92
+
93
+ server.tool(
94
+ "revit_create_section_view",
95
+ "Create a section view. The geometry, precisely, all in feet (Revit internal units): 'origin' is the model point the section is centred on and the cut plane passes through it; 'direction' is the horizontal direction the section LOOKS TOWARD (z is ignored, and it is normalised, so {x:0,y:1} and {x:0,y:5} are the same); 'width' is the extent across the view along the section line, centred on origin; 'height' is the vertical extent, centred on origin, with up always +Z; 'depth' is how far in front of origin, along direction, the view sees, which is where the far clip lands. So the crop is width x height centred on origin, and the view volume runs from origin to origin + direction * depth. The response carries the created view's own viewDirection, rightDirection and upDirection as {x,y,z} precisely so you can assert the section came out the way you asked: Revit's viewDirection is the direction towards the VIEWER, so a section looking toward D reports viewDirection -D — asked {x:0,y:1}, it reports {x:0,y:-1,z:0}, and rightDirection {x:1,y:0,z:0}. That is measured against Revit, not inferred. The response also carries the crop as Revit actually made it, every number read back off the created view: 'cutPlaneOrigin' is the model point the section really cuts on and 'requestedOrigin' is the origin you asked for, echoed — equal means the cut landed where you asked, and a gap of exactly 'depth' means it did not; 'modelBounds' is the axis-aligned model box the view volume really covers, which runs origin -> origin + direction * depth. 'viewOrigin', 'cropTransform' and 'cropLocalBounds' are reported to be seen, not judged: Revit rewrites the frame into its own convention — origin moved to a corner, basisZ turned back at the viewer, depth expressed as -depth..0 whatever it was given — so a section cutting where you asked and one cutting 'depth' feet away report the SAME local pair. Judge the geometry by cutPlaneOrigin and modelBounds, never by the local numbers. A taken name gets a numeric suffix rather than failing. One call is one undo step.",
96
+ {
97
+ name: z.string().min(1).describe("View name. A taken name gets ' 2', ' 3', ... appended."),
98
+ origin: z
99
+ .object({ x: z.number(), y: z.number(), z: z.number() })
100
+ .describe("Model point the section is centred on, in feet. The cut plane passes through it."),
101
+ direction: z
102
+ .object({ x: z.number(), y: z.number() })
103
+ .describe(
104
+ "Horizontal direction the section looks TOWARD, in plan. Normalised, so only the direction matters. The created view reports viewDirection -direction, since Revit's viewDirection points back at the viewer.",
105
+ ),
106
+ width: z
107
+ .number()
108
+ .positive()
109
+ .describe("Extent across the view along the section line, in feet, centred on origin"),
110
+ height: z
111
+ .number()
112
+ .positive()
113
+ .describe("Vertical extent in feet, centred on origin. Up is always +Z."),
114
+ depth: z
115
+ .number()
116
+ .positive()
117
+ .describe("How far in front of origin, along direction, the view sees, in feet. The far clip lands here."),
118
+ scale: z
119
+ .number()
120
+ .int()
121
+ .positive()
122
+ .optional()
123
+ .describe("View scale denominator, e.g. 50 for 1:50. Omit it to keep the type's default."),
124
+ },
125
+ async ({ name, origin, direction, width, height, depth, scale }) => {
126
+ try {
127
+ const result = await bridge.call("/views/create-section", {
128
+ name,
129
+ origin,
130
+ direction,
131
+ width,
132
+ height,
133
+ depth,
134
+ scale,
135
+ });
136
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
137
+ } catch (error) {
138
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
139
+ }
140
+ },
141
+ );
142
+
143
+ server.tool(
144
+ "revit_list_legends",
145
+ "List the legend views in the document: id, name and scale. Read-only, and that is the point — Revit's API cannot author the first legend in a document, so this is the set revit_create_legend has to duplicate from. An empty list means a legend has to be made once in the Revit UI (View tab > Legends > Legend), or come from the template, before any legend can be created from here.",
146
+ {},
147
+ async () => {
148
+ try {
149
+ const result = await bridge.call("/views/legends");
150
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
151
+ } catch (error) {
152
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
153
+ }
154
+ },
155
+ );
156
+
157
+ server.tool(
158
+ "revit_create_legend",
159
+ "Create a legend view by duplicating one the document already has — the only way the Revit API can make a legend. There is no creation call for the FIRST legend: the API exposes no legend view type, ViewPlan.Create takes only plan types and ViewDrafting.Create refuses a Legend view family type. A document with no legend therefore fails with NO_LEGEND_TO_DUPLICATE and you should tell the user to make one legend in the Revit UI (View tab > Legends > Legend) or use a template that has one — do not substitute a drafting view and call it a legend. The copy comes through empty (Revit's Duplicate option), so fill it with revit_draw_detail_lines and revit_add_text_notes; legend components, the elements that show a real family type at scale, have no creation API at all. A taken name gets a numeric suffix rather than failing. One call is one undo step.",
160
+ {
161
+ name: z.string().min(1).describe("Name for the new legend. A taken name gets ' 2', ' 3', ... appended."),
162
+ from_legend_id: z
163
+ .number()
164
+ .int()
165
+ .optional()
166
+ .describe("Id of the legend to duplicate, from revit_list_legends. Omit it for the first legend in the document."),
167
+ scale: z
168
+ .number()
169
+ .int()
170
+ .positive()
171
+ .optional()
172
+ .describe("View scale denominator, e.g. 50 for 1:50. Omit it to keep the source legend's scale."),
173
+ },
174
+ async ({ name, from_legend_id, scale }) => {
175
+ try {
176
+ // The tool arguments stay snake_case like every other tool's; the bridge
177
+ // reads fromLegendId — map it here rather than on the C# side.
178
+ const result = await bridge.call("/views/create-legend", {
179
+ name,
180
+ fromLegendId: from_legend_id,
181
+ scale,
182
+ });
183
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
184
+ } catch (error) {
185
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
186
+ }
187
+ },
188
+ );
189
+
190
+ server.tool(
191
+ "revit_create_3d_view",
192
+ "Create a 3D view — an isometric by default, or a perspective camera with perspective: true. Give 'eye' and 'target' (both, or neither) to aim it: eye is where the camera stands, target is what it looks at, both x/y/z in feet (Revit internal units). The bridge builds the up and forward vectors Revit needs from those two points, so you never have to. The response carries 'modelExtents' — the bounding box {min, max, center} of everything modelled in the document — which is how you work out where to put the camera in the first place: create a view with no eye/target, read the extents, then create the one you actually want. It also reports the view's own viewDirection/rightDirection/upDirection, which is Revit's direction toward the VIEWER (so the opposite of the way the camera looks). Follow this with revit_set_view_style and revit_export_view_image to actually SEE the model. A perspective view has no view scale and 'scale' is ignored on one. A taken name gets a numeric suffix rather than failing. One call is one undo step.",
193
+ {
194
+ name: z.string().min(1).describe("View name. A taken name gets ' 2', ' 3', ... appended."),
195
+ eye: z
196
+ .object({ x: z.number(), y: z.number(), z: z.number() })
197
+ .optional()
198
+ .describe("Camera position in feet. Pass it together with 'target', or neither."),
199
+ target: z
200
+ .object({ x: z.number(), y: z.number(), z: z.number() })
201
+ .optional()
202
+ .describe("Point the camera looks at, in feet. Pass it together with 'eye', or neither."),
203
+ perspective: z
204
+ .boolean()
205
+ .optional()
206
+ .describe("True for a perspective camera, false (the default) for an isometric"),
207
+ scale: z
208
+ .number()
209
+ .int()
210
+ .positive()
211
+ .optional()
212
+ .describe("View scale denominator, e.g. 100 for 1:100. Ignored on a perspective view."),
213
+ },
214
+ async ({ name, eye, target, perspective, scale }) => {
215
+ try {
216
+ const result = await bridge.call("/views/create-3d", {
217
+ name,
218
+ eye,
219
+ target,
220
+ perspective,
221
+ scale,
222
+ });
223
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
224
+ } catch (error) {
225
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
226
+ }
227
+ },
228
+ );
229
+
230
+ server.tool(
231
+ "revit_set_view_style",
232
+ "Set how a view is drawn, which is most of what decides whether an exported image reads as a model or as a diagram. 'style' is Wireframe, HiddenLine, Shading, ShadingWithEdges, Realistic, RealisticWithEdges, FlatColors or Rendering — Realistic is the one that shows materials and RPC content properly. 'detail_level' is Coarse, Medium or Fine. Both are read back off the view in the response, because a view template can override what you asked for. Cast shadows are NOT set here: pass 'shadows' to revit_set_view_graphics instead, which takes the same style and detail_level, probes the shadows parameter on the live view and reports what it found. Passing 'shadows' here fails with SHADOWS_HANDLED_ELSEWHERE pointing at that tool. One call is one undo step.",
233
+ {
234
+ view_id: z.number().int().describe("Id of the view to restyle"),
235
+ style: z
236
+ .string()
237
+ .min(1)
238
+ .describe(
239
+ "Display style name: Wireframe, HiddenLine, Shading, ShadingWithEdges, Realistic, RealisticWithEdges, FlatColors or Rendering",
240
+ ),
241
+ detail_level: z
242
+ .string()
243
+ .min(1)
244
+ .optional()
245
+ .describe("Detail level: Coarse, Medium or Fine. Omit it to leave the view's own."),
246
+ shadows: z
247
+ .boolean()
248
+ .optional()
249
+ .describe(
250
+ "Not handled here. Send it to revit_set_view_graphics, which probes whether the shadows parameter is writable on that view and says which route it took. Passing it here fails the call rather than silently doing nothing.",
251
+ ),
252
+ },
253
+ async ({ view_id, style, detail_level, shadows }) => {
254
+ try {
255
+ const result = await bridge.call("/views/set-style", {
256
+ viewId: view_id,
257
+ style,
258
+ detailLevel: detail_level,
259
+ shadows,
260
+ });
261
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
262
+ } catch (error) {
263
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
264
+ }
265
+ },
266
+ );
267
+
268
+ server.tool(
269
+ "revit_set_view_background",
270
+ "Put a sky behind a 3D view. This is the single biggest difference between an export that reads as a visualisation and one that reads as a screenshot of Revit: a default 3D view is drawn on a flat dark slate colour, and it survives into the PNG. 'kind' is 'sky' for Revit's own sky and clouds, 'gradient' for a three-band sky you colour yourself, or 'image' for a photographic backdrop. IMPORTANT: 'sky' takes NO colours — Revit's factory for it is ViewDisplayBackground.CreateSky(), which has no parameters, and passing sky_color/horizon_color/ground_color with it fails the call rather than being ignored; use 'gradient' when you want to choose the colours. Colours are {r,g,b}, 0-255, and the gradient defaults to a daylight sky (70/130/190 sky, 205/225/240 horizon, 130/120/105 ground). Only a 3D view has a background — a plan, a section or a sheet is a BAD_REQUEST, with the message saying so. The response reports the background read back off the view after the write, and 'kind' comes back as Revit's own enum name, so asking for 'sky' reports 'SunAndClouds' — that is Revit's word for it, not a different background. Pair it with revit_hide_view_categories and revit_set_view_style before revit_export_view_image. One call is one undo step.",
271
+ {
272
+ view_id: z.number().int().describe("Id of the 3D view to give a background to"),
273
+ kind: z
274
+ .enum(["sky", "gradient", "image"])
275
+ .describe(
276
+ "'sky' for Revit's own sky and clouds (no colours), 'gradient' for sky/horizon/ground colours, 'image' for a file",
277
+ ),
278
+ sky_color: z
279
+ .object({ r: z.number().int().min(0).max(255), g: z.number().int().min(0).max(255), b: z.number().int().min(0).max(255) })
280
+ .optional()
281
+ .describe("Top band of a gradient, {r,g,b} 0-255. Only valid with kind 'gradient'."),
282
+ horizon_color: z
283
+ .object({ r: z.number().int().min(0).max(255), g: z.number().int().min(0).max(255), b: z.number().int().min(0).max(255) })
284
+ .optional()
285
+ .describe("Middle band of a gradient, {r,g,b} 0-255. Only valid with kind 'gradient'."),
286
+ ground_color: z
287
+ .object({ r: z.number().int().min(0).max(255), g: z.number().int().min(0).max(255), b: z.number().int().min(0).max(255) })
288
+ .optional()
289
+ .describe("Bottom band of a gradient, {r,g,b} 0-255. Only valid with kind 'gradient'."),
290
+ image_path: z
291
+ .string()
292
+ .min(1)
293
+ .optional()
294
+ .describe(
295
+ "Full path of the backdrop image, required with kind 'image'. Revit reads it off disk every time it draws the view, so the file has to stay there.",
296
+ ),
297
+ },
298
+ async ({ view_id, kind, sky_color, horizon_color, ground_color, image_path }) => {
299
+ try {
300
+ // The tool arguments stay snake_case like every other tool's; the bridge
301
+ // reads viewId/skyColor/... — map them here rather than on the C# side.
302
+ const result = await bridge.call("/views/set-background", {
303
+ viewId: view_id,
304
+ kind,
305
+ skyColor: sky_color,
306
+ horizonColor: horizon_color,
307
+ groundColor: ground_color,
308
+ imagePath: image_path,
309
+ });
310
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
311
+ } catch (error) {
312
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
313
+ }
314
+ },
315
+ );
316
+
317
+ server.tool(
318
+ "revit_hide_view_categories",
319
+ "Turn whole categories off in one view. This is what takes the level datums, section marks, elevation tags and reference planes out of a presentation view — they float through an otherwise finished isometric and mark it instantly as somebody's working view. Pass categories: ['annotation'] for the whole set at once, which is what a presentation view wants; the set is Levels, Grids, ReferencePlanes, Sections, Elevations, Cameras, SunPath and Lines. Names are case-insensitive and a literal OST_* BuiltInCategory name is accepted too, so anything outside the friendly set is still reachable. An unknown name is a BAD_REQUEST listing the accepted ones, and nothing is hidden — that is a typo worth fixing. A category Revit will not hide in that view comes back as its own row with skipped: true and a reason while the rest are still hidden, so one refusal cannot cost you the other seven. MEASURED, so you are not surprised: Cameras and SunPath refuse in every view tested on Revit 2027 — CanCategoryBeHidden is false for OST_Cameras and every OST_Sun* category, because the sun path is a view-control-bar toggle (SunAndShadowSettings.Visible), not a visibility/graphics category. Every row's 'hidden' is read back off the view, so a view template overriding what you asked for is visible rather than silent. Set hidden: false to bring a category back. One call is one undo step.",
320
+ {
321
+ view_id: z.number().int().describe("Id of the view to change, from revit_list_views"),
322
+ categories: z
323
+ .array(z.string().min(1))
324
+ .min(1)
325
+ .describe(
326
+ "Category names: Levels, Grids, ReferencePlanes, Sections, Elevations, Cameras, SunPath, Lines, the shorthand 'annotation' for all of them, or any OST_* name. All in this one call.",
327
+ ),
328
+ hidden: z
329
+ .boolean()
330
+ .optional()
331
+ .describe("True (the default) hides them; false brings them back."),
332
+ },
333
+ async ({ view_id, categories, hidden }) => {
334
+ try {
335
+ const result = await bridge.call("/views/hide-categories", {
336
+ viewId: view_id,
337
+ categories,
338
+ hidden,
339
+ });
340
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
341
+ } catch (error) {
342
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
343
+ }
344
+ },
345
+ );
346
+
347
+ server.tool(
348
+ "revit_override_view_categories",
349
+ "Override the graphics of whole categories in ONE view — the other half of the Visibility/Graphics dialog, and what stops a drawing set reading as the same CAD export printed five times: paving drawn light so it sits behind the planting, planting green, context halftoned, the thing the drawing is about drawn heavy. Nothing about the model changes; it is all stored on the view. The overrides are MERGED onto what the view already has: Revit's current settings for that category are copied and only the fields you name are replaced, so asking for a colour cannot wipe a fill pattern or a line pattern somebody set in the dialog — the patterns are reported in 'before' and 'after' precisely so you can see they survived. The colours are LINE colours (Projection/Surface > Lines and Cut > Lines); surface and cut PATTERN colours are read and reported but never written here. Line weights are 1-16 or -1 to clear the override; surface_transparency is 0 (opaque) to 100. IMPORTANT — a view whose TEMPLATE owns the V/G overrides is refused with OVERRIDES_CONTROLLED_BY_TEMPLATE naming the template and the categories: Revit would accept the write, commit it and keep drawing the view the template's way, and a success message for a change nobody can see is worse than a refusal. Every category name, every value and every category's IsCategoryOverridable are checked BEFORE anything is written, so the call is all-or-nothing, and a view that has no V/G at all (a sheet, a schedule, a legend) is OVERRIDES_NOT_SUPPORTED. Visibility is untouched: 'hidden' is reported on both sides and 'hiddenPreserved' says so. dry_run DEFAULTS TO TRUE and answers with the current override plus 'would' — the exact merged override it would write. Read that, then call again with dry_run false. One applied call is one undo step for the whole batch, and 'after' is read back off the view.",
350
+ {
351
+ view_id: z
352
+ .number()
353
+ .int()
354
+ .describe("Id of the view to override categories in, from revit_list_views"),
355
+ overrides: z
356
+ .array(
357
+ z.object({
358
+ category: z
359
+ .string()
360
+ .min(1)
361
+ .describe(
362
+ "Category display name ('Planting', 'Site', 'Topography') or a literal OST_* BuiltInCategory name",
363
+ ),
364
+ projection_color: rgb
365
+ .optional()
366
+ .describe("Projection/Surface LINE colour, {r,g,b} 0-255"),
367
+ cut_color: rgb.optional().describe("Cut LINE colour, {r,g,b} 0-255"),
368
+ projection_line_weight: z
369
+ .number()
370
+ .int()
371
+ .min(-1)
372
+ .max(16)
373
+ .optional()
374
+ .describe("Projection line weight 1-16, or -1 to clear the override"),
375
+ cut_line_weight: z
376
+ .number()
377
+ .int()
378
+ .min(-1)
379
+ .max(16)
380
+ .optional()
381
+ .describe("Cut line weight 1-16, or -1 to clear the override"),
382
+ halftone: z.boolean().optional().describe("Draw the category halftone — how context is pushed back"),
383
+ surface_transparency: z
384
+ .number()
385
+ .int()
386
+ .min(0)
387
+ .max(100)
388
+ .optional()
389
+ .describe("Surface transparency percent, 0 opaque to 100 fully transparent"),
390
+ }),
391
+ )
392
+ .min(1)
393
+ .describe("One row per category, each with at least one setting. All in this one call."),
394
+ dry_run: z
395
+ .boolean()
396
+ .default(true)
397
+ .describe(
398
+ "DEFAULTS TO TRUE. True reports each category's current override and the merged override that would be written, and changes nothing. Pass false to apply it.",
399
+ ),
400
+ },
401
+ async ({ view_id, overrides, dry_run }) => {
402
+ const rows = overrides.map((row) => ({
403
+ category: row.category,
404
+ projectionColor: row.projection_color,
405
+ cutColor: row.cut_color,
406
+ projectionLineWeight: row.projection_line_weight,
407
+ cutLineWeight: row.cut_line_weight,
408
+ halftone: row.halftone,
409
+ surfaceTransparency: row.surface_transparency,
410
+ }));
411
+
412
+ // Checked here rather than in the schema: zod can say "these fields are
413
+ // optional", not "at least one of them". A row naming none is a request
414
+ // that would write nothing and report a change.
415
+ const empty = rows.find((row) =>
416
+ Object.keys(row).every((key) => key === "category" || row[key] === undefined),
417
+ );
418
+ if (empty) {
419
+ return {
420
+ content: [
421
+ {
422
+ type: "text",
423
+ text: `Error: the overrides row for "${empty.category}" names no setting. Pass at least one of projection_color, cut_color, projection_line_weight, cut_line_weight, halftone or surface_transparency.`,
424
+ },
425
+ ],
426
+ };
427
+ }
428
+
429
+ try {
430
+ const result = await bridge.call("/views/override-categories", {
431
+ viewId: view_id,
432
+ overrides: rows,
433
+ dryRun: dry_run,
434
+ });
435
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
436
+ } catch (error) {
437
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
438
+ }
439
+ },
440
+ );
441
+
442
+ server.tool(
443
+ "revit_set_view_sun",
444
+ "Move the sun for a view, which is most of what decides whether a shaded export reads as architecture: it sets the direction every face is lit from. Two routes, and they are two different Revit modes — pass one or the other, never both. (1) azimuth/altitude in DEGREES puts the view in Lighting mode and states the sun position directly: azimuth is a compass bearing 0-360 off project north, altitude is 0-90 above the horizon. The bridge converts to the radians Revit stores. (2) date (YYYY-MM-DD) and time (HH:MM, 24-hour) put the view in Still Image mode and let Revit compute the sun itself from the project's latitude, longitude and time zone — the honest route for 'half three on a June afternoon'. Watch two things on the date route, both measured against a live model: Revit applies the project's daylight saving rule, so 15:30 on 21 June came back stored as 14:30 while 15:30 on 21 January round-tripped exactly; and the azimuth/altitude in the response are the position Revit COMPUTED, read back off the active frame rather than echoed. IMPORTANT — SUN SETTINGS CAN BE SHARED BETWEEN VIEWS: a view either owns its settings or sits on the document's shared ones, and in the second case moving the sun here moves it in every other view sharing them. The response reports sunSettingsId and sharesSettings so you can see which: two views reporting the same sunSettingsId are one sun. Note this only aims the sun — it does not turn CAST SHADOWS on. That is revit_set_view_graphics, which probes the shadows parameter on the view and either writes it or posts Revit's own shadows command, reporting which. One call is one undo step.",
445
+ {
446
+ view_id: z.number().int().describe("Id of the view whose sun to move, from revit_list_views"),
447
+ azimuth: z
448
+ .number()
449
+ .min(0)
450
+ .max(360)
451
+ .optional()
452
+ .describe(
453
+ "Compass bearing of the sun in degrees, 0 is north and it runs clockwise. Puts the view in Lighting mode. Not to be combined with date/time.",
454
+ ),
455
+ altitude: z
456
+ .number()
457
+ .min(-90)
458
+ .max(90)
459
+ .optional()
460
+ .describe(
461
+ "Height of the sun above the horizon in degrees. Puts the view in Lighting mode. Not to be combined with date/time.",
462
+ ),
463
+ date: z
464
+ .string()
465
+ .min(1)
466
+ .optional()
467
+ .describe("Date as YYYY-MM-DD, e.g. '2026-06-21'. Puts the view in Still Image mode. Not to be combined with azimuth/altitude."),
468
+ time: z
469
+ .string()
470
+ .min(1)
471
+ .optional()
472
+ .describe("Time as HH:MM on a 24-hour clock, e.g. '15:30'. Puts the view in Still Image mode. Not to be combined with azimuth/altitude."),
473
+ },
474
+ async ({ view_id, azimuth, altitude, date, time }) => {
475
+ // Checked here rather than in the schema: a raw shape cannot express
476
+ // "one of these two groups", and a call with neither must not reach Revit.
477
+ const angles = azimuth !== undefined || altitude !== undefined;
478
+ const clock = date !== undefined || time !== undefined;
479
+
480
+ if (!angles && !clock) {
481
+ return {
482
+ content: [
483
+ { type: "text", text: "Error: pass azimuth and/or altitude (degrees), or date and/or time." },
484
+ ],
485
+ };
486
+ }
487
+ if (angles && clock) {
488
+ return {
489
+ content: [
490
+ {
491
+ type: "text",
492
+ text: "Error: pass either azimuth/altitude or date/time, not both — they are two different Revit sun modes.",
493
+ },
494
+ ],
495
+ };
496
+ }
497
+
498
+ try {
499
+ const result = await bridge.call("/views/set-sun", {
500
+ viewId: view_id,
501
+ azimuth,
502
+ altitude,
503
+ date,
504
+ time,
505
+ });
506
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
507
+ } catch (error) {
508
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
509
+ }
510
+ },
511
+ );
512
+
513
+ server.tool(
514
+ "revit_export_view_image",
515
+ "Export a view to a raster image on disk — the only way anything outside Revit gets to see what the model looks like. Pair it with revit_create_3d_view and revit_set_view_style. IMPORTANT: Revit appends its own suffix to the file name (' - <view type> - <view name>'), so the file it writes is NOT the path you asked for — the response reports the real absolute path under 'path' and echoes what you asked for under 'requestedPath'. Open the one under 'path'. 'path' may be a folder or a file; omit it for <your home>\\RevitProjects\\renders\\. Size is one dimension and Revit fits the other: 'width' fits horizontally (default 1600 px), 'height' fits vertically, and the width/height in the response are read out of the PNG that was written. This is NOT a photoreal render — the Revit API cannot start the raytracer at all, so what you get is the view exactly as drawn on screen, which is why the display style matters. Not an undo step: nothing in the model changes.",
516
+ {
517
+ view_id: z.number().int().optional().describe("Id of the view to export"),
518
+ view_ids: z
519
+ .array(z.number().int())
520
+ .min(1)
521
+ .optional()
522
+ .describe("Ids of several views to export, each written as its own file"),
523
+ path: z
524
+ .string()
525
+ .min(1)
526
+ .optional()
527
+ .describe(
528
+ "Output folder, or a file path whose name Revit will append the view to. Omit it for <your home>\\RevitProjects\\renders\\.",
529
+ ),
530
+ width: z.number().int().positive().optional().describe("Image width in pixels. Defaults to 1600."),
531
+ height: z
532
+ .number()
533
+ .int()
534
+ .positive()
535
+ .optional()
536
+ .describe("Image height in pixels, used only when width is omitted"),
537
+ format: z
538
+ .string()
539
+ .min(1)
540
+ .optional()
541
+ .describe("PNG (the default), JPEG, JPEGLossless, JPEGMedium, JPEGSmallest, BMP, TIFF or TARGA"),
542
+ },
543
+ async ({ view_id, view_ids, path, width, height, format }) => {
544
+ try {
545
+ if (view_id === undefined && !view_ids) {
546
+ return {
547
+ content: [{ type: "text", text: "Error: pass 'view_id' or 'view_ids'." }],
548
+ };
549
+ }
550
+ const result = await bridge.call("/views/export-image", {
551
+ viewId: view_ids ? undefined : view_id,
552
+ viewIds: view_ids,
553
+ path,
554
+ width,
555
+ height,
556
+ format,
557
+ });
558
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
559
+ } catch (error) {
560
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
561
+ }
562
+ },
563
+ );
564
+
565
+ server.tool(
566
+ "revit_duplicate_view",
567
+ "Duplicate a view. 'detailing' picks Revit's duplicate option: Duplicate (geometry only, the default), WithDetailing (carries annotation across) or AsDependent (stays linked to the original). A taken name gets a numeric suffix rather than failing. One call is one undo step.",
568
+ {
569
+ view_id: z.number().int().describe("Id of the view to duplicate, from revit_list_views"),
570
+ name: z.string().min(1).describe("Name for the copy. A taken name gets ' 2', ' 3', ... appended."),
571
+ detailing: z
572
+ .enum(["Duplicate", "WithDetailing", "AsDependent"])
573
+ .optional()
574
+ .describe("Revit's ViewDuplicateOption. Defaults to Duplicate."),
575
+ },
576
+ async ({ view_id, name, detailing }) => {
577
+ try {
578
+ const result = await bridge.call("/views/duplicate", {
579
+ viewId: view_id,
580
+ name,
581
+ detailing,
582
+ });
583
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
584
+ } catch (error) {
585
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
586
+ }
587
+ },
588
+ );
589
+
590
+ server.tool(
591
+ "revit_set_view_scale",
592
+ "Set the view scale on one view or a batch of them. 'scale' is the denominator X in 1:X — 100 is 1:100 — and Revit allows 1 to 24000. Pass every view in one call: the whole batch is one undo step. A view the bridge will not scale — a schedule or a sheet, which have no view scale, a view template, whose scale belongs to every view using it, and a PERSPECTIVE 3D view, which has no scale either and comes back as PERSPECTIVE_VIEW_HAS_NO_SCALE pointing at revit_scale_perspective_crop — comes back under 'failed' with a code and a reason while the rest are still re-scaled, so one schedule in the list cannot cost you thirty plans. The scale in 'updated' is read back off each view, so a view template overriding what you asked for is visible rather than silent.",
593
+ {
594
+ view_id: z
595
+ .number()
596
+ .int()
597
+ .optional()
598
+ .describe("Single view id from revit_list_views. Use this or view_ids, not both."),
599
+ view_ids: z
600
+ .array(z.number().int())
601
+ .min(1)
602
+ .optional()
603
+ .describe("View ids to re-scale, all in this one call. Use this or view_id, not both."),
604
+ scale: z
605
+ .number()
606
+ .int()
607
+ .positive()
608
+ .describe("View scale denominator, e.g. 100 for 1:100. Revit's range is 1 to 24000."),
609
+ },
610
+ async ({ view_id, view_ids, scale }) => {
611
+ // Checked here rather than in the schema: a raw shape cannot express
612
+ // "one of these two", and a call with neither must not reach Revit.
613
+ if (view_id === undefined && view_ids === undefined) {
614
+ return {
615
+ content: [
616
+ { type: "text", text: "Error: pass either view_id (one view) or view_ids (a batch)." },
617
+ ],
618
+ };
619
+ }
620
+ if (view_id !== undefined && view_ids !== undefined) {
621
+ return {
622
+ content: [
623
+ { type: "text", text: "Error: pass either view_id or view_ids, not both." },
624
+ ],
625
+ };
626
+ }
627
+
628
+ try {
629
+ const result = await bridge.call("/views/set-scale", {
630
+ viewId: view_id,
631
+ viewIds: view_ids,
632
+ scale,
633
+ });
634
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
635
+ } catch (error) {
636
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
637
+ }
638
+ },
639
+ );
640
+
641
+ server.tool(
642
+ "revit_scale_perspective_crop",
643
+ "Make ONE perspective 3D view bigger or smaller on its sheet, proportions locked. This is the tool for a perspective, because a perspective camera has no view scale: revit_set_view_scale writes the 1:X denominator and refuses a perspective with PERSPECTIVE_VIEW_HAS_NO_SCALE. It scales the view's crop box on both axes — Revit's own View3D.ScalePerspectiveCropBox, which changes the size AND the scale of the view on the sheet together — so multiplier 2 doubles it on the paper, 0.5 halves it, and the framing is identical: the same shot, printed larger. It is NOT a reframe, and that is the other tool to know: revit_set_view_crop crops to a region of the MODEL and changes what is in shot. The camera is never touched here, and the answer proves it with 'cameraUnchanged' comparing the orientation before and after. Refusals come before anything is written: NOT_A_3D_VIEW (a plan or a section re-scales with revit_set_view_scale), VIEW_IS_TEMPLATE (Revit throws on a template — name the views using it), VIEW_NOT_PERSPECTIVE (an isometric 3D view has a real scale, so use revit_set_view_scale). dry_run DEFAULTS TO TRUE and reports the view's current size with the multiplier you asked for and deliberately no predicted 'after' — the size Revit lands on is read back off an applied call, never calculated here. 'before' and 'after' carry the view's Outline in PAPER feet, the crop box, the camera and the viewport's box on the sheet, all measured. Read 'outline' and 'viewport' to judge it, NOT 'cropBox': a verified run grew a view 5.65x on the paper with the crop box's model coordinates identical either side — camera and composition are both kept, which is what this tool is for. One last thing to expect: Revit leaves the view TITLE at its old paper position, so after a large multiplier the label can sit over the enlarged image — put it back with revit_set_viewport_position (label_offset), which is a separate call because where a title belongs is a drawing decision. One applied call is one undo step.",
644
+ {
645
+ view_id: z
646
+ .number()
647
+ .int()
648
+ .describe("Perspective 3D view id from revit_list_views"),
649
+ multiplier: z
650
+ .number()
651
+ .positive()
652
+ .describe(
653
+ "How much bigger the view gets on the sheet. 2 doubles it, 0.5 halves it, 1 changes nothing. Must be greater than zero.",
654
+ ),
655
+ dry_run: z
656
+ .boolean()
657
+ .default(true)
658
+ .describe(
659
+ "DEFAULTS TO TRUE. True reports the view's current size and the multiplier asked for, and changes nothing. Pass false to apply it.",
660
+ ),
661
+ },
662
+ async ({ view_id, multiplier, dry_run }) => {
663
+ try {
664
+ const result = await bridge.call("/views/scale-perspective-crop", {
665
+ viewId: view_id,
666
+ multiplier,
667
+ dryRun: dry_run,
668
+ });
669
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
670
+ } catch (error) {
671
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
672
+ }
673
+ },
674
+ );
675
+
676
+ server.tool(
677
+ "revit_place_views_on_sheets",
678
+ "Put views on sheets. Pass every placement in one call: the whole batch is one undo step, and a placement Revit refuses comes back under 'failed' with its code while the rest still land. Schedules are handled automatically — they go on a sheet as a schedule instance, not a viewport, and you do not have to know which kind of view you are holding. x and y are sheet coordinates in FEET on the paper (an A1 sheet is 1.95 x 1.38), and default to the centre of the sheet. Failure codes: VIEW_ALREADY_PLACED (a view lives on exactly one sheet — duplicate it to place it again), CANNOT_PLACE (Revit refuses that view on that sheet).",
679
+ {
680
+ placements: z
681
+ .array(
682
+ z.object({
683
+ sheet_id: z.number().int().describe("Sheet id from revit_list_sheets"),
684
+ view_id: z.number().int().describe("View id from revit_list_views"),
685
+ x: z
686
+ .number()
687
+ .optional()
688
+ .describe("Sheet x in feet on the paper. Omit for the centre of the sheet."),
689
+ y: z
690
+ .number()
691
+ .optional()
692
+ .describe("Sheet y in feet on the paper. Omit for the centre of the sheet."),
693
+ }),
694
+ )
695
+ .min(1)
696
+ .describe("Placements to make, all in this one call"),
697
+ },
698
+ async ({ placements }) => {
699
+ try {
700
+ // The tool arguments stay snake_case like every other tool's; the bridge
701
+ // reads sheetId / viewId — map them here rather than on the C# side.
702
+ const result = await bridge.call("/sheets/place-view", {
703
+ placements: placements.map((placement) => ({
704
+ sheetId: placement.sheet_id,
705
+ viewId: placement.view_id,
706
+ x: placement.x,
707
+ y: placement.y,
708
+ })),
709
+ });
710
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
711
+ } catch (error) {
712
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
713
+ }
714
+ },
715
+ );
716
+
717
+ server.tool(
718
+ "revit_create_schedule",
719
+ "Create a schedule for a category, with the named fields added in order. A field name Revit does not know for that category is not an error: it comes back under 'skippedFields', and the response then also lists 'availableFields' — the exact names that category does offer — so you can correct the call without guessing. Place the result on a sheet with revit_place_views_on_sheets, which handles schedules for you. One call is one undo step.",
720
+ {
721
+ category: z
722
+ .string()
723
+ .min(1)
724
+ .describe("Category to schedule, e.g. 'Planting' or 'OST_LightingFixtures'"),
725
+ name: z.string().min(1).describe("Schedule name. A taken name gets ' 2', ' 3', ... appended."),
726
+ fields: z
727
+ .array(z.string().min(1))
728
+ .min(1)
729
+ .describe("Field names to add, in order. Unknown ones are skipped and reported, not fatal."),
730
+ scale: z
731
+ .number()
732
+ .int()
733
+ .positive()
734
+ .optional()
735
+ .describe("View scale denominator. Schedules have no meaningful scale; omit it unless you know otherwise."),
736
+ },
737
+ async ({ category, name, fields, scale }) => {
738
+ try {
739
+ const result = await bridge.call("/schedules/create", { category, name, fields, scale });
740
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
741
+ } catch (error) {
742
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
743
+ }
744
+ },
745
+ );
746
+ }