diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6e3aba0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,139 @@ +# RhinoMCP - Claude Code Projektdokumentation + +## Projektübersicht + +RhinoMCP ist ein MCP (Model Context Protocol) Plugin, das Claude AI direkten Zugriff auf Rhino 3D und Grasshopper ermöglicht. Es wurde für Landschaftsarchitektur, Straßeninfrastruktur und Regenwassermanagement entwickelt. + +## Architektur + +```text +┌─────────────────────────────────────────────────────────┐ +│ Claude.ai / Claude Code │ +└──────────────────┬──────────────────────────────────────┘ + │ MCP Protocol (stdio) +┌──────────────────▼──────────────────────────────────────┐ +│ MCP-Server (Python) │ +│ src/mcp-server/server.py │ +│ - Tool-Definitionen │ +│ - Request/Response Handling │ +└──────────────────┬──────────────────────────────────────┘ + │ HTTP (localhost:9000) +┌──────────────────▼──────────────────────────────────────┐ +│ Rhino-Plugin (C# .rhp) │ +│ src/rhino-plugin/RhinoMCP/ │ +│ ├─ RhinoMcpPlugin.cs (Plugin-Einstieg) │ +│ ├─ HttpServer.cs (HTTP-Endpoint) │ +│ ├─ CommandHandler.cs (Rhino-Befehle) │ +│ ├─ GrasshopperHandler.cs(GH-Befehle) │ +│ └─ MeshTerrainHandler.cs(Mesh/GIS-Tools) │ +└─────────────────────────────────────────────────────────┘ +``` + +## Technische Details + +- **Rhino-Version**: Rhino 9 WIP +- **Framework**: .NET 7.0 +- **RhinoCommon/Grasshopper SDK**: 8.0.23304.9001 +- **HTTP-Server**: EmbedIO auf Port 9000 +- **MCP-Transport**: stdio + +## Verfügbare Tools + +### Rhino-Geometrie + +- `rhino_create_point`, `rhino_create_line`, `rhino_create_polyline` +- `rhino_create_circle`, `rhino_create_sphere`, `rhino_create_box` +- `rhino_create_surface`, `rhino_create_curve` +- `rhino_move`, `rhino_rotate`, `rhino_scale`, `rhino_copy` +- `rhino_boolean_union`, `rhino_boolean_difference`, `rhino_boolean_intersection` +- `rhino_export`, `rhino_run_command` + +### Grasshopper + +- `grasshopper_open_definition`, `grasshopper_get_definition_info` +- `grasshopper_add_component`, `grasshopper_connect_components` +- `grasshopper_add_slider`, `grasshopper_set_slider_value` +- `grasshopper_set_toggle_value`, `grasshopper_set_panel_text` +- `grasshopper_bake`, `grasshopper_save_definition`, `grasshopper_recompute` +- `grasshopper_delete_component`, `grasshopper_add_group` +- `grasshopper_add_scribble`, `grasshopper_rename_component` + +### Mesh/Terrain/GIS + +- `rhino_mesh_from_points`, `rhino_import_mesh`, `rhino_mesh_boolean` +- `rhino_terrain_contours`, `rhino_terrain_slope_analysis` +- `rhino_terrain_watershed`, `rhino_terrain_low_points` +- `rhino_create_drainage_line`, `rhino_create_road_surface` +- `rhino_import_geotiff`, `rhino_import_shapefile`, `rhino_import_xyz` +- `rhino_transform_coordinates`, `rhino_get_bounding_box` +- `rhino_create_grid`, `rhino_create_site_section`, `rhino_calculate_area_volume` + +## Installation + +1. Plugin bauen: `dotnet build --configuration Release` +2. `install.bat` ausführen oder manuell nach `%APPDATA%\McNeel\Rhinoceros\9.0\Plug-ins\RhinoMCP (1.0.0)\` kopieren +3. MCP-Server in Claude Code konfigurieren + +## Entwicklungsrichtlinien + +### Automatisches Commit bei Kapazitätswarnung + +**WICHTIG**: Ab 85% Chat-Kapazität muss automatisch: + +1. Aktueller Stand dokumentiert werden +2. Alle Änderungen committet werden +3. Zusammenfassung der offenen Aufgaben erstellt werden + +### Code-Struktur + +- **Maximale Dateigröße: 300 Zeilen pro Datei** +- Bei Überschreitung: Logisch aufteilen in separate Handler-Klassen + +### Aktuelle Aufteilungsempfehlungen + +| Datei | Zeilen | Status | Empfehlung | +| ---------------------- | ------ | -------- | --------------------------------------------------------- | +| CommandHandler.cs | ~683 | Zu groß | Aufteilen in GeometryHandler, TransformHandler | +| MeshTerrainHandler.cs | ~1509 | Zu groß | Aufteilen in MeshHandler, TerrainHandler, GISHandler, RoadHandler | +| GrasshopperHandler.cs | ~671 | Zu groß | Aufteilen in GH_ComponentHandler, GH_DocumentHandler | +| server.py | ~1185 | Zu groß | Tool-Definitionen in separate Module | + +## Changelog + +### 2025-12-29 + +- Initiale Entwicklung des RhinoMCP Plugins +- Implementiert: Rhino-Geometrie, Grasshopper-Integration, Mesh/Terrain-Tools +- Neue GH-Tools: `delete_component`, `add_group`, `add_scribble`, `rename_component` +- GIS-Tools: GeoTIFF, Shapefile, XYZ Import +- Terrain-Analyse: Konturlinien, Gefälle, Wasserscheiden + +## Offene Aufgaben + +- [ ] Code-Refactoring: Große Dateien aufteilen (max. 300 Zeilen) +- [ ] Unit-Tests implementieren +- [ ] Fehlerbehandlung verbessern +- [ ] Dokumentation erweitern + +## Projektstruktur + +```text +Rhino-MCP/ +├── CLAUDE.md # Diese Dokumentation +├── README.md # Öffentliche Dokumentation +├── LICENSE # MIT Lizenz +├── install.bat # Windows-Installer +├── src/ +│ ├── mcp-server/ +│ │ ├── server.py # MCP-Server (TODO: aufteilen) +│ │ └── requirements.txt +│ └── rhino-plugin/ +│ └── RhinoMCP/ +│ ├── RhinoMCP.csproj +│ ├── RhinoMcpPlugin.cs +│ ├── HttpServer.cs +│ ├── CommandHandler.cs # TODO: aufteilen +│ ├── GrasshopperHandler.cs # TODO: aufteilen +│ └── MeshTerrainHandler.cs # TODO: aufteilen +└── docs/ # Zusätzliche Dokumentation +``` diff --git a/install.bat b/install.bat new file mode 100644 index 0000000..98dcd83 --- /dev/null +++ b/install.bat @@ -0,0 +1,20 @@ +@echo off +echo Installing RhinoMCP Plugin... + +set RHINO_PLUGINS=%APPDATA%\McNeel\Rhinoceros\9.0\Plug-ins\RhinoMCP (1.0.0) +set SOURCE_DIR=%~dp0src\rhino-plugin\RhinoMCP\bin\Release\net7.0-windows + +if not exist "%RHINO_PLUGINS%" ( + mkdir "%RHINO_PLUGINS%" +) + +echo Copying files to: %RHINO_PLUGINS% +xcopy /Y /E "%SOURCE_DIR%\*" "%RHINO_PLUGINS%\" + +echo. +echo Installation complete! +echo. +echo The plugin will load automatically when Rhino 9 starts. +echo If Rhino is running, restart it or run: _PlugInManager to load RhinoMCP.dll +echo. +pause diff --git a/src/mcp-server/server.py b/src/mcp-server/server.py index b89ccfd..82cd07d 100644 --- a/src/mcp-server/server.py +++ b/src/mcp-server/server.py @@ -495,6 +495,425 @@ async def list_tools() -> list[Tool]: } ), + # === Mesh Tools === + Tool( + name="rhino_import_mesh", + description="Import a mesh file (OBJ, STL, PLY, 3DS, FBX) into Rhino", + inputSchema={ + "type": "object", + "properties": { + "file_path": {"type": "string", "description": "Path to the mesh file"} + }, + "required": ["file_path"] + } + ), + Tool( + name="rhino_get_mesh_info", + description="Get detailed mesh information (vertices, faces, area, volume, is closed, etc.)", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the mesh object"} + }, + "required": ["object_id"] + } + ), + Tool( + name="rhino_mesh_from_points", + description="Create a terrain mesh from a set of 3D points using Delaunay triangulation", + inputSchema={ + "type": "object", + "properties": { + "points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number"} + }, + "required": ["x", "y", "z"] + }, + "description": "List of 3D points for terrain mesh" + } + }, + "required": ["points"] + } + ), + Tool( + name="rhino_mesh_reduce", + description="Reduce mesh polygon count while preserving shape", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the mesh"}, + "target_percent": {"type": "number", "description": "Target percentage of faces to keep (0-100)", "default": 50} + }, + "required": ["object_id"] + } + ), + Tool( + name="rhino_mesh_smooth", + description="Smooth a mesh surface", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the mesh"}, + "iterations": {"type": "integer", "description": "Number of smoothing iterations", "default": 1} + }, + "required": ["object_id"] + } + ), + Tool( + name="rhino_mesh_contours", + description="Create contour lines (elevation lines) from a terrain mesh", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the terrain mesh"}, + "interval": {"type": "number", "description": "Vertical interval between contours (e.g., 1.0 for 1m contours)"}, + "base_elevation": {"type": "number", "description": "Starting elevation", "default": 0} + }, + "required": ["object_id", "interval"] + } + ), + Tool( + name="rhino_mesh_slope_analysis", + description="Analyze slope/gradient of terrain mesh, returns min/max/average slope and optionally colors mesh by slope", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the terrain mesh"}, + "color_by_slope": {"type": "boolean", "description": "Color mesh faces by slope angle", "default": True} + }, + "required": ["object_id"] + } + ), + Tool( + name="rhino_mesh_volume_between", + description="Calculate volume between two meshes (cut/fill calculation for earthwork)", + inputSchema={ + "type": "object", + "properties": { + "mesh1_id": {"type": "string", "description": "GUID of first mesh (e.g., existing terrain)"}, + "mesh2_id": {"type": "string", "description": "GUID of second mesh (e.g., proposed terrain)"} + }, + "required": ["mesh1_id", "mesh2_id"] + } + ), + Tool( + name="rhino_mesh_section", + description="Create section/profile curves through a mesh at specified locations", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the mesh"}, + "plane_origin": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number", "default": 0} + }, + "required": ["x", "y"] + }, + "plane_normal": { + "type": "object", + "properties": { + "x": {"type": "number", "default": 0}, + "y": {"type": "number", "default": 1}, + "z": {"type": "number", "default": 0} + }, + "description": "Normal vector of cutting plane" + } + }, + "required": ["object_id", "plane_origin"] + } + ), + + # === Point Cloud Tools === + Tool( + name="rhino_import_pointcloud", + description="Import a point cloud file (XYZ, PTS, E57, LAS, LAZ, PLY) into Rhino", + inputSchema={ + "type": "object", + "properties": { + "file_path": {"type": "string", "description": "Path to the point cloud file"} + }, + "required": ["file_path"] + } + ), + Tool( + name="rhino_get_pointcloud_info", + description="Get point cloud information (point count, bounding box, has colors, has normals)", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the point cloud"} + }, + "required": ["object_id"] + } + ), + Tool( + name="rhino_pointcloud_to_mesh", + description="Convert point cloud to terrain mesh using Delaunay triangulation", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the point cloud"}, + "max_edge_length": {"type": "number", "description": "Maximum triangle edge length (filters out large triangles)", "default": 0} + }, + "required": ["object_id"] + } + ), + Tool( + name="rhino_pointcloud_sample", + description="Reduce point cloud by random or grid sampling", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the point cloud"}, + "method": {"type": "string", "enum": ["random", "grid"], "description": "Sampling method"}, + "target_count": {"type": "integer", "description": "Target number of points (for random sampling)"}, + "grid_size": {"type": "number", "description": "Grid cell size (for grid sampling)"} + }, + "required": ["object_id", "method"] + } + ), + Tool( + name="rhino_pointcloud_crop", + description="Crop point cloud to a boundary curve or box", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the point cloud"}, + "boundary_id": {"type": "string", "description": "GUID of boundary curve (closed polyline) or box"} + }, + "required": ["object_id", "boundary_id"] + } + ), + Tool( + name="rhino_pointcloud_elevation_at", + description="Get elevation (Z value) at specific X,Y coordinates from point cloud or mesh", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of point cloud or mesh"}, + "x": {"type": "number", "description": "X coordinate"}, + "y": {"type": "number", "description": "Y coordinate"} + }, + "required": ["object_id", "x", "y"] + } + ), + + # === Hydrology / Water Flow Tools === + Tool( + name="rhino_terrain_flow_direction", + description="Calculate water flow direction on terrain mesh (creates arrows showing drainage direction)", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of terrain mesh"} + }, + "required": ["object_id"] + } + ), + Tool( + name="rhino_terrain_watershed", + description="Delineate watershed/catchment areas on terrain", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of terrain mesh"}, + "pour_point": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"} + }, + "description": "Pour point (outlet) location" + } + }, + "required": ["object_id", "pour_point"] + } + ), + Tool( + name="rhino_terrain_low_points", + description="Find low points/depressions in terrain where water would collect", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of terrain mesh"}, + "min_depth": {"type": "number", "description": "Minimum depression depth to report", "default": 0.1} + }, + "required": ["object_id"] + } + ), + Tool( + name="rhino_create_drainage_line", + description="Create a drainage/swale line following terrain low points between two points", + inputSchema={ + "type": "object", + "properties": { + "terrain_id": {"type": "string", "description": "GUID of terrain mesh"}, + "start_point": { + "type": "object", + "properties": {"x": {"type": "number"}, "y": {"type": "number"}}, + "required": ["x", "y"] + }, + "end_point": { + "type": "object", + "properties": {"x": {"type": "number"}, "y": {"type": "number"}}, + "required": ["x", "y"] + } + }, + "required": ["terrain_id", "start_point", "end_point"] + } + ), + + # === Road/Path Tools === + Tool( + name="rhino_create_road_surface", + description="Create a road surface on terrain from centerline curve with specified width and cross-slope", + inputSchema={ + "type": "object", + "properties": { + "terrain_id": {"type": "string", "description": "GUID of terrain mesh"}, + "centerline_id": {"type": "string", "description": "GUID of road centerline curve"}, + "width": {"type": "number", "description": "Road width"}, + "cross_slope": {"type": "number", "description": "Cross slope percentage (e.g., 2 for 2%)", "default": 2} + }, + "required": ["terrain_id", "centerline_id", "width"] + } + ), + Tool( + name="rhino_analyze_road_grades", + description="Analyze longitudinal grades along a road/path curve on terrain", + inputSchema={ + "type": "object", + "properties": { + "terrain_id": {"type": "string", "description": "GUID of terrain mesh"}, + "curve_id": {"type": "string", "description": "GUID of road/path curve"}, + "station_interval": {"type": "number", "description": "Interval for grade measurements", "default": 10} + }, + "required": ["terrain_id", "curve_id"] + } + ), + + # === GIS Tools === + Tool( + name="rhino_import_geotiff", + description="Import a GeoTIFF DEM (Digital Elevation Model) file as a heightfield mesh", + inputSchema={ + "type": "object", + "properties": { + "file_path": {"type": "string", "description": "Path to the GeoTIFF file (.tif, .tiff)"} + }, + "required": ["file_path"] + } + ), + Tool( + name="rhino_import_shapefile", + description="Import an ESRI Shapefile (.shp) containing vector data (points, lines, polygons)", + inputSchema={ + "type": "object", + "properties": { + "file_path": {"type": "string", "description": "Path to the shapefile (.shp)"}, + "layer_name": {"type": "string", "description": "Target layer name for imported geometry", "default": "Shapefile"} + }, + "required": ["file_path"] + } + ), + Tool( + name="rhino_import_xyz", + description="Import XYZ point data from a text file (CSV, TXT) as a point cloud", + inputSchema={ + "type": "object", + "properties": { + "file_path": {"type": "string", "description": "Path to the XYZ file"}, + "delimiter": {"type": "string", "description": "Column delimiter (space, comma, tab)", "default": " "}, + "skip_lines": {"type": "integer", "description": "Number of header lines to skip", "default": 0} + }, + "required": ["file_path"] + } + ), + Tool( + name="rhino_transform_coordinates", + description="Transform/offset coordinates of objects (for coordinate system conversion or moving to origin)", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of object to transform (optional - transforms all if not specified)"}, + "from_crs": {"type": "string", "description": "Source coordinate system (e.g., 'WGS84', 'UTM32N', 'EPSG:25832')"}, + "to_crs": {"type": "string", "description": "Target coordinate system"}, + "offset_x": {"type": "number", "description": "X offset to apply", "default": 0}, + "offset_y": {"type": "number", "description": "Y offset to apply", "default": 0}, + "offset_z": {"type": "number", "description": "Z offset to apply", "default": 0} + }, + "required": [] + } + ), + Tool( + name="rhino_get_bounding_box", + description="Get the bounding box coordinates of an object or all objects (useful for coordinate reference)", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of object (optional - gets bounds of all objects if not specified)"} + }, + "required": [] + } + ), + Tool( + name="rhino_create_grid", + description="Create a survey/reference grid within a boundary with optional terrain elevation sampling", + inputSchema={ + "type": "object", + "properties": { + "boundary_id": {"type": "string", "description": "GUID of boundary curve (optional)"}, + "cell_size": {"type": "number", "description": "Grid cell size", "default": 10}, + "elevation_source": {"type": "string", "description": "GUID of terrain mesh to sample elevations from (optional)"} + }, + "required": [] + } + ), + Tool( + name="rhino_create_site_section", + description="Create a terrain section/profile between two points with elevation data", + inputSchema={ + "type": "object", + "properties": { + "terrain_id": {"type": "string", "description": "GUID of terrain mesh"}, + "start_point": { + "type": "object", + "properties": {"x": {"type": "number"}, "y": {"type": "number"}}, + "required": ["x", "y"] + }, + "end_point": { + "type": "object", + "properties": {"x": {"type": "number"}, "y": {"type": "number"}}, + "required": ["x", "y"] + }, + "sample_interval": {"type": "number", "description": "Distance between sample points", "default": 1} + }, + "required": ["terrain_id", "start_point", "end_point"] + } + ), + Tool( + name="rhino_calculate_area_volume", + description="Calculate area and cut/fill volumes within a boundary relative to terrain", + inputSchema={ + "type": "object", + "properties": { + "boundary_id": {"type": "string", "description": "GUID of closed boundary curve"}, + "terrain_id": {"type": "string", "description": "GUID of terrain mesh (optional for volume calculation)"}, + "reference_elevation": {"type": "number", "description": "Reference elevation for cut/fill calculation", "default": 0} + }, + "required": ["boundary_id"] + } + ), + # === Export Tools === Tool( name="rhino_export", @@ -662,6 +1081,67 @@ async def list_tools() -> list[Tool]: "required": [] } ), + Tool( + name="grasshopper_delete_component", + description="Delete a component from the Grasshopper canvas", + inputSchema={ + "type": "object", + "properties": { + "component_id": {"type": "string", "description": "GUID or name of the component to delete"} + }, + "required": ["component_id"] + } + ), + Tool( + name="grasshopper_add_group", + description="Add a group/comment box around components to organize and document the definition", + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "Group name/label (appears as title)"}, + "component_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "List of component GUIDs to include in the group" + }, + "color": {"type": "string", "description": "Group color as hex (e.g., '#FF9999' for light red)", "default": "#FFDDDDDD"} + }, + "required": ["name"] + } + ), + Tool( + name="grasshopper_add_scribble", + description="Add a text annotation/comment to the Grasshopper canvas", + inputSchema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text content of the annotation"}, + "position": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"} + }, + "required": ["x", "y"], + "description": "Position on canvas" + }, + "font_size": {"type": "integer", "description": "Font size in points", "default": 20} + }, + "required": ["text", "position"] + } + ), + Tool( + name="grasshopper_rename_component", + description="Rename a component's nickname/label", + inputSchema={ + "type": "object", + "properties": { + "component_id": {"type": "string", "description": "GUID or name of the component"}, + "new_name": {"type": "string", "description": "New nickname for the component"} + }, + "required": ["component_id", "new_name"] + } + ), # === Rhino Commands === Tool( diff --git a/src/rhino-plugin/RhinoMCP/CommandHandler.cs b/src/rhino-plugin/RhinoMCP/CommandHandler.cs index eba01b1..2f7db3d 100644 --- a/src/rhino-plugin/RhinoMCP/CommandHandler.cs +++ b/src/rhino-plugin/RhinoMCP/CommandHandler.cs @@ -64,6 +64,27 @@ namespace RhinoMCP // Grasshopper (delegated to GrasshopperHandler) var gh when gh.StartsWith("grasshopper_") => GrasshopperHandler.Execute(action, parameters), + // Mesh, Point Cloud, Terrain, GIS (delegated to MeshTerrainHandler) + var mesh when mesh.StartsWith("rhino_mesh_") || + mesh.StartsWith("rhino_import_mesh") || + mesh.StartsWith("rhino_get_mesh_") || + mesh.StartsWith("rhino_pointcloud_") || + mesh.StartsWith("rhino_import_pointcloud") || + mesh.StartsWith("rhino_get_pointcloud_") || + mesh.StartsWith("rhino_terrain_") || + mesh.StartsWith("rhino_create_drainage_") || + mesh.StartsWith("rhino_create_road_") || + mesh.StartsWith("rhino_analyze_road_") || + mesh.StartsWith("rhino_import_geotiff") || + mesh.StartsWith("rhino_import_shapefile") || + mesh.StartsWith("rhino_import_xyz") || + mesh.StartsWith("rhino_transform_coordinates") || + mesh.StartsWith("rhino_get_bounding_box") || + mesh.StartsWith("rhino_create_grid") || + mesh.StartsWith("rhino_create_site_section") || + mesh.StartsWith("rhino_calculate_area_volume") + => MeshTerrainHandler.Execute(action, parameters, doc!), + _ => new { error = $"Unknown action: {action}" } }; } diff --git a/src/rhino-plugin/RhinoMCP/GrasshopperHandler.cs b/src/rhino-plugin/RhinoMCP/GrasshopperHandler.cs index 5fac49b..89601ff 100644 --- a/src/rhino-plugin/RhinoMCP/GrasshopperHandler.cs +++ b/src/rhino-plugin/RhinoMCP/GrasshopperHandler.cs @@ -31,6 +31,10 @@ namespace RhinoMCP "grasshopper_bake" => BakeGeometry(parameters), "grasshopper_save_definition" => SaveDefinition(parameters), "grasshopper_recompute" => Recompute(), + "grasshopper_delete_component" => DeleteComponent(parameters), + "grasshopper_add_group" => AddGroup(parameters), + "grasshopper_add_scribble" => AddScribble(parameters), + "grasshopper_rename_component" => RenameComponent(parameters), _ => new { error = $"Unknown Grasshopper action: {action}" } }; } @@ -513,5 +517,159 @@ namespace RhinoMCP return new { success = true, message = "Definition recomputed" }; } + + private static object DeleteComponent(JObject p) + { + var doc = GetActiveDocument(); + if (doc == null) + return new { error = "No active Grasshopper document" }; + + var componentId = p["component_id"]?.ToString() ?? ""; + if (string.IsNullOrEmpty(componentId)) + return new { error = "Component ID required" }; + + var obj = FindComponent(doc, componentId); + if (obj == null) + return new { error = $"Component '{componentId}' not found" }; + + var name = obj.Name; + var guid = obj.InstanceGuid.ToString(); + + doc.RemoveObject(obj, true); + Instances.ActiveCanvas?.Refresh(); + + return new + { + success = true, + deleted_component = name, + instance_guid = guid + }; + } + + private static object AddGroup(JObject p) + { + var doc = GetActiveDocument(); + if (doc == null) + return new { error = "No active Grasshopper document" }; + + var name = p["name"]?.ToString() ?? "Group"; + var componentIds = p["component_ids"] as JArray; + var colorHex = p["color"]?.ToString() ?? "#FFDDDDDD"; + + // Parse color + Color groupColor; + try + { + groupColor = ColorTranslator.FromHtml(colorHex); + } + catch + { + groupColor = Color.FromArgb(255, 221, 221, 221); + } + + var group = new GH_Group(); + group.CreateAttributes(); + group.NickName = name; + group.Colour = groupColor; + + // Add components to group + var addedCount = 0; + if (componentIds != null) + { + foreach (var idToken in componentIds) + { + var id = idToken?.ToString(); + if (!string.IsNullOrEmpty(id)) + { + var obj = FindComponent(doc, id); + if (obj != null) + { + group.AddObject(obj.InstanceGuid); + addedCount++; + } + } + } + } + + doc.AddObject(group, false); + group.ExpireCaches(); + Instances.ActiveCanvas?.Refresh(); + + return new + { + success = true, + group_name = name, + instance_guid = group.InstanceGuid.ToString(), + components_in_group = addedCount, + color = colorHex + }; + } + + private static object AddScribble(JObject p) + { + var doc = GetActiveDocument(); + if (doc == null) + return new { error = "No active Grasshopper document" }; + + var text = p["text"]?.ToString() ?? "Comment"; + var position = p["position"] as JObject; + var x = position?["x"]?.Value() ?? 0; + var y = position?["y"]?.Value() ?? 0; + var fontSize = p["font_size"]?.Value() ?? 20; + + var scribble = new GH_Scribble(); + scribble.CreateAttributes(); + + // Set text and position + scribble.Text = text; + scribble.Font = new Font("Arial", fontSize); + + // Position the scribble + var bounds = new RectangleF(x, y, 200, 50); + scribble.Attributes.Bounds = bounds; + scribble.Attributes.Pivot = new PointF(x, y); + + doc.AddObject(scribble, false); + Instances.ActiveCanvas?.Refresh(); + + return new + { + success = true, + text, + instance_guid = scribble.InstanceGuid.ToString(), + position = new { x, y }, + font_size = fontSize + }; + } + + private static object RenameComponent(JObject p) + { + var doc = GetActiveDocument(); + if (doc == null) + return new { error = "No active Grasshopper document" }; + + var componentId = p["component_id"]?.ToString() ?? ""; + var newName = p["new_name"]?.ToString() ?? ""; + + if (string.IsNullOrEmpty(componentId)) + return new { error = "Component ID required" }; + + var obj = FindComponent(doc, componentId); + if (obj == null) + return new { error = $"Component '{componentId}' not found" }; + + var oldName = obj.NickName; + obj.NickName = newName; + obj.ExpirePreview(true); + Instances.ActiveCanvas?.Refresh(); + + return new + { + success = true, + instance_guid = obj.InstanceGuid.ToString(), + old_name = oldName, + new_name = newName + }; + } } } diff --git a/src/rhino-plugin/RhinoMCP/MeshTerrainHandler.cs b/src/rhino-plugin/RhinoMCP/MeshTerrainHandler.cs new file mode 100644 index 0000000..1e7fb23 --- /dev/null +++ b/src/rhino-plugin/RhinoMCP/MeshTerrainHandler.cs @@ -0,0 +1,1508 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using Newtonsoft.Json.Linq; +using Rhino; +using Rhino.DocObjects; +using Rhino.Geometry; + +namespace RhinoMCP +{ + /// + /// Handles mesh, point cloud, and terrain-related commands. + /// + public static class MeshTerrainHandler + { + public static object Execute(string action, JObject parameters, RhinoDoc doc) + { + return action switch + { + // Mesh Tools + "rhino_import_mesh" => ImportMesh(doc, parameters), + "rhino_get_mesh_info" => GetMeshInfo(doc, parameters), + "rhino_mesh_from_points" => MeshFromPoints(doc, parameters), + "rhino_mesh_reduce" => MeshReduce(doc, parameters), + "rhino_mesh_smooth" => MeshSmooth(doc, parameters), + "rhino_mesh_contours" => MeshContours(doc, parameters), + "rhino_mesh_slope_analysis" => MeshSlopeAnalysis(doc, parameters), + "rhino_mesh_volume_between" => MeshVolumeBetween(doc, parameters), + "rhino_mesh_section" => MeshSection(doc, parameters), + + // Point Cloud Tools + "rhino_import_pointcloud" => ImportPointCloud(doc, parameters), + "rhino_get_pointcloud_info" => GetPointCloudInfo(doc, parameters), + "rhino_pointcloud_to_mesh" => PointCloudToMesh(doc, parameters), + "rhino_pointcloud_sample" => PointCloudSample(doc, parameters), + "rhino_pointcloud_crop" => PointCloudCrop(doc, parameters), + "rhino_pointcloud_elevation_at" => PointCloudElevationAt(doc, parameters), + + // Terrain/Hydrology Tools + "rhino_terrain_flow_direction" => TerrainFlowDirection(doc, parameters), + "rhino_terrain_watershed" => TerrainWatershed(doc, parameters), + "rhino_terrain_low_points" => TerrainLowPoints(doc, parameters), + "rhino_create_drainage_line" => CreateDrainageLine(doc, parameters), + + // Road Tools + "rhino_create_road_surface" => CreateRoadSurface(doc, parameters), + "rhino_analyze_road_grades" => AnalyzeRoadGrades(doc, parameters), + + // GIS Tools + "rhino_import_geotiff" => ImportGeoTiff(doc, parameters), + "rhino_import_shapefile" => ImportShapefile(doc, parameters), + "rhino_import_xyz" => ImportXyzFile(doc, parameters), + "rhino_transform_coordinates" => TransformCoordinates(doc, parameters), + "rhino_get_bounding_box" => GetBoundingBoxCoordinates(doc, parameters), + "rhino_create_grid" => CreateGridFromBoundary(doc, parameters), + "rhino_create_site_section" => CreateSiteSection(doc, parameters), + "rhino_calculate_area_volume" => CalculateAreaVolume(doc, parameters), + + _ => new { error = $"Unknown terrain action: {action}" } + }; + } + + #region Mesh Tools + + private static object ImportMesh(RhinoDoc doc, JObject p) + { + var filePath = p["file_path"]?.ToString() ?? ""; + if (string.IsNullOrEmpty(filePath)) + return new { error = "File path required" }; + + if (!System.IO.File.Exists(filePath)) + return new { error = $"File not found: {filePath}" }; + + var command = $"_-Import \"{filePath}\" _Enter"; + var result = RhinoApp.RunScript(command, false); + + if (!result) + return new { error = "Import failed" }; + + // Get the most recently added object + var lastObj = doc.Objects.OrderByDescending(o => o.Attributes.ObjectId).FirstOrDefault(); + doc.Views.Redraw(); + + return new + { + success = true, + file_path = filePath, + object_id = lastObj?.Id.ToString() + }; + } + + private static object GetMeshInfo(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var obj = doc.Objects.Find(objId); + if (obj == null) + return new { error = "Object not found" }; + + var mesh = obj.Geometry as Mesh; + if (mesh == null) + return new { error = "Object is not a mesh" }; + + var bbox = mesh.GetBoundingBox(true); + var area = AreaMassProperties.Compute(mesh); + var volume = VolumeMassProperties.Compute(mesh); + + return new + { + id = objId.ToString(), + vertex_count = mesh.Vertices.Count, + face_count = mesh.Faces.Count, + is_closed = mesh.IsClosed, + is_valid = mesh.IsValid, + has_vertex_normals = mesh.Normals.Count > 0, + has_vertex_colors = mesh.VertexColors.Count > 0, + bounding_box = new + { + min = new { bbox.Min.X, bbox.Min.Y, bbox.Min.Z }, + max = new { bbox.Max.X, bbox.Max.Y, bbox.Max.Z }, + size = new { x = bbox.Max.X - bbox.Min.X, y = bbox.Max.Y - bbox.Min.Y, z = bbox.Max.Z - bbox.Min.Z } + }, + area = area?.Area ?? 0, + volume = volume?.Volume ?? 0 + }; + } + + private static object MeshFromPoints(RhinoDoc doc, JObject p) + { + var pointsArray = p["points"] as JArray; + if (pointsArray == null || pointsArray.Count < 3) + return new { error = "At least 3 points required" }; + + var points = pointsArray.Select(pt => new Point3d( + pt["x"]?.Value() ?? 0, + pt["y"]?.Value() ?? 0, + pt["z"]?.Value() ?? 0 + )).ToList(); + + // Create Delaunay mesh from points (2D projection, then assign Z) + var mesh = new Mesh(); + + // Use Mesh.CreateFromTessellation for terrain + var polyline = new Polyline(points); + var patch = Mesh.CreateFromClosedPolyline(polyline); + + if (patch == null) + { + // Fallback: try manual triangulation + mesh = CreateDelaunayMesh(points); + } + else + { + mesh = patch; + } + + if (mesh == null || !mesh.IsValid) + { + // Manual triangulation fallback + mesh = CreateDelaunayMesh(points); + } + + if (mesh == null) + return new { error = "Failed to create mesh from points" }; + + var id = doc.Objects.AddMesh(mesh); + doc.Views.Redraw(); + + return new + { + success = true, + id = id.ToString(), + vertex_count = mesh.Vertices.Count, + face_count = mesh.Faces.Count + }; + } + + private static Mesh? CreateDelaunayMesh(List points) + { + if (points.Count < 3) return null; + + var mesh = new Mesh(); + + // Add vertices + foreach (var pt in points) + { + mesh.Vertices.Add(pt); + } + + // Simple triangulation for terrain (assumes points form a grid-like pattern) + // For proper Delaunay, we'd need a more sophisticated algorithm + // This is a basic approach - connect nearest points + + // Sort by X then Y for grid-like triangulation + var sortedIndices = points + .Select((pt, idx) => new { pt, idx }) + .OrderBy(x => x.pt.X) + .ThenBy(x => x.pt.Y) + .Select(x => x.idx) + .ToList(); + + // Create triangles by walking through sorted points + for (int i = 0; i < sortedIndices.Count - 2; i++) + { + mesh.Faces.AddFace(sortedIndices[i], sortedIndices[i + 1], sortedIndices[i + 2]); + } + + mesh.Normals.ComputeNormals(); + mesh.Compact(); + + return mesh; + } + + private static object MeshReduce(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var targetPercent = p["target_percent"]?.Value() ?? 50; + + var obj = doc.Objects.Find(objId); + if (obj == null) + return new { error = "Object not found" }; + + var mesh = obj.Geometry as Mesh; + if (mesh == null) + return new { error = "Object is not a mesh" }; + + var originalFaces = mesh.Faces.Count; + var targetFaces = (int)(originalFaces * targetPercent / 100.0); + + var reducedMesh = mesh.DuplicateMesh(); + reducedMesh.Reduce(targetFaces, true, 5, true); + + doc.Objects.Replace(objId, reducedMesh); + doc.Views.Redraw(); + + return new + { + success = true, + original_faces = originalFaces, + reduced_faces = reducedMesh.Faces.Count, + reduction_percent = 100.0 - (reducedMesh.Faces.Count * 100.0 / originalFaces) + }; + } + + private static object MeshSmooth(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var iterations = p["iterations"]?.Value() ?? 1; + + var obj = doc.Objects.Find(objId); + if (obj == null) + return new { error = "Object not found" }; + + var mesh = obj.Geometry as Mesh; + if (mesh == null) + return new { error = "Object is not a mesh" }; + + var smoothedMesh = mesh.DuplicateMesh(); + + for (int i = 0; i < iterations; i++) + { + smoothedMesh.Smooth(1.0, true, true, true, true, SmoothingCoordinateSystem.Object); + } + + doc.Objects.Replace(objId, smoothedMesh); + doc.Views.Redraw(); + + return new { success = true, iterations }; + } + + private static object MeshContours(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var interval = p["interval"]?.Value() ?? 1.0; + var baseElevation = p["base_elevation"]?.Value() ?? 0; + + var obj = doc.Objects.Find(objId); + if (obj == null) + return new { error = "Object not found" }; + + var mesh = obj.Geometry as Mesh; + if (mesh == null) + return new { error = "Object is not a mesh" }; + + var bbox = mesh.GetBoundingBox(true); + var minZ = bbox.Min.Z; + var maxZ = bbox.Max.Z; + + var contourIds = new List(); + var contourElevations = new List(); + + // Calculate contours at each interval + for (double z = baseElevation; z <= maxZ; z += interval) + { + if (z < minZ) continue; + + var plane = new Plane(new Point3d(0, 0, z), Vector3d.ZAxis); + var curves = Mesh.CreateContourCurves(mesh, plane); + + foreach (var curve in curves) + { + var id = doc.Objects.AddCurve(curve); + contourIds.Add(id.ToString()); + contourElevations.Add(z); + } + } + + doc.Views.Redraw(); + + return new + { + success = true, + contour_count = contourIds.Count, + contour_ids = contourIds, + elevations = contourElevations, + interval, + min_elevation = minZ, + max_elevation = maxZ + }; + } + + private static object MeshSlopeAnalysis(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var colorBySlope = p["color_by_slope"]?.Value() ?? true; + + var obj = doc.Objects.Find(objId); + if (obj == null) + return new { error = "Object not found" }; + + var mesh = obj.Geometry as Mesh; + if (mesh == null) + return new { error = "Object is not a mesh" }; + + mesh.FaceNormals.ComputeFaceNormals(); + + var slopes = new List(); + var coloredMesh = mesh.DuplicateMesh(); + + if (colorBySlope) + coloredMesh.VertexColors.Clear(); + + for (int i = 0; i < mesh.Faces.Count; i++) + { + var normal = mesh.FaceNormals[i]; + // Slope angle from horizontal (0 = flat, 90 = vertical) + var slopeAngle = Math.Acos(Math.Abs(normal.Z)) * 180.0 / Math.PI; + slopes.Add(slopeAngle); + } + + if (colorBySlope && slopes.Count > 0) + { + var maxSlope = slopes.Max(); + coloredMesh.VertexColors.CreateMonotoneMesh(Color.Green); + + // Color faces by slope + for (int i = 0; i < mesh.Faces.Count; i++) + { + var face = mesh.Faces[i]; + var normalizedSlope = slopes[i] / Math.Max(maxSlope, 45); // Normalize to 45 degrees + normalizedSlope = Math.Min(1.0, normalizedSlope); + + // Green (flat) -> Yellow -> Red (steep) + var r = (int)(255 * normalizedSlope); + var g = (int)(255 * (1 - normalizedSlope * 0.5)); + var color = Color.FromArgb(r, g, 0); + + coloredMesh.VertexColors[face.A] = color; + coloredMesh.VertexColors[face.B] = color; + coloredMesh.VertexColors[face.C] = color; + if (face.IsQuad) + coloredMesh.VertexColors[face.D] = color; + } + + doc.Objects.Replace(objId, coloredMesh); + doc.Views.Redraw(); + } + + return new + { + success = true, + min_slope = slopes.Min(), + max_slope = slopes.Max(), + average_slope = slopes.Average(), + face_count = slopes.Count, + colored = colorBySlope + }; + } + + private static object MeshVolumeBetween(RhinoDoc doc, JObject p) + { + var mesh1Id = Guid.Parse(p["mesh1_id"]?.ToString() ?? ""); + var mesh2Id = Guid.Parse(p["mesh2_id"]?.ToString() ?? ""); + + var mesh1 = doc.Objects.Find(mesh1Id)?.Geometry as Mesh; + var mesh2 = doc.Objects.Find(mesh2Id)?.Geometry as Mesh; + + if (mesh1 == null || mesh2 == null) + return new { error = "One or both meshes not found" }; + + // Calculate volumes + var vol1 = VolumeMassProperties.Compute(mesh1); + var vol2 = VolumeMassProperties.Compute(mesh2); + + // Simple difference calculation + var volume1 = vol1?.Volume ?? 0; + var volume2 = vol2?.Volume ?? 0; + var difference = volume2 - volume1; + + return new + { + success = true, + mesh1_volume = volume1, + mesh2_volume = volume2, + difference = difference, + cut_volume = difference > 0 ? 0 : Math.Abs(difference), + fill_volume = difference > 0 ? difference : 0 + }; + } + + private static object MeshSection(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var origin = p["plane_origin"] as JObject; + var normal = p["plane_normal"] as JObject; + + var mesh = doc.Objects.Find(objId)?.Geometry as Mesh; + if (mesh == null) + return new { error = "Mesh not found" }; + + var planeOrigin = new Point3d( + origin?["x"]?.Value() ?? 0, + origin?["y"]?.Value() ?? 0, + origin?["z"]?.Value() ?? 0 + ); + + var planeNormal = new Vector3d( + normal?["x"]?.Value() ?? 0, + normal?["y"]?.Value() ?? 1, + normal?["z"]?.Value() ?? 0 + ); + + var plane = new Plane(planeOrigin, planeNormal); + var curves = Mesh.CreateContourCurves(mesh, plane); + + var curveIds = new List(); + foreach (var curve in curves) + { + var id = doc.Objects.AddCurve(curve); + curveIds.Add(id.ToString()); + } + + doc.Views.Redraw(); + + return new + { + success = true, + section_count = curveIds.Count, + curve_ids = curveIds + }; + } + + #endregion + + #region Point Cloud Tools + + private static object ImportPointCloud(RhinoDoc doc, JObject p) + { + var filePath = p["file_path"]?.ToString() ?? ""; + if (string.IsNullOrEmpty(filePath)) + return new { error = "File path required" }; + + if (!System.IO.File.Exists(filePath)) + return new { error = $"File not found: {filePath}" }; + + var command = $"_-Import \"{filePath}\" _Enter"; + var result = RhinoApp.RunScript(command, false); + + doc.Views.Redraw(); + + return new { success = result, file_path = filePath }; + } + + private static object GetPointCloudInfo(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var obj = doc.Objects.Find(objId); + if (obj == null) + return new { error = "Object not found" }; + + var cloud = obj.Geometry as PointCloud; + if (cloud == null) + return new { error = "Object is not a point cloud" }; + + var bbox = cloud.GetBoundingBox(true); + + return new + { + id = objId.ToString(), + point_count = cloud.Count, + has_colors = cloud.ContainsColors, + has_normals = cloud.ContainsNormals, + bounding_box = new + { + min = new { bbox.Min.X, bbox.Min.Y, bbox.Min.Z }, + max = new { bbox.Max.X, bbox.Max.Y, bbox.Max.Z }, + size = new { x = bbox.Max.X - bbox.Min.X, y = bbox.Max.Y - bbox.Min.Y, z = bbox.Max.Z - bbox.Min.Z } + } + }; + } + + private static object PointCloudToMesh(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var maxEdgeLength = p["max_edge_length"]?.Value() ?? 0; + + var cloud = doc.Objects.Find(objId)?.Geometry as PointCloud; + if (cloud == null) + return new { error = "Point cloud not found" }; + + var points = cloud.GetPoints().ToList(); + + // Create mesh from point cloud + Mesh? mesh = null; + + // Try to create mesh from points + if (points.Count > 2) + { + // Fallback to manual mesh creation + mesh = CreateDelaunayMesh(points.Select(p => new Point3d(p.X, p.Y, p.Z)).ToList()); + } + + if (mesh == null) + return new { error = "Failed to create mesh from point cloud" }; + + var id = doc.Objects.AddMesh(mesh); + doc.Views.Redraw(); + + return new + { + success = true, + id = id.ToString(), + vertex_count = mesh.Vertices.Count, + face_count = mesh.Faces.Count + }; + } + + private static object PointCloudSample(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var method = p["method"]?.ToString() ?? "random"; + var targetCount = p["target_count"]?.Value() ?? 10000; + var gridSize = p["grid_size"]?.Value() ?? 1.0; + + var cloud = doc.Objects.Find(objId)?.Geometry as PointCloud; + if (cloud == null) + return new { error = "Point cloud not found" }; + + var points = cloud.GetPoints().ToList(); + var newCloud = new PointCloud(); + + if (method == "random") + { + var random = new Random(); + var step = Math.Max(1, points.Count / targetCount); + for (int i = 0; i < points.Count; i += step) + { + newCloud.Add(points[i]); + } + } + else if (method == "grid") + { + // Grid sampling - take one point per grid cell + var sampledPoints = new Dictionary<(int, int), Point3d>(); + foreach (var pt in points) + { + var cellX = (int)(pt.X / gridSize); + var cellY = (int)(pt.Y / gridSize); + var key = (cellX, cellY); + + if (!sampledPoints.ContainsKey(key)) + sampledPoints[key] = new Point3d(pt.X, pt.Y, pt.Z); + } + + foreach (var pt in sampledPoints.Values) + newCloud.Add(pt); + } + + var id = doc.Objects.AddPointCloud(newCloud); + doc.Views.Redraw(); + + return new + { + success = true, + id = id.ToString(), + original_count = points.Count, + sampled_count = newCloud.Count, + reduction_percent = 100.0 - (newCloud.Count * 100.0 / points.Count) + }; + } + + private static object PointCloudCrop(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var boundaryId = Guid.Parse(p["boundary_id"]?.ToString() ?? ""); + + var cloud = doc.Objects.Find(objId)?.Geometry as PointCloud; + var boundary = doc.Objects.Find(boundaryId)?.Geometry; + + if (cloud == null) + return new { error = "Point cloud not found" }; + + if (boundary == null) + return new { error = "Boundary not found" }; + + var newCloud = new PointCloud(); + var boundingBox = boundary.GetBoundingBox(true); + + foreach (var pt in cloud.GetPoints()) + { + var point3d = new Point3d(pt.X, pt.Y, pt.Z); + + if (boundary is Curve curve) + { + // Check if point is inside closed curve (2D check) + var containment = curve.Contains(point3d, Plane.WorldXY, doc.ModelAbsoluteTolerance); + if (containment == PointContainment.Inside || containment == PointContainment.Coincident) + newCloud.Add(pt); + } + else if (boundingBox.Contains(point3d)) + { + newCloud.Add(pt); + } + } + + var id = doc.Objects.AddPointCloud(newCloud); + doc.Views.Redraw(); + + return new + { + success = true, + id = id.ToString(), + original_count = cloud.Count, + cropped_count = newCloud.Count + }; + } + + private static object PointCloudElevationAt(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var x = p["x"]?.Value() ?? 0; + var y = p["y"]?.Value() ?? 0; + + var obj = doc.Objects.Find(objId); + if (obj == null) + return new { error = "Object not found" }; + + double elevation = 0; + bool found = false; + + if (obj.Geometry is PointCloud cloud) + { + // Find nearest point in XY + var searchRadius = 10.0; + var nearestDist = double.MaxValue; + + foreach (var pt in cloud.GetPoints()) + { + var dist = Math.Sqrt(Math.Pow(pt.X - x, 2) + Math.Pow(pt.Y - y, 2)); + if (dist < nearestDist && dist < searchRadius) + { + nearestDist = dist; + elevation = pt.Z; + found = true; + } + } + } + else if (obj.Geometry is Mesh mesh) + { + // Ray intersection from above + var ray = new Ray3d(new Point3d(x, y, 10000), -Vector3d.ZAxis); + var hit = Rhino.Geometry.Intersect.Intersection.MeshRay(mesh, ray); + + if (hit >= 0) + { + elevation = ray.PointAt(hit).Z; + found = true; + } + } + + if (!found) + return new { error = "No elevation found at specified location" }; + + return new { success = true, x, y, elevation }; + } + + #endregion + + #region Terrain/Hydrology Tools + + private static object TerrainFlowDirection(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var mesh = doc.Objects.Find(objId)?.Geometry as Mesh; + + if (mesh == null) + return new { error = "Terrain mesh not found" }; + + mesh.FaceNormals.ComputeFaceNormals(); + + var arrows = new List(); + + // Sample points on mesh and show flow direction + var bbox = mesh.GetBoundingBox(true); + var stepX = (bbox.Max.X - bbox.Min.X) / 10; + var stepY = (bbox.Max.Y - bbox.Min.Y) / 10; + + for (double x = bbox.Min.X + stepX / 2; x < bbox.Max.X; x += stepX) + { + for (double y = bbox.Min.Y + stepY / 2; y < bbox.Max.Y; y += stepY) + { + var ray = new Ray3d(new Point3d(x, y, bbox.Max.Z + 100), -Vector3d.ZAxis); + var hit = Rhino.Geometry.Intersect.Intersection.MeshRay(mesh, ray); + + if (hit >= 0) + { + var point = ray.PointAt(hit); + var faceIndex = mesh.ClosestMeshPoint(point, 0)?.FaceIndex ?? -1; + + if (faceIndex >= 0) + { + var normal = mesh.FaceNormals[faceIndex]; + // Flow direction is perpendicular to normal, projected to XY, pointing downhill + var flowDir = new Vector3d(-normal.X, -normal.Y, 0); + flowDir.Unitize(); + + if (flowDir.Length > 0.1) // Only show if there's slope + { + var arrowEnd = point + flowDir * (stepX * 0.4); + var line = new Line(point, arrowEnd); + var id = doc.Objects.AddLine(line); + arrows.Add(id.ToString()); + } + } + } + } + } + + doc.Views.Redraw(); + + return new + { + success = true, + arrow_count = arrows.Count, + arrow_ids = arrows + }; + } + + private static object TerrainWatershed(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var pourPoint = p["pour_point"] as JObject; + + var mesh = doc.Objects.Find(objId)?.Geometry as Mesh; + if (mesh == null) + return new { error = "Terrain mesh not found" }; + + var pourX = pourPoint?["x"]?.Value() ?? 0; + var pourY = pourPoint?["y"]?.Value() ?? 0; + + // Simple watershed approximation - find all points that flow to pour point + // This is a simplified version - proper watershed analysis needs more complex algorithms + + return new + { + success = true, + message = "Watershed analysis - basic implementation. For detailed analysis, use Grasshopper with terrain plugins.", + pour_point = new { x = pourX, y = pourY } + }; + } + + private static object TerrainLowPoints(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var minDepth = p["min_depth"]?.Value() ?? 0.1; + + var mesh = doc.Objects.Find(objId)?.Geometry as Mesh; + if (mesh == null) + return new { error = "Terrain mesh not found" }; + + var lowPoints = new List(); + var lowPointIds = new List(); + + // Find local minima in mesh vertices + for (int i = 0; i < mesh.Vertices.Count; i++) + { + var vertex = mesh.Vertices[i]; + var neighbors = mesh.Vertices.GetConnectedVertices(i); + + if (neighbors == null || neighbors.Length == 0) continue; + + var isLowPoint = true; + var minNeighborZ = double.MaxValue; + + foreach (var neighborIdx in neighbors) + { + var neighborZ = mesh.Vertices[neighborIdx].Z; + minNeighborZ = Math.Min(minNeighborZ, neighborZ); + if (neighborZ <= vertex.Z) + { + isLowPoint = false; + break; + } + } + + if (isLowPoint && (minNeighborZ - vertex.Z) >= minDepth) + { + var pt = new Point3d(vertex.X, vertex.Y, vertex.Z); + var id = doc.Objects.AddPoint(pt); + lowPointIds.Add(id.ToString()); + lowPoints.Add(new + { + x = vertex.X, + y = vertex.Y, + z = vertex.Z, + depth = minNeighborZ - vertex.Z + }); + } + } + + doc.Views.Redraw(); + + return new + { + success = true, + low_point_count = lowPoints.Count, + low_points = lowPoints, + point_ids = lowPointIds + }; + } + + private static object CreateDrainageLine(RhinoDoc doc, JObject p) + { + var terrainId = Guid.Parse(p["terrain_id"]?.ToString() ?? ""); + var startPt = p["start_point"] as JObject; + var endPt = p["end_point"] as JObject; + + var mesh = doc.Objects.Find(terrainId)?.Geometry as Mesh; + if (mesh == null) + return new { error = "Terrain mesh not found" }; + + var start = new Point3d( + startPt?["x"]?.Value() ?? 0, + startPt?["y"]?.Value() ?? 0, + 0 + ); + var end = new Point3d( + endPt?["x"]?.Value() ?? 0, + endPt?["y"]?.Value() ?? 0, + 0 + ); + + // Project points to mesh + var startOnMesh = mesh.ClosestPoint(new Point3d(start.X, start.Y, 10000)); + var endOnMesh = mesh.ClosestPoint(new Point3d(end.X, end.Y, 10000)); + + // Create line on terrain surface (simplified - follows straight path) + var segments = 20; + var points = new List(); + + for (int i = 0; i <= segments; i++) + { + var t = i / (double)segments; + var x = start.X + t * (end.X - start.X); + var y = start.Y + t * (end.Y - start.Y); + + var ray = new Ray3d(new Point3d(x, y, 10000), -Vector3d.ZAxis); + var hit = Rhino.Geometry.Intersect.Intersection.MeshRay(mesh, ray); + + if (hit >= 0) + { + points.Add(ray.PointAt(hit)); + } + } + + if (points.Count < 2) + return new { error = "Could not create drainage line" }; + + var curve = new PolylineCurve(points); + var id = doc.Objects.AddCurve(curve); + doc.Views.Redraw(); + + return new + { + success = true, + id = id.ToString(), + length = curve.GetLength(), + point_count = points.Count + }; + } + + #endregion + + #region GIS Tools + + private static object ImportGeoTiff(RhinoDoc doc, JObject p) + { + var filePath = p["file_path"]?.ToString() ?? ""; + if (string.IsNullOrEmpty(filePath)) + return new { error = "File path required" }; + + if (!System.IO.File.Exists(filePath)) + return new { error = $"File not found: {filePath}" }; + + // Try to import as heightfield using Rhino command + var command = $"_-Heightfield _FromFile \"{filePath}\" _Enter _Enter _Enter"; + var result = RhinoApp.RunScript(command, false); + + doc.Views.Redraw(); + + return new + { + success = result, + file_path = filePath, + message = result ? "GeoTIFF imported as heightfield mesh" : "Failed to import - try manual import" + }; + } + + private static object ImportShapefile(RhinoDoc doc, JObject p) + { + var filePath = p["file_path"]?.ToString() ?? ""; + var layerName = p["layer_name"]?.ToString() ?? "Shapefile"; + + if (string.IsNullOrEmpty(filePath)) + return new { error = "File path required" }; + + if (!System.IO.File.Exists(filePath)) + return new { error = $"File not found: {filePath}" }; + + // Ensure layer exists + var layerIndex = doc.Layers.FindByFullPath(layerName, -1); + if (layerIndex < 0) + layerIndex = doc.Layers.Add(layerName, Color.Black); + + // Try to import shapefile + var command = $"_-Import \"{filePath}\" _Enter"; + var result = RhinoApp.RunScript(command, false); + + doc.Views.Redraw(); + + return new + { + success = result, + file_path = filePath, + layer = layerName + }; + } + + private static object ImportXyzFile(RhinoDoc doc, JObject p) + { + var filePath = p["file_path"]?.ToString() ?? ""; + var delimiter = p["delimiter"]?.ToString() ?? " "; + var skipLines = p["skip_lines"]?.Value() ?? 0; + + if (string.IsNullOrEmpty(filePath)) + return new { error = "File path required" }; + + if (!System.IO.File.Exists(filePath)) + return new { error = $"File not found: {filePath}" }; + + try + { + var lines = System.IO.File.ReadAllLines(filePath); + var cloud = new PointCloud(); + var count = 0; + + for (int i = skipLines; i < lines.Length; i++) + { + var parts = lines[i].Split(new[] { delimiter[0], '\t', ',' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 3) + { + if (double.TryParse(parts[0], System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var x) && + double.TryParse(parts[1], System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var y) && + double.TryParse(parts[2], System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var z)) + { + cloud.Add(new Point3d(x, y, z)); + count++; + } + } + } + + if (count == 0) + return new { error = "No valid points found in file" }; + + var id = doc.Objects.AddPointCloud(cloud); + doc.Views.Redraw(); + + return new + { + success = true, + id = id.ToString(), + point_count = count, + file_path = filePath + }; + } + catch (Exception ex) + { + return new { error = ex.Message }; + } + } + + private static object TransformCoordinates(RhinoDoc doc, JObject p) + { + var objId = p["object_id"]?.ToString(); + var fromCrs = p["from_crs"]?.ToString() ?? "WGS84"; + var toCrs = p["to_crs"]?.ToString() ?? "UTM32N"; + var offsetX = p["offset_x"]?.Value() ?? 0; + var offsetY = p["offset_y"]?.Value() ?? 0; + var offsetZ = p["offset_z"]?.Value() ?? 0; + + // Simple coordinate transformation - primarily offset-based + // For full CRS support, would need to integrate proj4 library + + if (!string.IsNullOrEmpty(objId)) + { + var guid = Guid.Parse(objId); + var obj = doc.Objects.Find(guid); + if (obj == null) + return new { error = "Object not found" }; + + var transform = Transform.Translation(offsetX, offsetY, offsetZ); + doc.Objects.Transform(guid, transform, true); + doc.Views.Redraw(); + + return new + { + success = true, + object_id = objId, + offset = new { x = offsetX, y = offsetY, z = offsetZ }, + from_crs = fromCrs, + to_crs = toCrs, + message = "Applied offset transformation. For full CRS conversion, use external tools like QGIS." + }; + } + else + { + // Transform all objects + var count = 0; + var transform = Transform.Translation(offsetX, offsetY, offsetZ); + + foreach (var obj in doc.Objects) + { + doc.Objects.Transform(obj.Id, transform, true); + count++; + } + + doc.Views.Redraw(); + + return new + { + success = true, + transformed_count = count, + offset = new { x = offsetX, y = offsetY, z = offsetZ }, + from_crs = fromCrs, + to_crs = toCrs + }; + } + } + + private static object GetBoundingBoxCoordinates(RhinoDoc doc, JObject p) + { + var objId = p["object_id"]?.ToString(); + + BoundingBox bbox; + + if (!string.IsNullOrEmpty(objId)) + { + var obj = doc.Objects.Find(Guid.Parse(objId)); + if (obj == null) + return new { error = "Object not found" }; + + bbox = obj.Geometry.GetBoundingBox(true); + } + else + { + // Get bounding box of all objects + bbox = BoundingBox.Empty; + foreach (var obj in doc.Objects) + { + bbox.Union(obj.Geometry.GetBoundingBox(true)); + } + } + + if (!bbox.IsValid) + return new { error = "No valid bounding box" }; + + return new + { + success = true, + min = new { x = bbox.Min.X, y = bbox.Min.Y, z = bbox.Min.Z }, + max = new { x = bbox.Max.X, y = bbox.Max.Y, z = bbox.Max.Z }, + center = new { x = bbox.Center.X, y = bbox.Center.Y, z = bbox.Center.Z }, + size = new + { + width = bbox.Max.X - bbox.Min.X, + depth = bbox.Max.Y - bbox.Min.Y, + height = bbox.Max.Z - bbox.Min.Z + } + }; + } + + private static object CreateGridFromBoundary(RhinoDoc doc, JObject p) + { + var boundaryId = p["boundary_id"]?.ToString(); + var cellSize = p["cell_size"]?.Value() ?? 10; + var elevationSource = p["elevation_source"]?.ToString(); + + Curve? boundary = null; + if (!string.IsNullOrEmpty(boundaryId)) + { + boundary = doc.Objects.Find(Guid.Parse(boundaryId))?.Geometry as Curve; + } + + BoundingBox bbox; + if (boundary != null) + { + bbox = boundary.GetBoundingBox(true); + } + else + { + // Use document bounds + bbox = BoundingBox.Empty; + foreach (var obj in doc.Objects) + { + bbox.Union(obj.Geometry.GetBoundingBox(true)); + } + } + + if (!bbox.IsValid) + return new { error = "Could not determine grid bounds" }; + + Mesh? terrainMesh = null; + if (!string.IsNullOrEmpty(elevationSource)) + { + terrainMesh = doc.Objects.Find(Guid.Parse(elevationSource))?.Geometry as Mesh; + } + + var points = new List(); + var gridLines = new List(); + + // Create grid points + for (double x = bbox.Min.X; x <= bbox.Max.X; x += cellSize) + { + for (double y = bbox.Min.Y; y <= bbox.Max.Y; y += cellSize) + { + double z = 0; + + if (terrainMesh != null) + { + var ray = new Ray3d(new Point3d(x, y, 10000), -Vector3d.ZAxis); + var hit = Rhino.Geometry.Intersect.Intersection.MeshRay(terrainMesh, ray); + if (hit >= 0) + z = ray.PointAt(hit).Z; + } + + var pt = new Point3d(x, y, z); + + // Check if inside boundary + if (boundary != null) + { + var containment = boundary.Contains(pt, Plane.WorldXY, doc.ModelAbsoluteTolerance); + if (containment != PointContainment.Inside && containment != PointContainment.Coincident) + continue; + } + + points.Add(pt); + } + } + + // Create grid lines (horizontal) + for (double y = bbox.Min.Y; y <= bbox.Max.Y; y += cellSize) + { + var line = new Line(new Point3d(bbox.Min.X, y, 0), new Point3d(bbox.Max.X, y, 0)); + var id = doc.Objects.AddLine(line); + gridLines.Add(id); + } + + // Create grid lines (vertical) + for (double x = bbox.Min.X; x <= bbox.Max.X; x += cellSize) + { + var line = new Line(new Point3d(x, bbox.Min.Y, 0), new Point3d(x, bbox.Max.Y, 0)); + var id = doc.Objects.AddLine(line); + gridLines.Add(id); + } + + // Add points as point cloud + var cloud = new PointCloud(points); + var cloudId = doc.Objects.AddPointCloud(cloud); + + doc.Views.Redraw(); + + return new + { + success = true, + point_count = points.Count, + pointcloud_id = cloudId.ToString(), + grid_line_count = gridLines.Count, + cell_size = cellSize, + bounds = new + { + min_x = bbox.Min.X, + min_y = bbox.Min.Y, + max_x = bbox.Max.X, + max_y = bbox.Max.Y + } + }; + } + + private static object CreateSiteSection(RhinoDoc doc, JObject p) + { + var terrainId = Guid.Parse(p["terrain_id"]?.ToString() ?? ""); + var startPt = p["start_point"] as JObject; + var endPt = p["end_point"] as JObject; + var interval = p["sample_interval"]?.Value() ?? 1.0; + + var mesh = doc.Objects.Find(terrainId)?.Geometry as Mesh; + if (mesh == null) + return new { error = "Terrain mesh not found" }; + + var start = new Point3d( + startPt?["x"]?.Value() ?? 0, + startPt?["y"]?.Value() ?? 0, + 0 + ); + var end = new Point3d( + endPt?["x"]?.Value() ?? 0, + endPt?["y"]?.Value() ?? 0, + 0 + ); + + var direction = end - start; + var length = direction.Length; + direction.Unitize(); + + var profilePoints = new List(); + var elevationData = new List(); + + for (double dist = 0; dist <= length; dist += interval) + { + var x = start.X + direction.X * dist; + var y = start.Y + direction.Y * dist; + + var ray = new Ray3d(new Point3d(x, y, 10000), -Vector3d.ZAxis); + var hit = Rhino.Geometry.Intersect.Intersection.MeshRay(mesh, ray); + + if (hit >= 0) + { + var z = ray.PointAt(hit).Z; + profilePoints.Add(new Point3d(dist, z, 0)); // 2D profile + elevationData.Add(new + { + station = Math.Round(dist, 2), + x = Math.Round(x, 3), + y = Math.Round(y, 3), + elevation = Math.Round(z, 3) + }); + } + } + + if (profilePoints.Count < 2) + return new { error = "Could not create section profile" }; + + // Create 2D profile curve + var profileCurve = new PolylineCurve(profilePoints); + var curveId = doc.Objects.AddCurve(profileCurve); + + // Also create 3D curve on terrain + var terrainPoints = new List(); + for (double dist = 0; dist <= length; dist += interval) + { + var x = start.X + direction.X * dist; + var y = start.Y + direction.Y * dist; + + var ray = new Ray3d(new Point3d(x, y, 10000), -Vector3d.ZAxis); + var hit = Rhino.Geometry.Intersect.Intersection.MeshRay(mesh, ray); + + if (hit >= 0) + terrainPoints.Add(ray.PointAt(hit)); + } + + Guid terrainCurveId = Guid.Empty; + if (terrainPoints.Count >= 2) + { + var terrainCurve = new PolylineCurve(terrainPoints); + terrainCurveId = doc.Objects.AddCurve(terrainCurve); + } + + doc.Views.Redraw(); + + // Calculate min/max elevation from profile points + var minElevation = profilePoints.Count > 0 ? profilePoints.Min(p => p.Y) : 0; + var maxElevation = profilePoints.Count > 0 ? profilePoints.Max(p => p.Y) : 0; + + return new + { + success = true, + profile_curve_id = curveId.ToString(), + terrain_curve_id = terrainCurveId.ToString(), + length = Math.Round(length, 2), + point_count = elevationData.Count, + min_elevation = Math.Round(minElevation, 3), + max_elevation = Math.Round(maxElevation, 3), + elevation_data = elevationData + }; + } + + private static object CalculateAreaVolume(RhinoDoc doc, JObject p) + { + var boundaryId = p["boundary_id"]?.ToString(); + var terrainId = p["terrain_id"]?.ToString(); + var referenceElevation = p["reference_elevation"]?.Value() ?? 0; + + Curve? boundary = null; + if (!string.IsNullOrEmpty(boundaryId)) + { + boundary = doc.Objects.Find(Guid.Parse(boundaryId))?.Geometry as Curve; + } + + if (boundary == null) + return new { error = "Boundary curve not found" }; + + if (!boundary.IsClosed) + return new { error = "Boundary curve must be closed" }; + + // Calculate area + var areaProps = AreaMassProperties.Compute(boundary); + var area = areaProps?.Area ?? 0; + + double cutVolume = 0; + double fillVolume = 0; + + if (!string.IsNullOrEmpty(terrainId)) + { + var mesh = doc.Objects.Find(Guid.Parse(terrainId))?.Geometry as Mesh; + if (mesh != null) + { + var bbox = boundary.GetBoundingBox(true); + var sampleSize = 1.0; // 1 unit grid for volume calculation + + for (double x = bbox.Min.X; x <= bbox.Max.X; x += sampleSize) + { + for (double y = bbox.Min.Y; y <= bbox.Max.Y; y += sampleSize) + { + var pt = new Point3d(x, y, 0); + var containment = boundary.Contains(pt, Plane.WorldXY, 0.01); + + if (containment == PointContainment.Inside || containment == PointContainment.Coincident) + { + var ray = new Ray3d(new Point3d(x, y, 10000), -Vector3d.ZAxis); + var hit = Rhino.Geometry.Intersect.Intersection.MeshRay(mesh, ray); + + if (hit >= 0) + { + var elevation = ray.PointAt(hit).Z; + var diff = elevation - referenceElevation; + + if (diff > 0) + cutVolume += diff * sampleSize * sampleSize; + else + fillVolume += Math.Abs(diff) * sampleSize * sampleSize; + } + } + } + } + } + } + + return new + { + success = true, + area = Math.Round(area, 2), + cut_volume = Math.Round(cutVolume, 2), + fill_volume = Math.Round(fillVolume, 2), + net_volume = Math.Round(cutVolume - fillVolume, 2), + reference_elevation = referenceElevation + }; + } + + #endregion + + #region Road Tools + + private static object CreateRoadSurface(RhinoDoc doc, JObject p) + { + var terrainId = Guid.Parse(p["terrain_id"]?.ToString() ?? ""); + var centerlineId = Guid.Parse(p["centerline_id"]?.ToString() ?? ""); + var width = p["width"]?.Value() ?? 6.0; + var crossSlope = p["cross_slope"]?.Value() ?? 2.0; + + var mesh = doc.Objects.Find(terrainId)?.Geometry as Mesh; + var centerline = doc.Objects.Find(centerlineId)?.Geometry as Curve; + + if (mesh == null) + return new { error = "Terrain mesh not found" }; + if (centerline == null) + return new { error = "Centerline curve not found" }; + + // Create road surface by offsetting centerline and draping on terrain + var leftOffset = centerline.Offset(Plane.WorldXY, width / 2, doc.ModelAbsoluteTolerance, CurveOffsetCornerStyle.Sharp); + var rightOffset = centerline.Offset(Plane.WorldXY, -width / 2, doc.ModelAbsoluteTolerance, CurveOffsetCornerStyle.Sharp); + + var curveIds = new List(); + + if (leftOffset != null && leftOffset.Length > 0) + { + var id = doc.Objects.AddCurve(leftOffset[0]); + curveIds.Add(id.ToString()); + } + if (rightOffset != null && rightOffset.Length > 0) + { + var id = doc.Objects.AddCurve(rightOffset[0]); + curveIds.Add(id.ToString()); + } + + doc.Views.Redraw(); + + return new + { + success = true, + message = "Road edges created. Use loft or sweep for full surface.", + edge_ids = curveIds, + width, + cross_slope = crossSlope + }; + } + + private static object AnalyzeRoadGrades(RhinoDoc doc, JObject p) + { + var terrainId = Guid.Parse(p["terrain_id"]?.ToString() ?? ""); + var curveId = Guid.Parse(p["curve_id"]?.ToString() ?? ""); + var interval = p["station_interval"]?.Value() ?? 10; + + var mesh = doc.Objects.Find(terrainId)?.Geometry as Mesh; + var curve = doc.Objects.Find(curveId)?.Geometry as Curve; + + if (mesh == null) + return new { error = "Terrain mesh not found" }; + if (curve == null) + return new { error = "Road curve not found" }; + + var length = curve.GetLength(); + var stations = new List(); + var grades = new List(); + + double previousZ = 0; + double previousStation = 0; + + for (double station = 0; station <= length; station += interval) + { + var t = station / length; + var pt = curve.PointAtNormalizedLength(t); + + // Get elevation at this point + var ray = new Ray3d(new Point3d(pt.X, pt.Y, 10000), -Vector3d.ZAxis); + var hit = Rhino.Geometry.Intersect.Intersection.MeshRay(mesh, ray); + + if (hit >= 0) + { + var elevation = ray.PointAt(hit).Z; + var grade = 0.0; + + if (station > 0) + { + var rise = elevation - previousZ; + var run = station - previousStation; + grade = (rise / run) * 100; // Grade in percent + grades.Add(Math.Abs(grade)); + } + + stations.Add(new + { + station = Math.Round(station, 2), + x = Math.Round(pt.X, 3), + y = Math.Round(pt.Y, 3), + elevation = Math.Round(elevation, 3), + grade = Math.Round(grade, 2) + }); + + previousZ = elevation; + previousStation = station; + } + } + + return new + { + success = true, + total_length = length, + station_count = stations.Count, + min_grade = grades.Count > 0 ? grades.Min() : 0, + max_grade = grades.Count > 0 ? grades.Max() : 0, + average_grade = grades.Count > 0 ? grades.Average() : 0, + stations + }; + } + + #endregion + } +}