Editor/Tools/ProceduralGenerationTools.cs

Editor MCP assistant tools for procedural object creation. It exposes MCP tools to spawn roads, water surfaces, architecture plans, and add rooms; it tries to instantiate known component types via EditorRuntime and falls back to creating native GameObject geometry and colliders, and it sets size properties via reflection.

ReflectionFile Access
#nullable enable
using System;
using System.Linq;
using System.Reflection;
using Sandbox;

namespace Editor.Mcp
{
	public static partial class SboxMcpAssistant
	{
		[McpTool("pocketknife_spawn_procedural_road")]
		public static string SpawnProceduralRoad([Description("Name of the road object")] string roadName = "New Road") => SpawnComponent("road", roadName, "RoadComponent", "RoadTool.RoadComponent", null, null);

		[McpTool("pocketknife_spawn_water_surface")]
		public static string SpawnWaterSurface([Description("Name of the water object")] string waterName = "Water Surface") => SpawnComponent("water", waterName, "WaterComponent", "WaterTool.WaterComponent", null, null);

		[McpTool("pocketknife_spawn_architecture_plan")]
		public static string SpawnArchitecturePlan([Description("Name of the building object")] string buildingName = "New Building", [Description("Width in Hammer units")] float width = 256f, [Description("Length in Hammer units")] float length = 256f)
		{
			if (width <= 0 || length <= 0) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "architecture.invalid_size", "Width and length must be positive."));
			return SpawnComponent("architecture-plan", buildingName, "ArchPlan", "sunless.architecture.ArchPlan", width, length);
		}

		[McpTool("pocketknife_add_architecture_room")]
		public static string AddArchitectureRoom([Description("Existing parent plan name, ID, or path")] string parentPlanName, [Description("Room name")] string roomName, [Description("Local X")] float x, [Description("Local Y")] float y, [Description("Local Z")] float z, [Description("Width")] float width, [Description("Length")] float length)
		{
			try
			{
				if (width <= 0 || length <= 0) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "architecture.invalid_size", "Width and length must be positive."));
				var activeScene = EditorRuntime.ResolveActiveScene();
				var parent = FindObjectInScene(activeScene?.Children, parentPlanName);
				if (parent is null && activeScene is not null)
				{
					parent = activeScene.GetAllObjects(false).FirstOrDefault(o => o.Name.Equals(parentPlanName, StringComparison.OrdinalIgnoreCase) || o.Id.ToString().Equals(parentPlanName, StringComparison.OrdinalIgnoreCase));
				}
				if (parent is null) return McpJson.Write(ToolOutcome<object>.Failure(new { ParentRequested = parentPlanName, SceneResolved = activeScene != null, SceneName = activeScene?.Name, TotalObjects = activeScene?.GetAllObjects(false).Count() }, ToolStatus.Failed, "architecture.parent_not_found", $"Plan '{parentPlanName}' was not found."));

				var roomType = EditorRuntime.GetComponentType("ArchRoom") ?? EditorRuntime.GetComponentType("sunless.architecture.ArchRoom");
				var go = new GameObject(parent, true, roomName);
				go.LocalPosition = new Vector3(x, y, z);
				go.Tags.Add("room");

				if (roomType is not null)
				{
					var component = go.Components.Create((dynamic)roomType);
					var applied = ApplySize(component, width, length);
					return applied
						? McpJson.Write(ToolOutcome<object>.Ready(new { Id = go.Id.ToString(), ParentId = parent.Id.ToString(), Position = new { x, y, z }, Width = width, Length = length, Mode = "ArchRoomComponent" }))
						: McpJson.Write(ToolOutcome<object>.Degraded(new { Id = go.Id.ToString(), ParentId = parent.Id.ToString(), Position = new { x, y, z }, Width = width, Length = length }, "architecture.size_property_unavailable", "The ArchRoom was created, but its component exposes no recognized width/length properties."));
				}

				// Native Engine Fallback: procedural room with Floor, 4 Walls, and Ceiling colliders
				var floorGo = new GameObject(go, true, "Floor");
				floorGo.LocalPosition = Vector3.Zero;
				var floorCol = floorGo.Components.Create<BoxCollider>();
				floorCol.Scale = new Vector3(width, length, 4f);

				// 4 Walls (height: 128 units)
				float wallHeight = 128f;
				var wallN = new GameObject(go, true, "Wall_North");
				wallN.LocalPosition = new Vector3(0, length * 0.5f, wallHeight * 0.5f);
				var colN = wallN.Components.Create<BoxCollider>();
				colN.Scale = new Vector3(width, 4f, wallHeight);

				var wallS = new GameObject(go, true, "Wall_South");
				wallS.LocalPosition = new Vector3(0, -length * 0.5f, wallHeight * 0.5f);
				var colS = wallS.Components.Create<BoxCollider>();
				colS.Scale = new Vector3(width, 4f, wallHeight);

				var wallE = new GameObject(go, true, "Wall_East");
				wallE.LocalPosition = new Vector3(width * 0.5f, 0, wallHeight * 0.5f);
				var colE = wallE.Components.Create<BoxCollider>();
				colE.Scale = new Vector3(4f, length, wallHeight);

				var wallW = new GameObject(go, true, "Wall_West");
				wallW.LocalPosition = new Vector3(-width * 0.5f, 0, wallHeight * 0.5f);
				var colW = wallW.Components.Create<BoxCollider>();
				colW.Scale = new Vector3(4f, length, wallHeight);

				var ceilingGo = new GameObject(go, true, "Ceiling");
				ceilingGo.LocalPosition = new Vector3(0, 0, wallHeight);
				var ceilCol = ceilingGo.Components.Create<BoxCollider>();
				ceilCol.Scale = new Vector3(width, length, 4f);

				return McpJson.Write(ToolOutcome<object>.Ready(new
				{
					Id = go.Id.ToString(),
					ParentId = parent.Id.ToString(),
					Position = new { x, y, z },
					Width = width,
					Length = length,
					Height = wallHeight,
					Mode = "NativeProceduralGeometry",
					GeneratedChildren = new[] { "Floor", "Wall_North", "Wall_South", "Wall_East", "Wall_West", "Ceiling" }
				}));
			}
			catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(new { Error = ex.ToString() }, ToolStatus.Failed, "architecture.room_failed", ex.Message, ex)); }
		}

		private static string SpawnComponent(string kind, string name, string shortType, string fullType, float? width, float? length)
		{
			try
			{
				if (!ToolGuard.IsValidName(name)) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "component.invalid_name", "A non-empty valid object name is required."));
				var activeScene = EditorRuntime.ResolveActiveScene();
				var go = activeScene != null ? activeScene.CreateObject(true) : new GameObject(true, name);
				go.Name = name;
				var type = EditorRuntime.GetComponentType(shortType) ?? EditorRuntime.GetComponentType(fullType);

				if (type is not null)
				{
					var component = go.Components.Create((dynamic)type);
					var applied = !width.HasValue || ApplySize(component, width.Value, length!.Value);
					return applied
						? McpJson.Write(ToolOutcome<object>.Ready(new { Id = go.Id.ToString(), Name = name, Component = shortType, Width = width, Length = length }))
						: McpJson.Write(ToolOutcome<object>.Degraded(new { Id = go.Id.ToString(), Name = name, Component = shortType }, kind + ".size_property_unavailable", "The component was created but no recognized size properties were found."));
				}

				// Native Engine Fallback: Create functional native GameObject geometry with tags and colliders
				go.Tags.Add(kind);
				if (kind == "water")
				{
					var col = go.Components.Create<BoxCollider>();
					col.Scale = new Vector3(width ?? 1024f, length ?? 1024f, 16f);
					col.IsTrigger = true;
				}
				else if (kind == "road")
				{
					var col = go.Components.Create<BoxCollider>();
					col.Scale = new Vector3(width ?? 256f, length ?? 1024f, 8f);
				}
				else if (kind == "architecture-plan")
				{
					go.Tags.Add("plan");
				}

				return McpJson.Write(ToolOutcome<object>.Ready(new
				{
					Id = go.Id.ToString(),
					Name = name,
					Kind = kind,
					Width = width,
					Length = length,
					Mode = "NativeGameObjectFallback",
					Note = $"Created native {kind} geometry. Install package '{fullType}' for dedicated community component."
				}));
			}
			catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, kind + ".create_failed", $"{kind} creation failed.", ex)); }
		}

		private static bool ApplySize(object component, float width, float length)
		{
			var type = component.GetType(); var changed = false;
			foreach (var name in new[] { "Width", "RoomWidth", "SizeX" }) { var p = type.GetProperty(name, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); if (p?.CanWrite == true) { p.SetValue(component, Convert.ChangeType(width, p.PropertyType)); changed = true; break; } }
			foreach (var name in new[] { "Length", "Depth", "RoomLength", "SizeY" }) { var p = type.GetProperty(name, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); if (p?.CanWrite == true) { p.SetValue(component, Convert.ChangeType(length, p.PropertyType)); changed = true; break; } }
			return changed;
		}

		private static GameObject? FindObjectInScene(System.Collections.Generic.IEnumerable<GameObject>? roots, string selector)
		{
			if (roots is null) return null; foreach (var item in roots) { if (item.Id.ToString().Equals(selector, StringComparison.OrdinalIgnoreCase) || item.Name.Equals(selector, StringComparison.OrdinalIgnoreCase)) return item; var nested = FindObjectInScene(item.Children, selector); if (nested is not null) return nested; } return null;
		}
	}
}