using System.IO; using System.Text.Json; using Autodesk.Revit.DB; namespace RevitBridge.Tools { /// /// Exports a PNG of a view to a temp file for the agent to open with the read /// tool: the response carries only the file path and image metadata, /// never base64 image data. The long edge is clamped to 1568 px; the fit /// direction comes from the view outline and is verified against the produced /// PNG header, re-exporting once along the long edge if the guess was wrong. /// internal sealed class CaptureView : ITool { private const int MaxLongEdgePx = 1568; /// How long a captured PNG stays on disk before a later capture sweeps it. /// Long enough that a capture is never pulled out from under a session still reading it. private static readonly TimeSpan CaptureRetention = TimeSpan.FromHours(24); public string Name => "capture_view"; public string Label => "Capture View"; public string Description => "Export a PNG snapshot of a Revit view to a temporary file and return its path — the response contains NO image data; open the returned filePath with the read tool to actually see the image. Defaults to the active view; pass view_id for any other graphical view or sheet (find ids with get_elements, category 'Views' or 'Sheets'). The long image edge is capped at 1568 px. Schedules and view templates cannot be captured."; public string Tier => "advanced"; public object ParametersSchema => new { type = "object", properties = new { view_id = new { type = "integer", description = "Element id of the view or sheet to capture. Default: the active view.", }, }, required = Array.Empty(), }; public string? PromptSnippet => "Export a PNG of a Revit view to a temp file (open it with the read tool)."; public IReadOnlyList? PromptGuidelines => new[] { "capture_view returns a filePath and never image data — open that file with the read tool to actually see the view.", }; public object? Execute(JsonElement args, ToolContext context) { var doc = context.Document ?? throw new NoActiveDocumentException(); var view = ResolveView(doc, args); PruneOldCaptures(); string prefix = Path.Combine(CaptureDirectory(), "revit_view_" + Guid.NewGuid().ToString("N")); ExportPng(doc, view, prefix, GuessLandscape(view)); string filePath = FindExportedFile(prefix); var (width, height) = ReadPngSize(filePath); // The outline-based aspect guess can be wrong (crop boxes, title blocks); // the PNG header is authoritative. One re-export with the fit direction // on the long edge guarantees the <= 1568 px clamp. if (width > 0 && Math.Max(width, height) > MaxLongEdgePx) { File.Delete(filePath); ExportPng(doc, view, prefix, horizontal: width >= height); filePath = FindExportedFile(prefix); (width, height) = ReadPngSize(filePath); } long fileSizeBytes = new FileInfo(filePath).Length; string compact = $"Captured view '{view.Name}' (id {view.Id.Value}) to {filePath} ({width}x{height} px, {Math.Max(1, fileSizeBytes / 1024)} KB). Open filePath with the read tool to see it."; return new ToolOutput(new { filePath, viewId = view.Id.Value, viewName = view.Name, width, height, fileSizeBytes, }, compact); } /// Captures live in one folder this add-in owns, NOT in Path.GetTempPath(). /// Revit can hand out a fresh per-session temp folder (observed: %LOCALAPPDATA%\Temp\ /// <guid>\), so a sweep of GetTempPath() only ever sees the current session's own /// files while every earlier session's captures accumulate unreachably in sibling /// folders. A fixed directory makes the sweep below correct and cheap. Falls back to /// the temp path if the folder cannot be created — a capture must never fail over /// where it is filed. private static string CaptureDirectory() { try { string directory = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "pi-revit", "captures"); Directory.CreateDirectory(directory); return directory; } catch { return Path.GetTempPath(); } } /// The PNG must outlive the call — the agent opens it with the read tool /// after the result returns — so this run's file is never deleted here. Instead each /// capture sweeps the ones left by earlier sessions, which otherwise accumulate /// forever. Best-effort: a locked or vanished file is skipped, and a failing sweep /// never costs the caller their capture. The wildcard also covers the older /// 'revit_view_snapshot_*' naming so pre-existing captures are collected too. private static void PruneOldCaptures() { try { DateTime cutoff = DateTime.UtcNow - CaptureRetention; foreach (string path in Directory.EnumerateFiles(CaptureDirectory(), "revit_view_*.png")) { try { if (File.GetLastWriteTimeUtc(path) < cutoff) File.Delete(path); } catch { // In use by a reader, or already gone: leave it for the next sweep. } } } catch { // Capture folder unreadable: cleanup is never worth failing a capture over. } } private static View ResolveView(Document doc, JsonElement args) { View view; if (JsonArgs.GetLong(args, "view_id") is { } viewId) { view = doc.GetElement(new ElementId(viewId)) as View ?? throw new ArgumentException($"view_id {viewId} is not a view. Find views with get_elements (category 'Views' or 'Sheets')."); if (view.IsTemplate) throw new ArgumentException($"View '{view.Name}' ({viewId}) is a view template and cannot be captured."); } else { view = doc.ActiveView ?? throw new ArgumentException("No active view to capture; pass view_id."); } if (view is ViewSchedule) throw new ArgumentException($"View '{view.Name}' is a schedule; schedules cannot be exported as images. Capture a graphical view or sheet, or query the data with get_elements instead."); return view; } private static void ExportPng(Document doc, View view, string prefix, bool horizontal) { var options = new ImageExportOptions { ExportRange = ExportRange.SetOfViews, FilePath = prefix, HLRandWFViewsFileType = ImageFileType.PNG, ShadowViewsFileType = ImageFileType.PNG, ImageResolution = ImageResolution.DPI_150, ZoomType = ZoomFitType.FitToPage, PixelSize = MaxLongEdgePx, FitDirection = horizontal ? FitDirectionType.Horizontal : FitDirectionType.Vertical, }; options.SetViewsAndSheets(new List { view.Id }); try { doc.ExportImage(options); } catch (Autodesk.Revit.Exceptions.ApplicationException ex) { throw new InvalidOperationException($"Revit could not export view '{view.Name}' as an image: {ex.Message}"); } } /// Initial fit direction from the view outline; verified against the produced PNG. private static bool GuessLandscape(View view) { try { var outline = view.Outline; return outline.Max.U - outline.Min.U >= outline.Max.V - outline.Min.V; } catch { return true; } } /// ExportRange.SetOfViews appends " - [view type] - [view name]" to the /// file path prefix, so the produced name is discovered by globbing the prefix. private static string FindExportedFile(string prefix) { string directory = Path.GetDirectoryName(prefix)!; var matches = Directory.GetFiles(directory, Path.GetFileName(prefix) + "*.png"); return matches.Length > 0 ? matches[0] : throw new InvalidOperationException("Revit reported a successful export but no PNG file was produced."); } /// Reads width/height from the PNG IHDR chunk (offsets 16/20, big-endian). private static (int Width, int Height) ReadPngSize(string filePath) { try { using var stream = File.OpenRead(filePath); Span header = stackalloc byte[24]; stream.ReadExactly(header); if (header[12] != (byte)'I' || header[13] != (byte)'H' || header[14] != (byte)'D' || header[15] != (byte)'R') return (0, 0); int width = (header[16] << 24) | (header[17] << 16) | (header[18] << 8) | header[19]; int height = (header[20] << 24) | (header[21] << 16) | (header[22] << 8) | header[23]; return (width, height); } catch { return (0, 0); } } } }