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.
#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;
}
}
}