From 083f8db0a78efca93d9837c2039634179e00bda7 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:19:59 -0500 Subject: [PATCH 1/8] ProBuilder support initial update --- .../references/tools-reference.md | 88 + .../Tools/GameObjects/GameObjectModify.cs | 2 +- MCPForUnity/Editor/Tools/ManageScene.cs | 16 +- MCPForUnity/Editor/Tools/ProBuilder.meta | 8 + .../Tools/ProBuilder/ManageProBuilder.cs | 1784 +++++++++++++++++ .../Tools/ProBuilder/ManageProBuilder.cs.meta | 11 + .../Tools/ProBuilder/ProBuilderMeshUtils.cs | 254 +++ .../ProBuilder/ProBuilderMeshUtils.cs.meta | 11 + .../Tools/ProBuilder/ProBuilderSmoothing.cs | 97 + .../ProBuilder/ProBuilderSmoothing.cs.meta | 11 + README.md | 2 +- Server/src/cli/commands/probuilder.py | 341 ++++ Server/src/cli/main.py | 1 + Server/src/services/registry/tool_registry.py | 1 + Server/src/services/tools/manage_material.py | 2 +- .../src/services/tools/manage_probuilder.py | 176 ++ Server/tests/test_manage_probuilder.py | 465 +++++ Server/uv.lock | 12 +- .../EditMode/Tools/ManageProBuilderTests.cs | 860 ++++++++ .../Tools/ManageProBuilderTests.cs.meta | 11 + docs/guides/CLI_USAGE.md | 23 + 21 files changed, 4168 insertions(+), 8 deletions(-) create mode 100644 MCPForUnity/Editor/Tools/ProBuilder.meta create mode 100644 MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs create mode 100644 MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs create mode 100644 MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ProBuilder/ProBuilderSmoothing.cs create mode 100644 MCPForUnity/Editor/Tools/ProBuilder/ProBuilderSmoothing.cs.meta create mode 100644 Server/src/cli/commands/probuilder.py create mode 100644 Server/src/services/tools/manage_probuilder.py create mode 100644 Server/tests/test_manage_probuilder.py create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProBuilderTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProBuilderTests.cs.meta diff --git a/.claude/skills/unity-mcp-skill/references/tools-reference.md b/.claude/skills/unity-mcp-skill/references/tools-reference.md index 60b967cd4..cdd644bbc 100644 --- a/.claude/skills/unity-mcp-skill/references/tools-reference.md +++ b/.claude/skills/unity-mcp-skill/references/tools-reference.md @@ -15,6 +15,7 @@ Complete reference for all MCP tools. Each tool includes parameters, types, and - [UI Tools](#ui-tools) - [Editor Control Tools](#editor-control-tools) - [Testing Tools](#testing-tools) +- [ProBuilder Tools](#probuilder-tools) --- @@ -789,3 +790,90 @@ execute_custom_tool( ``` Discover available custom tools via `mcpforunity://custom-tools` resource. + +--- + +## ProBuilder Tools + +### manage_probuilder + +Unified tool for ProBuilder mesh operations. Requires `com.unity.probuilder` package. + +**Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `action` | string | Yes | Action to perform (see categories below) | +| `target` | string | Sometimes | Target GameObject name/path/id | +| `search_method` | string | No | How to find target: `by_id`, `by_name`, `by_path`, `by_tag`, `by_layer` | +| `properties` | dict | No | Action-specific parameters | + +**Actions by category:** + +**Shape Creation:** +- `create_shape` — Create ProBuilder primitive (shape_type, size, position, rotation, name) +- `create_poly_shape` — Create from 2D polygon footprint (points, extrudeHeight, flipNormals) + +**Mesh Editing:** +- `extrude_faces` — Extrude faces (faceIndices, distance, method) +- `extrude_edges` — Extrude edges (edgeIndices, distance, asGroup) +- `bevel_edges` — Bevel edges (edgeIndices, amount 0-1) +- `subdivide` — Subdivide faces (faceIndices optional) +- `delete_faces` — Delete faces (faceIndices) +- `bridge_edges` — Bridge two open edges (edgeA, edgeB as {a,b} pairs) +- `connect_elements` — Connect edges/faces (edgeIndices or faceIndices) +- `detach_faces` — Detach faces to new object (faceIndices, deleteSource) +- `flip_normals` — Flip face normals (faceIndices) +- `merge_faces` — Merge faces into one (faceIndices) +- `combine_meshes` — Combine ProBuilder objects (targets list) + +**Vertex Operations:** +- `merge_vertices` — Merge/weld vertices (vertexIndices) +- `split_vertices` — Split shared vertices (vertexIndices) +- `move_vertices` — Translate vertices (vertexIndices, offset [x,y,z]) + +**UV & Materials:** +- `set_face_material` — Assign material to faces (faceIndices, materialPath) +- `set_face_color` — Set vertex color on faces (faceIndices, color [r,g,b,a]) +- `set_face_uvs` — Set UV params (faceIndices, scale, offset, rotation, flipU, flipV) + +**Query:** +- `get_mesh_info` — Get mesh details with `include` parameter: + - `"summary"` (default): counts, bounds, materials + - `"faces"`: + face normals, centers, and direction labels + - `"edges"`: + edge vertex pairs (capped at 200) + - `"all"`: everything +- `convert_to_probuilder` — Convert standard mesh to ProBuilder + +**Smoothing:** +- `set_smoothing` — Set smoothing group on faces (faceIndices, smoothingGroup: 0=hard, 1+=smooth) +- `auto_smooth` — Auto-assign smoothing groups by angle (angleThreshold: default 30) + +**Mesh Utilities:** +- `center_pivot` — Move pivot to mesh bounds center +- `freeze_transform` — Bake transform into vertices, reset transform +- `validate_mesh` — Check mesh health (read-only diagnostics) +- `repair_mesh` — Auto-fix degenerate triangles + +**Examples:** + +```python +# Create a cube +manage_probuilder(action="create_shape", properties={"shape_type": "Cube", "name": "MyCube"}) + +# Get face info with directions +manage_probuilder(action="get_mesh_info", target="MyCube", properties={"include": "faces"}) + +# Extrude the top face (find it via direction="top" in get_mesh_info results) +manage_probuilder(action="extrude_faces", target="MyCube", + properties={"faceIndices": [2], "distance": 1.5}) + +# Auto-smooth +manage_probuilder(action="auto_smooth", target="MyCube", properties={"angleThreshold": 30}) + +# Cleanup workflow +manage_probuilder(action="center_pivot", target="MyCube") +manage_probuilder(action="validate_mesh", target="MyCube") +``` + +See also: [ProBuilder Workflow Guide](probuilder-guide.md) for detailed patterns. diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs index 44511e91e..7be8f91dd 100644 --- a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs @@ -34,7 +34,7 @@ internal static object Handle(JObject @params, JToken targetToken, string search bool modified = false; - string name = @params["name"]?.ToString(); + string name = @params["name"]?.ToString() ?? @params["new_name"]?.ToString() ?? @params["newName"]?.ToString(); if (!string.IsNullOrEmpty(name) && targetGo.name != name) { // Check if we're renaming the root object of an open prefab stage diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index ec59533ae..31a6e2a1e 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -604,8 +604,8 @@ private static object CaptureSurroundBatch(SceneCommand cmd) if (r != null && r.gameObject.activeInHierarchy) targetBounds.Encapsulate(r.bounds); } center = targetBounds.center; - radius = targetBounds.extents.magnitude * 1.8f; - radius = Mathf.Max(radius, 3f); + radius = targetBounds.extents.magnitude * 2.5f; + radius = Mathf.Max(radius, 5f); } } else @@ -632,8 +632,8 @@ private static object CaptureSurroundBatch(SceneCommand cmd) return new ErrorResponse("No renderers found in the scene. Cannot determine scene bounds for batch capture."); center = bounds.center; - radius = bounds.extents.magnitude * 1.8f; - radius = Mathf.Max(radius, 3f); + radius = bounds.extents.magnitude * 2.5f; + radius = Mathf.Max(radius, 5f); } // Define 6 viewpoints: front, back, left, right, top, bird's-eye (45° elevated front-right) @@ -665,6 +665,10 @@ private static object CaptureSurroundBatch(SceneCommand cmd) tempCam.transform.position = pos; tempCam.transform.LookAt(center); + // Force material refresh before capture + EditorApplication.QueuePlayerLoopUpdate(); + SceneView.RepaintAll(); + Texture2D tile = ScreenshotUtility.RenderCameraToTexture(tempCam, maxRes); tiles.Add(tile); tileLabels.Add(label); @@ -804,6 +808,10 @@ private static object CaptureOrbitBatch(SceneCommand cmd) : "level"; string angleLabel = $"{dirLabel}_{elevLabel}"; + // Force material refresh before capture + EditorApplication.QueuePlayerLoopUpdate(); + SceneView.RepaintAll(); + Texture2D tile = ScreenshotUtility.RenderCameraToTexture(tempCam, maxRes); tiles.Add(tile); tileLabels.Add(angleLabel); diff --git a/MCPForUnity/Editor/Tools/ProBuilder.meta b/MCPForUnity/Editor/Tools/ProBuilder.meta new file mode 100644 index 000000000..c19b7ece6 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ProBuilder.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bfd453a23cda46348e276fe627e5016f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs b/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs new file mode 100644 index 000000000..2da47821f --- /dev/null +++ b/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs @@ -0,0 +1,1784 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.ProBuilder +{ + /// + /// Tool for managing Unity ProBuilder meshes for in-editor 3D modeling. + /// Requires com.unity.probuilder package to be installed. + /// + /// SHAPE CREATION: + /// - create_shape: Create ProBuilder primitive (shapeType, size/radius/height, position, rotation, name) + /// Shape types: Cube, Cylinder, Sphere, Plane, Cone, Torus, Pipe, Arch, Stair, CurvedStair, Door, Prism + /// - create_poly_shape: Create from 2D polygon footprint (points, extrudeHeight, flipNormals) + /// + /// MESH EDITING: + /// - extrude_faces: Extrude faces (faceIndices, distance, method: FaceNormal/VertexNormal/IndividualFaces) + /// - extrude_edges: Extrude edges (edgeIndices, distance, asGroup) + /// - bevel_edges: Bevel edges (edgeIndices, amount 0-1) + /// - subdivide: Subdivide faces (faceIndices optional) + /// - delete_faces: Delete faces (faceIndices) + /// - bridge_edges: Bridge two open edges (edgeA, edgeB as {a,b} pairs) + /// - connect_elements: Connect edges/faces (edgeIndices or faceIndices) + /// - detach_faces: Detach faces to new object (faceIndices, deleteSource) + /// - flip_normals: Flip face normals (faceIndices) + /// - merge_faces: Merge faces into one (faceIndices) + /// - combine_meshes: Combine ProBuilder objects (targets list) + /// - merge_objects: Merge objects (auto-converts non-ProBuilder), convenience wrapper (targets, name) + /// + /// VERTEX OPERATIONS: + /// - merge_vertices: Merge/weld vertices (vertexIndices) + /// - split_vertices: Split shared vertices (vertexIndices) + /// - move_vertices: Translate vertices (vertexIndices, offset [x,y,z]) + /// + /// UV & MATERIALS: + /// - set_face_material: Assign material to faces (faceIndices, materialPath) + /// - set_face_color: Set vertex color (faceIndices, color [r,g,b,a]) + /// - set_face_uvs: Set UV params (faceIndices, scale, offset, rotation, flipU, flipV) + /// + /// QUERY: + /// - get_mesh_info: Get mesh details (face count, vertex count, bounds, materials) + /// - convert_to_probuilder: Convert standard mesh to ProBuilder + /// + [McpForUnityTool("manage_probuilder", AutoRegister = false, Group = "probuilder")] + public static class ManageProBuilder + { + // ProBuilder types resolved via reflection (optional package) + internal static Type _proBuilderMeshType; + private static Type _shapeGeneratorType; + internal static Type _shapeTypeEnum; + private static Type _extrudeMethodEnum; + private static Type _extrudeElementsType; + private static Type _bevelType; + private static Type _deleteElementsType; + private static Type _appendElementsType; + private static Type _connectElementsType; + private static Type _mergeElementsType; + private static Type _combineMeshesType; + private static Type _surfaceTopologyType; + internal static Type _faceType; + internal static Type _edgeType; + private static Type _editorMeshUtilityType; + private static Type _meshImporterType; + internal static Type _smoothingType; + internal static Type _meshValidationType; + private static bool _typesResolved; + private static bool _proBuilderAvailable; + + private static bool EnsureProBuilder() + { + if (_typesResolved) return _proBuilderAvailable; + _typesResolved = true; + + _proBuilderMeshType = Type.GetType("UnityEngine.ProBuilder.ProBuilderMesh, Unity.ProBuilder"); + if (_proBuilderMeshType == null) + { + _proBuilderAvailable = false; + return false; + } + + _shapeGeneratorType = Type.GetType("UnityEngine.ProBuilder.ShapeGenerator, Unity.ProBuilder"); + _shapeTypeEnum = Type.GetType("UnityEngine.ProBuilder.ShapeType, Unity.ProBuilder"); + _faceType = Type.GetType("UnityEngine.ProBuilder.Face, Unity.ProBuilder"); + _edgeType = Type.GetType("UnityEngine.ProBuilder.Edge, Unity.ProBuilder"); + + // MeshOperations + _extrudeElementsType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.ExtrudeElements, Unity.ProBuilder"); + _extrudeMethodEnum = Type.GetType("UnityEngine.ProBuilder.ExtrudeMethod, Unity.ProBuilder"); + _bevelType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.Bevel, Unity.ProBuilder"); + _deleteElementsType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.DeleteElements, Unity.ProBuilder"); + _appendElementsType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.AppendElements, Unity.ProBuilder"); + _connectElementsType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.ConnectElements, Unity.ProBuilder"); + _mergeElementsType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.MergeElements, Unity.ProBuilder"); + _combineMeshesType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.CombineMeshes, Unity.ProBuilder"); + _surfaceTopologyType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.SurfaceTopology, Unity.ProBuilder"); + + // Editor utilities + _editorMeshUtilityType = Type.GetType("UnityEditor.ProBuilder.EditorMeshUtility, Unity.ProBuilder.Editor"); + _meshImporterType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.MeshImporter, Unity.ProBuilder"); + _smoothingType = Type.GetType("UnityEngine.ProBuilder.Smoothing, Unity.ProBuilder"); + _meshValidationType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.MeshValidation, Unity.ProBuilder"); + + _proBuilderAvailable = true; + return true; + } + + public static object HandleCommand(JObject @params) + { + if (!EnsureProBuilder()) + { + return new ErrorResponse( + "ProBuilder package is not installed. Install com.unity.probuilder via Package Manager." + ); + } + + var p = new ToolParams(@params); + string action = p.Get("action"); + if (string.IsNullOrEmpty(action)) + return new ErrorResponse("Action is required"); + + try + { + switch (action.ToLowerInvariant()) + { + case "ping": + return new SuccessResponse("ProBuilder tool is available", new { tool = "manage_probuilder" }); + + // Shape creation + case "create_shape": return CreateShape(@params); + case "create_poly_shape": return CreatePolyShape(@params); + + // Mesh editing + case "extrude_faces": return ExtrudeFaces(@params); + case "extrude_edges": return ExtrudeEdges(@params); + case "bevel_edges": return BevelEdges(@params); + case "subdivide": return Subdivide(@params); + case "delete_faces": return DeleteFaces(@params); + case "bridge_edges": return BridgeEdges(@params); + case "connect_elements": return ConnectElements(@params); + case "detach_faces": return DetachFaces(@params); + case "flip_normals": return FlipNormals(@params); + case "merge_faces": return MergeFaces(@params); + case "combine_meshes": return CombineMeshes(@params); + case "merge_objects": return MergeObjects(@params); + + // Vertex operations + case "merge_vertices": return MergeVertices(@params); + case "split_vertices": return SplitVertices(@params); + case "move_vertices": return MoveVertices(@params); + + // UV & materials + case "set_face_material": return SetFaceMaterial(@params); + case "set_face_color": return SetFaceColor(@params); + case "set_face_uvs": return SetFaceUVs(@params); + + // Query + case "get_mesh_info": return GetMeshInfo(@params); + case "convert_to_probuilder": return ConvertToProBuilder(@params); + + // Smoothing + case "set_smoothing": return ProBuilderSmoothing.SetSmoothing(@params); + case "auto_smooth": return ProBuilderSmoothing.AutoSmooth(@params); + + // Mesh utilities + case "center_pivot": return ProBuilderMeshUtils.CenterPivot(@params); + case "freeze_transform": return ProBuilderMeshUtils.FreezeTransform(@params); + case "validate_mesh": return ProBuilderMeshUtils.ValidateMesh(@params); + case "repair_mesh": return ProBuilderMeshUtils.RepairMesh(@params); + + default: + return new ErrorResponse($"Unknown action: {action}"); + } + } + catch (Exception ex) + { + return new ErrorResponse(ex.Message, new { stackTrace = ex.StackTrace }); + } + } + + // ===================================================================== + // Helpers + // ===================================================================== + + internal static GameObject FindTarget(JObject @params) + { + return ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString()); + } + + private static Component GetProBuilderMesh(GameObject go) + { + return go.GetComponent(_proBuilderMeshType); + } + + internal static Component RequireProBuilderMesh(JObject @params) + { + var go = FindTarget(@params); + if (go == null) + throw new Exception("Target GameObject not found."); + var pbMesh = GetProBuilderMesh(go); + if (pbMesh == null) + throw new Exception($"GameObject '{go.name}' does not have a ProBuilderMesh component."); + return pbMesh; + } + + internal static void RefreshMesh(Component pbMesh) + { + _proBuilderMeshType.GetMethod("ToMesh", Type.EmptyTypes)?.Invoke(pbMesh, null); + _proBuilderMeshType.GetMethod("Refresh", Type.EmptyTypes)?.Invoke(pbMesh, null); + + if (_editorMeshUtilityType != null) + { + var optimizeMethod = _editorMeshUtilityType.GetMethod("Optimize", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType }, + null); + optimizeMethod?.Invoke(null, new object[] { pbMesh }); + } + } + + internal static object GetFacesArray(Component pbMesh) + { + var facesProperty = _proBuilderMeshType.GetProperty("faces"); + return facesProperty?.GetValue(pbMesh); + } + + internal static Array GetFacesByIndices(Component pbMesh, JToken faceIndicesToken) + { + var allFaces = GetFacesArray(pbMesh); + if (allFaces == null) + throw new Exception("Could not read faces from ProBuilderMesh."); + + var facesList = (System.Collections.IList)allFaces; + + if (faceIndicesToken == null) + { + // Return all faces when no indices specified + var allResult = Array.CreateInstance(_faceType, facesList.Count); + for (int i = 0; i < facesList.Count; i++) + allResult.SetValue(facesList[i], i); + return allResult; + } + + var indices = faceIndicesToken.ToObject(); + var result = Array.CreateInstance(_faceType, indices.Length); + for (int i = 0; i < indices.Length; i++) + { + if (indices[i] < 0 || indices[i] >= facesList.Count) + throw new Exception($"Face index {indices[i]} out of range (0-{facesList.Count - 1})."); + result.SetValue(facesList[indices[i]], i); + } + return result; + } + + internal static JObject ExtractProperties(JObject @params) + { + var propsToken = @params["properties"]; + if (propsToken is JObject jObj) return jObj; + if (propsToken is JValue jVal && jVal.Type == JTokenType.String) + { + var parsed = JObject.Parse(jVal.ToString()); + if (parsed != null) return parsed; + } + + // Fallback: properties might be at the top level + return @params; + } + + private static Vector3 ParseVector3(JToken token) + { + return VectorParsing.ParseVector3OrDefault(token); + } + + internal static int GetFaceCount(Component pbMesh) + { + var faceCount = _proBuilderMeshType.GetProperty("faceCount"); + return faceCount != null ? (int)faceCount.GetValue(pbMesh) : -1; + } + + internal static int GetVertexCount(Component pbMesh) + { + var vertexCount = _proBuilderMeshType.GetProperty("vertexCount"); + return vertexCount != null ? (int)vertexCount.GetValue(pbMesh) : -1; + } + + // ===================================================================== + // Shape Creation + // ===================================================================== + + private static object CreateShape(JObject @params) + { + var props = ExtractProperties(@params); + string shapeTypeStr = props["shapeType"]?.ToString() ?? props["shape_type"]?.ToString(); + if (string.IsNullOrEmpty(shapeTypeStr)) + return new ErrorResponse("shapeType parameter is required."); + + if (_shapeGeneratorType == null || _shapeTypeEnum == null) + return new ErrorResponse("ShapeGenerator or ShapeType not found in ProBuilder assembly."); + + // Parse shape type enum + object shapeTypeValue; + try + { + shapeTypeValue = Enum.Parse(_shapeTypeEnum, shapeTypeStr, true); + } + catch + { + var validTypes = string.Join(", ", Enum.GetNames(_shapeTypeEnum)); + return new ErrorResponse($"Unknown shape type '{shapeTypeStr}'. Valid types: {validTypes}"); + } + + // Use ShapeGenerator.CreateShape(ShapeType) or CreateShape(ShapeType, PivotLocation) + var createMethod = _shapeGeneratorType.GetMethod("CreateShape", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _shapeTypeEnum }, + null); + + // Fallback: look for overload with PivotLocation (ProBuilder 4.x+) + object[] invokeArgs; + if (createMethod != null) + { + invokeArgs = new[] { shapeTypeValue }; + } + else + { + var pivotLocationType = Type.GetType("UnityEngine.ProBuilder.PivotLocation, Unity.ProBuilder"); + if (pivotLocationType != null) + { + createMethod = _shapeGeneratorType.GetMethod("CreateShape", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _shapeTypeEnum, pivotLocationType }, + null); + // PivotLocation.Center = 0 + invokeArgs = new[] { shapeTypeValue, Enum.ToObject(pivotLocationType, 0) }; + } + else + { + invokeArgs = null; + } + } + + if (createMethod == null) + return new ErrorResponse("ShapeGenerator.CreateShape method not found. Check your ProBuilder version."); + + Undo.IncrementCurrentGroup(); + var pbMesh = createMethod.Invoke(null, invokeArgs) as Component; + if (pbMesh == null) + return new ErrorResponse("Failed to create ProBuilder shape."); + + var go = pbMesh.gameObject; + Undo.RegisterCreatedObjectUndo(go, $"Create ProBuilder {shapeTypeStr}"); + + // Apply name + string name = props["name"]?.ToString(); + if (!string.IsNullOrEmpty(name)) + go.name = name; + + // Apply position + var posToken = props["position"]; + if (posToken != null) + go.transform.position = ParseVector3(posToken); + + // Apply rotation + var rotToken = props["rotation"]; + if (rotToken != null) + go.transform.eulerAngles = ParseVector3(rotToken); + + // Apply size/dimensions via scale (ShapeGenerator creates shapes with known defaults) + ApplyShapeDimensions(go, shapeTypeStr, props); + + RefreshMesh(pbMesh); + + return new SuccessResponse($"Created ProBuilder {shapeTypeStr}: {go.name}", new + { + gameObjectName = go.name, + instanceId = go.GetInstanceID(), + shapeType = shapeTypeStr, + faceCount = GetFaceCount(pbMesh), + vertexCount = GetVertexCount(pbMesh), + }); + } + + private static void ApplyShapeDimensions(GameObject go, string shapeType, JObject props) + { + float size = props["size"]?.Value() ?? 0; + float width = props["width"]?.Value() ?? 0; + float height = props["height"]?.Value() ?? 0; + float depth = props["depth"]?.Value() ?? 0; + float radius = props["radius"]?.Value() ?? 0; + + if (size <= 0 && width <= 0 && height <= 0 && depth <= 0 && radius <= 0) + return; + + // Each shape type has known default dimensions from ProBuilder's ShapeGenerator. + // We compute a scale factor relative to those defaults. + Vector3 scale; + string shapeUpper = shapeType.ToUpperInvariant(); + + switch (shapeUpper) + { + case "CUBE": + // Default: 1x1x1 + scale = new Vector3( + width > 0 ? width : (size > 0 ? size : 1f), + height > 0 ? height : (size > 0 ? size : 1f), + depth > 0 ? depth : (size > 0 ? size : 1f)); + break; + + case "PRISM": + // Default: 1x1x1 + scale = new Vector3( + width > 0 ? width : (size > 0 ? size : 1f), + height > 0 ? height : (size > 0 ? size : 1f), + depth > 0 ? depth : (size > 0 ? size : 1f)); + break; + + case "CYLINDER": + // Default: radius=0.5 (diameter=1), height=2 + float cylRadius = radius > 0 ? radius : (size > 0 ? size / 2f : 0.5f); + float cylHeight = height > 0 ? height : (size > 0 ? size : 2f); + scale = new Vector3(cylRadius / 0.5f, cylHeight / 2f, cylRadius / 0.5f); + break; + + case "CONE": + // Default: 1x1x1 (radius 0.5) + float coneRadius = radius > 0 ? radius : (size > 0 ? size / 2f : 0.5f); + float coneHeight = height > 0 ? height : (size > 0 ? size : 1f); + scale = new Vector3(coneRadius / 0.5f, coneHeight, coneRadius / 0.5f); + break; + + case "SPHERE": + // Default: radius=0.5 (diameter=1) + float sphereRadius = radius > 0 ? radius : (size > 0 ? size / 2f : 0.5f); + scale = Vector3.one * (sphereRadius / 0.5f); + break; + + case "TORUS": + // Default: fits in ~1x1x1 + float torusScale = radius > 0 ? radius * 2f : (size > 0 ? size : 1f); + scale = Vector3.one * torusScale; + break; + + case "ARCH": + // Default: approximately 4x2x1 + scale = new Vector3( + width > 0 ? width / 4f : (size > 0 ? size / 4f : 1f), + height > 0 ? height / 2f : (size > 0 ? size / 2f : 1f), + depth > 0 ? depth : (size > 0 ? size : 1f)); + break; + + case "STAIR": + // Default: approximately 2x2.5x4 + scale = new Vector3( + width > 0 ? width / 2f : (size > 0 ? size / 2f : 1f), + height > 0 ? height / 2.5f : (size > 0 ? size / 2.5f : 1f), + depth > 0 ? depth / 4f : (size > 0 ? size / 4f : 1f)); + break; + + case "CURVEDSTAIR": + // Default: similar to stair + scale = new Vector3( + width > 0 ? width / 2f : (size > 0 ? size / 2f : 1f), + height > 0 ? height / 2.5f : (size > 0 ? size / 2.5f : 1f), + depth > 0 ? depth / 2f : (size > 0 ? size / 2f : 1f)); + break; + + case "PIPE": + // Default: radius=1, height=2 + float pipeRadius = radius > 0 ? radius : (size > 0 ? size / 2f : 1f); + float pipeHeight = height > 0 ? height : (size > 0 ? size : 2f); + scale = new Vector3(pipeRadius, pipeHeight / 2f, pipeRadius); + break; + + case "PLANE": + // Default: 1x1 + float planeSize = size > 0 ? size : 1f; + scale = new Vector3( + width > 0 ? width : planeSize, + 1f, + depth > 0 ? depth : planeSize); + break; + + case "DOOR": + // Default: approximately 4x4x1 + scale = new Vector3( + width > 0 ? width / 4f : (size > 0 ? size / 4f : 1f), + height > 0 ? height / 4f : (size > 0 ? size / 4f : 1f), + depth > 0 ? depth : (size > 0 ? size : 1f)); + break; + + default: + // Generic fallback: uniform scale from size + if (size > 0) + scale = Vector3.one * size; + else + return; // No dimensions to apply + break; + } + + go.transform.localScale = scale; + } + + private static object CreatePolyShape(JObject @params) + { + var props = ExtractProperties(@params); + var pointsToken = props["points"]; + if (pointsToken == null) + return new ErrorResponse("points parameter is required."); + + var points = new List(); + foreach (var pt in pointsToken) + points.Add(ParseVector3(pt)); + + if (points.Count < 3) + return new ErrorResponse("At least 3 points are required for a poly shape."); + + float extrudeHeight = props["extrudeHeight"]?.Value() ?? props["extrude_height"]?.Value() ?? 1f; + bool flipNormals = props["flipNormals"]?.Value() ?? props["flip_normals"]?.Value() ?? false; + + // Create a new GameObject with ProBuilderMesh + var go = new GameObject("PolyShape"); + Undo.RegisterCreatedObjectUndo(go, "Create ProBuilder PolyShape"); + var pbMesh = go.AddComponent(_proBuilderMeshType); + + // Use AppendElements.CreateShapeFromPolygon + if (_appendElementsType == null) + { + UnityEngine.Object.DestroyImmediate(go); + return new ErrorResponse("AppendElements type not found in ProBuilder assembly."); + } + + var createFromPolygonMethod = _appendElementsType.GetMethod("CreateShapeFromPolygon", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, typeof(IList), typeof(float), typeof(bool) }, + null); + + if (createFromPolygonMethod == null) + { + UnityEngine.Object.DestroyImmediate(go); + return new ErrorResponse("CreateShapeFromPolygon method not found."); + } + + var actionResult = createFromPolygonMethod.Invoke(null, new object[] { pbMesh, points, extrudeHeight, flipNormals }); + + string name = props["name"]?.ToString(); + if (!string.IsNullOrEmpty(name)) + go.name = name; + + RefreshMesh(pbMesh); + + return new SuccessResponse($"Created poly shape: {go.name}", new + { + gameObjectName = go.name, + instanceId = go.GetInstanceID(), + pointCount = points.Count, + extrudeHeight, + faceCount = GetFaceCount(pbMesh), + vertexCount = GetVertexCount(pbMesh), + }); + } + + // ===================================================================== + // Mesh Editing + // ===================================================================== + + private static object ExtrudeFaces(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var faces = GetFacesByIndices(pbMesh, props["faceIndices"] ?? props["face_indices"]); + float distance = props["distance"]?.Value() ?? 0.5f; + + string methodStr = props["method"]?.ToString() ?? "FaceNormal"; + object extrudeMethod; + try + { + extrudeMethod = Enum.Parse(_extrudeMethodEnum, methodStr, true); + } + catch + { + return new ErrorResponse($"Unknown extrude method '{methodStr}'. Valid: FaceNormal, VertexNormal, IndividualFaces"); + } + + Undo.RegisterCompleteObjectUndo(pbMesh, "Extrude Faces"); + + var extrudeMethodInfo = _extrudeElementsType?.GetMethod("Extrude", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, faces.GetType(), _extrudeMethodEnum, typeof(float) }, + null); + + if (extrudeMethodInfo == null) + return new ErrorResponse("ExtrudeElements.Extrude method not found."); + + extrudeMethodInfo.Invoke(null, new object[] { pbMesh, faces, extrudeMethod, distance }); + RefreshMesh(pbMesh); + + return new SuccessResponse($"Extruded {faces.Length} face(s) by {distance}", new + { + facesExtruded = faces.Length, + distance, + method = methodStr, + faceCount = GetFaceCount(pbMesh), + }); + } + + private static object ExtrudeEdges(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var edgeIndicesToken = props["edgeIndices"] ?? props["edge_indices"]; + if (edgeIndicesToken == null) + return new ErrorResponse("edgeIndices parameter is required."); + + float distance = props["distance"]?.Value() ?? 0.5f; + bool asGroup = props["asGroup"]?.Value() ?? props["as_group"]?.Value() ?? true; + + var edgeIndices = edgeIndicesToken.ToObject(); + + // Get edges from the mesh + var edgesProperty = _proBuilderMeshType.GetProperty("faces"); + var allFaces = (System.Collections.IList)edgesProperty?.GetValue(pbMesh); + if (allFaces == null) + return new ErrorResponse("Could not read faces from mesh."); + + // Collect edges from specified indices + var edgeList = new List(); + var allEdges = new List(); + + // Get all edges via face edges + foreach (var face in allFaces) + { + var edgesProp = _faceType.GetProperty("edges"); + if (edgesProp != null) + { + var faceEdges = edgesProp.GetValue(face) as System.Collections.IList; + if (faceEdges != null) + { + foreach (var edge in faceEdges) + allEdges.Add(edge); + } + } + } + + foreach (int idx in edgeIndices) + { + if (idx < 0 || idx >= allEdges.Count) + return new ErrorResponse($"Edge index {idx} out of range (0-{allEdges.Count - 1})."); + edgeList.Add(allEdges[idx]); + } + + Undo.RegisterCompleteObjectUndo(pbMesh, "Extrude Edges"); + + var edgeArray = Array.CreateInstance(_edgeType, edgeList.Count); + for (int i = 0; i < edgeList.Count; i++) + edgeArray.SetValue(edgeList[i], i); + + var extrudeMethod = _extrudeElementsType?.GetMethod("Extrude", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, edgeArray.GetType(), typeof(float), typeof(bool), typeof(bool) }, + null); + + if (extrudeMethod == null) + return new ErrorResponse("ExtrudeElements.Extrude (edges) method not found."); + + extrudeMethod.Invoke(null, new object[] { pbMesh, edgeArray, distance, asGroup, true }); + RefreshMesh(pbMesh); + + return new SuccessResponse($"Extruded {edgeList.Count} edge(s) by {distance}", new + { + edgesExtruded = edgeList.Count, + distance, + faceCount = GetFaceCount(pbMesh), + }); + } + + private static object BevelEdges(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var edgeIndicesToken = props["edgeIndices"] ?? props["edge_indices"]; + if (edgeIndicesToken == null) + return new ErrorResponse("edgeIndices parameter is required."); + + float amount = props["amount"]?.Value() ?? 0.1f; + + if (_bevelType == null) + return new ErrorResponse("Bevel type not found in ProBuilder assembly."); + + // Collect edges + var allEdges = CollectAllEdges(pbMesh); + var edgeIndices = edgeIndicesToken.ToObject(); + var selectedEdges = new List(); + foreach (int idx in edgeIndices) + { + if (idx < 0 || idx >= allEdges.Count) + return new ErrorResponse($"Edge index {idx} out of range (0-{allEdges.Count - 1})."); + selectedEdges.Add(allEdges[idx]); + } + + Undo.RegisterCompleteObjectUndo(pbMesh, "Bevel Edges"); + + // BevelEdges expects IList + var edgeListType = typeof(List<>).MakeGenericType(_edgeType); + var typedList = Activator.CreateInstance(edgeListType) as System.Collections.IList; + foreach (var e in selectedEdges) + typedList.Add(e); + + var bevelMethod = _bevelType.GetMethod("BevelEdges", + BindingFlags.Static | BindingFlags.Public); + + if (bevelMethod == null) + return new ErrorResponse("Bevel.BevelEdges method not found."); + + bevelMethod.Invoke(null, new object[] { pbMesh, typedList, amount }); + RefreshMesh(pbMesh); + + return new SuccessResponse($"Beveled {selectedEdges.Count} edge(s) with amount {amount}", new + { + edgesBeveled = selectedEdges.Count, + amount, + faceCount = GetFaceCount(pbMesh), + }); + } + + private static object Subdivide(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + + if (_surfaceTopologyType == null) + return new ErrorResponse("SurfaceTopology type not found."); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Subdivide"); + + // Find Subdivide method - try by parameter count first to avoid fragile generic type matching + var subdivideMethod = _surfaceTopologyType.GetMethods(BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault(m => m.Name == "Subdivide" && m.GetParameters().Length == 2); + + if (subdivideMethod == null) + { + subdivideMethod = _surfaceTopologyType.GetMethods(BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault(m => m.Name == "Subdivide"); + } + + if (subdivideMethod == null) + return new ErrorResponse("SurfaceTopology.Subdivide method not found."); + + var faceIndicesToken = props["faceIndices"] ?? props["face_indices"]; + if (faceIndicesToken != null) + { + var faces = GetFacesByIndices(pbMesh, faceIndicesToken); + var faceListType = typeof(List<>).MakeGenericType(_faceType); + var faceList = Activator.CreateInstance(faceListType) as System.Collections.IList; + foreach (var f in faces) + faceList.Add(f); + subdivideMethod.Invoke(null, new object[] { pbMesh, faceList }); + } + else + { + // Subdivide all - pass null or all faces + subdivideMethod.Invoke(null, new object[] { pbMesh, null }); + } + + RefreshMesh(pbMesh); + + return new SuccessResponse("Subdivided mesh", new + { + faceCount = GetFaceCount(pbMesh), + vertexCount = GetVertexCount(pbMesh), + }); + } + + private static object DeleteFaces(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var faceIndicesToken = props["faceIndices"] ?? props["face_indices"]; + if (faceIndicesToken == null) + return new ErrorResponse("faceIndices parameter is required."); + + if (_deleteElementsType == null) + return new ErrorResponse("DeleteElements type not found."); + + var faceIndices = faceIndicesToken.ToObject(); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Delete Faces"); + + // DeleteElements.DeleteFaces(ProBuilderMesh, int[]) + var deleteMethod = _deleteElementsType.GetMethod("DeleteFaces", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, typeof(int[]) }, + null); + + if (deleteMethod == null) + { + // Try with IEnumerable + var faces = GetFacesByIndices(pbMesh, faceIndicesToken); + deleteMethod = _deleteElementsType.GetMethod("DeleteFaces", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, faces.GetType() }, + null); + + if (deleteMethod == null) + return new ErrorResponse("DeleteElements.DeleteFaces method not found."); + + deleteMethod.Invoke(null, new object[] { pbMesh, faces }); + } + else + { + deleteMethod.Invoke(null, new object[] { pbMesh, faceIndices }); + } + + RefreshMesh(pbMesh); + + return new SuccessResponse($"Deleted {faceIndices.Length} face(s)", new + { + facesDeleted = faceIndices.Length, + faceCount = GetFaceCount(pbMesh), + }); + } + + private static object BridgeEdges(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + + if (_appendElementsType == null) + return new ErrorResponse("AppendElements type not found."); + + var edgeAToken = props["edgeA"] ?? props["edge_a"]; + var edgeBToken = props["edgeB"] ?? props["edge_b"]; + if (edgeAToken == null || edgeBToken == null) + return new ErrorResponse("edgeA and edgeB parameters are required (as {a, b} vertex index pairs)."); + + // Create Edge instances from vertex index pairs + var edgeACtor = _edgeType.GetConstructor(new[] { typeof(int), typeof(int) }); + var edgeBCtor = _edgeType.GetConstructor(new[] { typeof(int), typeof(int) }); + if (edgeACtor == null) + return new ErrorResponse("Edge constructor not found."); + + int aA = edgeAToken["a"]?.Value() ?? 0; + int aB = edgeAToken["b"]?.Value() ?? 0; + int bA = edgeBToken["a"]?.Value() ?? 0; + int bB = edgeBToken["b"]?.Value() ?? 0; + + var edgeA = edgeACtor.Invoke(new object[] { aA, aB }); + var edgeB = edgeBCtor.Invoke(new object[] { bA, bB }); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Bridge Edges"); + + var bridgeMethod = _appendElementsType.GetMethod("Bridge", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, _edgeType, _edgeType }, + null); + + if (bridgeMethod == null) + return new ErrorResponse("AppendElements.Bridge method not found."); + + var result = bridgeMethod.Invoke(null, new object[] { pbMesh, edgeA, edgeB }); + RefreshMesh(pbMesh); + + return new SuccessResponse("Bridged edges", new + { + bridgeCreated = result != null, + faceCount = GetFaceCount(pbMesh), + }); + } + + private static object ConnectElements(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + + if (_connectElementsType == null) + return new ErrorResponse("ConnectElements type not found."); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Connect Elements"); + + var faceIndicesToken = props["faceIndices"] ?? props["face_indices"]; + var edgeIndicesToken = props["edgeIndices"] ?? props["edge_indices"]; + + if (faceIndicesToken != null) + { + var faces = GetFacesByIndices(pbMesh, faceIndicesToken); + var connectMethod = _connectElementsType.GetMethod("Connect", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, faces.GetType() }, + null); + + if (connectMethod == null) + return new ErrorResponse("ConnectElements.Connect (faces) method not found."); + + connectMethod.Invoke(null, new object[] { pbMesh, faces }); + } + else if (edgeIndicesToken != null) + { + var allEdges = CollectAllEdges(pbMesh); + var edgeIndices = edgeIndicesToken.ToObject(); + var edgeListType = typeof(List<>).MakeGenericType(_edgeType); + var typedList = Activator.CreateInstance(edgeListType) as System.Collections.IList; + foreach (int idx in edgeIndices) + { + if (idx < 0 || idx >= allEdges.Count) + return new ErrorResponse($"Edge index {idx} out of range."); + typedList.Add(allEdges[idx]); + } + + var connectMethod = _connectElementsType.GetMethod("Connect", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, edgeListType }, + null); + + if (connectMethod == null) + return new ErrorResponse("ConnectElements.Connect (edges) method not found."); + + connectMethod.Invoke(null, new object[] { pbMesh, typedList }); + } + else + { + return new ErrorResponse("Either faceIndices or edgeIndices parameter is required."); + } + + RefreshMesh(pbMesh); + + return new SuccessResponse("Connected elements", new + { + faceCount = GetFaceCount(pbMesh), + }); + } + + private static object DetachFaces(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var faces = GetFacesByIndices(pbMesh, props["faceIndices"] ?? props["face_indices"]); + + if (_extrudeElementsType == null) + return new ErrorResponse("ExtrudeElements type not found."); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Detach Faces"); + + var detachMethod = _extrudeElementsType.GetMethod("DetachFaces", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, faces.GetType() }, + null); + + if (detachMethod == null) + return new ErrorResponse("ExtrudeElements.DetachFaces method not found."); + + var detachedFaces = detachMethod.Invoke(null, new object[] { pbMesh, faces }); + RefreshMesh(pbMesh); + + return new SuccessResponse($"Detached {faces.Length} face(s)", new + { + facesDetached = faces.Length, + faceCount = GetFaceCount(pbMesh), + }); + } + + private static object FlipNormals(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var faces = GetFacesByIndices(pbMesh, props["faceIndices"] ?? props["face_indices"]); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Flip Normals"); + + // Face.Reverse() flips the normal of each face + var reverseMethod = _faceType.GetMethod("Reverse"); + if (reverseMethod == null) + return new ErrorResponse("Face.Reverse method not found."); + + foreach (var face in faces) + reverseMethod.Invoke(face, null); + + RefreshMesh(pbMesh); + + return new SuccessResponse($"Flipped normals on {faces.Length} face(s)", new + { + facesFlipped = faces.Length, + }); + } + + private static object MergeFaces(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var faces = GetFacesByIndices(pbMesh, props["faceIndices"] ?? props["face_indices"]); + + if (_mergeElementsType == null) + return new ErrorResponse("MergeElements type not found."); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Merge Faces"); + + var mergeMethod = _mergeElementsType.GetMethod("Merge", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, faces.GetType() }, + null); + + if (mergeMethod == null) + return new ErrorResponse("MergeElements.Merge method not found."); + + mergeMethod.Invoke(null, new object[] { pbMesh, faces }); + RefreshMesh(pbMesh); + + return new SuccessResponse($"Merged {faces.Length} face(s)", new + { + facesMerged = faces.Length, + faceCount = GetFaceCount(pbMesh), + }); + } + + private static object CombineMeshes(JObject @params) + { + var props = ExtractProperties(@params); + var targetsToken = props["targets"]; + if (targetsToken == null) + return new ErrorResponse("targets parameter is required (list of GameObject names/paths/ids)."); + + if (_combineMeshesType == null) + return new ErrorResponse("CombineMeshes type not found."); + + var targets = targetsToken.ToObject(); + var pbMeshes = new List(); + + foreach (var targetStr in targets) + { + var go = ObjectResolver.ResolveGameObject(targetStr, null); + if (go == null) + return new ErrorResponse($"GameObject not found: {targetStr}"); + var pbMesh = GetProBuilderMesh(go); + if (pbMesh == null) + return new ErrorResponse($"GameObject '{go.name}' does not have a ProBuilderMesh component."); + pbMeshes.Add(pbMesh); + } + + if (pbMeshes.Count < 2) + return new ErrorResponse("At least 2 ProBuilder meshes are required for combining."); + + Undo.RegisterCompleteObjectUndo(pbMeshes[0], "Combine Meshes"); + + // Create typed list + var listType = typeof(List<>).MakeGenericType(_proBuilderMeshType); + var typedList = Activator.CreateInstance(listType) as System.Collections.IList; + foreach (var m in pbMeshes) + typedList.Add(m); + + var combineMethod = _combineMeshesType.GetMethod("Combine", + BindingFlags.Static | BindingFlags.Public); + + if (combineMethod == null) + return new ErrorResponse("CombineMeshes.Combine method not found."); + + combineMethod.Invoke(null, new object[] { typedList, pbMeshes[0] }); + RefreshMesh(pbMeshes[0]); + + return new SuccessResponse($"Combined {pbMeshes.Count} meshes", new + { + meshesCombined = pbMeshes.Count, + targetName = pbMeshes[0].gameObject.name, + faceCount = GetFaceCount(pbMeshes[0]), + }); + } + + private static Component ConvertToProBuilderInternal(GameObject go) + { + var existingPB = GetProBuilderMesh(go); + if (existingPB != null) + return existingPB; + + var meshFilter = go.GetComponent(); + if (meshFilter == null || meshFilter.sharedMesh == null) + return null; + + if (_meshImporterType == null) + return null; + + var pbMesh = go.AddComponent(_proBuilderMeshType); + + var importerCtor = _meshImporterType.GetConstructor(new[] { _proBuilderMeshType }); + if (importerCtor == null) + return null; + + var importer = importerCtor.Invoke(new object[] { pbMesh }); + var importM = _meshImporterType.GetMethod("Import", + BindingFlags.Instance | BindingFlags.Public, + null, + new[] { typeof(Mesh) }, + null); + + if (importM == null) + importM = _meshImporterType.GetMethod("Import", + BindingFlags.Instance | BindingFlags.Public); + + if (importM != null) + importM.Invoke(importer, new object[] { meshFilter.sharedMesh }); + + RefreshMesh(pbMesh); + return pbMesh; + } + + private static object MergeObjects(JObject @params) + { + var props = ExtractProperties(@params); + var targetsToken = props["targets"]; + if (targetsToken == null) + return new ErrorResponse("targets parameter is required (list of GameObject names/paths/ids)."); + + if (_combineMeshesType == null) + return new ErrorResponse("CombineMeshes type not found. Ensure ProBuilder is installed."); + + var targets = targetsToken.ToObject(); + if (targets.Length < 2) + return new ErrorResponse("At least 2 targets are required for merging."); + + var pbMeshes = new List(); + var nonPbObjects = new List(); + + foreach (var targetStr in targets) + { + var go = ObjectResolver.ResolveGameObject(targetStr, null); + if (go == null) + return new ErrorResponse($"GameObject not found: {targetStr}"); + var pbMesh = GetProBuilderMesh(go); + if (pbMesh != null) + pbMeshes.Add(pbMesh); + else + nonPbObjects.Add(go); + } + + // Convert non-ProBuilder objects first + foreach (var go in nonPbObjects) + { + var converted = ConvertToProBuilderInternal(go); + if (converted == null) + return new ErrorResponse($"Failed to convert '{go.name}' to ProBuilder mesh."); + pbMeshes.Add(converted); + } + + if (pbMeshes.Count < 2) + return new ErrorResponse("Need at least 2 meshes after conversion."); + + Undo.RegisterCompleteObjectUndo(pbMeshes[0], "Merge Objects"); + + var listType = typeof(List<>).MakeGenericType(_proBuilderMeshType); + var typedList = Activator.CreateInstance(listType) as System.Collections.IList; + foreach (var m in pbMeshes) + typedList.Add(m); + + var combineMethod = _combineMeshesType.GetMethod("Combine", + BindingFlags.Static | BindingFlags.Public); + + if (combineMethod == null) + return new ErrorResponse("CombineMeshes.Combine method not found."); + + combineMethod.Invoke(null, new object[] { typedList, pbMeshes[0] }); + RefreshMesh(pbMeshes[0]); + + string resultName = props["name"]?.ToString(); + if (!string.IsNullOrEmpty(resultName)) + pbMeshes[0].gameObject.name = resultName; + + return new SuccessResponse($"Merged {targets.Length} objects into '{pbMeshes[0].gameObject.name}'", new + { + mergedCount = targets.Length, + convertedCount = nonPbObjects.Count, + targetName = pbMeshes[0].gameObject.name, + faceCount = GetFaceCount(pbMeshes[0]), + vertexCount = GetVertexCount(pbMeshes[0]), + }); + } + + // ===================================================================== + // Vertex Operations + // ===================================================================== + + private static object MergeVertices(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var vertexIndicesToken = props["vertexIndices"] ?? props["vertex_indices"]; + if (vertexIndicesToken == null) + return new ErrorResponse("vertexIndices parameter is required."); + + var vertexIndices = vertexIndicesToken.ToObject(); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Merge Vertices"); + + // Use reflection to find the WeldVertices or MergeVertices method + var vertexEditingType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.VertexEditing, Unity.ProBuilder"); + if (vertexEditingType == null) + return new ErrorResponse("VertexEditing type not found."); + + var mergeMethod = vertexEditingType.GetMethod("MergeVertices", + BindingFlags.Static | BindingFlags.Public); + + if (mergeMethod == null) + { + // Try WeldVertices + mergeMethod = vertexEditingType.GetMethod("WeldVertices", + BindingFlags.Static | BindingFlags.Public); + } + + if (mergeMethod == null) + return new ErrorResponse("MergeVertices/WeldVertices method not found."); + + mergeMethod.Invoke(null, new object[] { pbMesh, vertexIndices, true }); + RefreshMesh(pbMesh); + + return new SuccessResponse($"Merged {vertexIndices.Length} vertices", new + { + verticesMerged = vertexIndices.Length, + vertexCount = GetVertexCount(pbMesh), + }); + } + + private static object SplitVertices(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var vertexIndicesToken = props["vertexIndices"] ?? props["vertex_indices"]; + if (vertexIndicesToken == null) + return new ErrorResponse("vertexIndices parameter is required."); + + var vertexIndices = vertexIndicesToken.ToObject(); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Split Vertices"); + + var vertexEditingType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.VertexEditing, Unity.ProBuilder"); + if (vertexEditingType == null) + return new ErrorResponse("VertexEditing type not found."); + + var splitMethod = vertexEditingType.GetMethod("SplitVertices", + BindingFlags.Static | BindingFlags.Public); + + if (splitMethod == null) + return new ErrorResponse("SplitVertices method not found."); + + splitMethod.Invoke(null, new object[] { pbMesh, vertexIndices }); + RefreshMesh(pbMesh); + + return new SuccessResponse($"Split {vertexIndices.Length} vertices", new + { + verticesSplit = vertexIndices.Length, + vertexCount = GetVertexCount(pbMesh), + }); + } + + private static object MoveVertices(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var vertexIndicesToken = props["vertexIndices"] ?? props["vertex_indices"]; + if (vertexIndicesToken == null) + return new ErrorResponse("vertexIndices parameter is required."); + + var offsetToken = props["offset"]; + if (offsetToken == null) + return new ErrorResponse("offset parameter is required ([x,y,z])."); + + var vertexIndices = vertexIndicesToken.ToObject(); + var offset = ParseVector3(offsetToken); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Move Vertices"); + + // Get positions array and modify + var positionsProperty = _proBuilderMeshType.GetProperty("positions"); + if (positionsProperty == null) + return new ErrorResponse("Could not access positions property."); + + var positions = positionsProperty.GetValue(pbMesh) as IList; + if (positions == null) + return new ErrorResponse("Could not read positions."); + + var posList = new List(positions); + foreach (int idx in vertexIndices) + { + if (idx < 0 || idx >= posList.Count) + return new ErrorResponse($"Vertex index {idx} out of range (0-{posList.Count - 1})."); + posList[idx] += offset; + } + + // Set positions back + var setPositionsMethod = _proBuilderMeshType.GetMethod("SetVertices", + BindingFlags.Instance | BindingFlags.Public, + null, + new[] { typeof(IList) }, + null); + + if (setPositionsMethod == null) + { + // Try alternative: RebuildWithPositionsAndFaces or direct positions + var posField = _proBuilderMeshType.GetProperty("positions"); + return new ErrorResponse("SetVertices method not found. Use vertex editing tools instead."); + } + + setPositionsMethod.Invoke(pbMesh, new object[] { posList }); + RefreshMesh(pbMesh); + + return new SuccessResponse($"Moved {vertexIndices.Length} vertices by ({offset.x}, {offset.y}, {offset.z})", new + { + verticesMoved = vertexIndices.Length, + offset = new[] { offset.x, offset.y, offset.z }, + }); + } + + // ===================================================================== + // UV & Materials + // ===================================================================== + + private static object SetFaceMaterial(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var faces = GetFacesByIndices(pbMesh, props["faceIndices"] ?? props["face_indices"]); + + string materialPath = props["materialPath"]?.ToString() ?? props["material_path"]?.ToString(); + if (string.IsNullOrEmpty(materialPath)) + return new ErrorResponse("materialPath parameter is required."); + + var material = AssetDatabase.LoadAssetAtPath(materialPath); + if (material == null) + return new ErrorResponse($"Material not found at path: {materialPath}"); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Set Face Material"); + + // ProBuilderMesh.SetMaterial(IEnumerable, Material) + var setMaterialMethod = _proBuilderMeshType.GetMethod("SetMaterial", + BindingFlags.Instance | BindingFlags.Public); + + if (setMaterialMethod == null) + return new ErrorResponse("SetMaterial method not found on ProBuilderMesh."); + + setMaterialMethod.Invoke(pbMesh, new object[] { faces, material }); + + // Before RefreshMesh, compact renderer materials to only those referenced by faces. + // ProBuilder's SetMaterial adds new materials to the renderer array but doesn't + // remove unused ones, causing "more materials than submeshes" warnings. + var meshRenderer = pbMesh.gameObject.GetComponent(); + if (meshRenderer != null) + { + var allFacesList = (System.Collections.IList)GetFacesArray(pbMesh); + var submeshIndexProp = _faceType.GetProperty("submeshIndex"); + var currentMats = meshRenderer.sharedMaterials; + + // Collect unique submesh indices actually used by faces + var usedIndices = new SortedSet(); + foreach (var f in allFacesList) + usedIndices.Add((int)submeshIndexProp.GetValue(f)); + + // Only compact if there are unused material slots + if (usedIndices.Count < currentMats.Length) + { + // Build compacted materials array and remap face indices + var remap = new Dictionary(); + var newMats = new Material[usedIndices.Count]; + int newIdx = 0; + foreach (int oldIdx in usedIndices) + { + newMats[newIdx] = oldIdx < currentMats.Length ? currentMats[oldIdx] : material; + remap[oldIdx] = newIdx; + newIdx++; + } + + foreach (var f in allFacesList) + { + int si = (int)submeshIndexProp.GetValue(f); + if (remap.TryGetValue(si, out int mapped) && mapped != si) + submeshIndexProp.SetValue(f, mapped); + } + + meshRenderer.sharedMaterials = newMats; + } + } + + RefreshMesh(pbMesh); + + return new SuccessResponse($"Set material on {faces.Length} face(s)", new + { + facesModified = faces.Length, + materialPath, + }); + } + + private static object SetFaceColor(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var faces = GetFacesByIndices(pbMesh, props["faceIndices"] ?? props["face_indices"]); + + var colorToken = props["color"]; + if (colorToken == null) + return new ErrorResponse("color parameter is required ([r,g,b,a])."); + + var color = VectorParsing.ParseColorOrDefault(colorToken); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Set Face Color"); + + // ProBuilderMesh.SetFaceColor(Face, Color) + var setColorMethod = _proBuilderMeshType.GetMethod("SetFaceColor", + BindingFlags.Instance | BindingFlags.Public); + + if (setColorMethod == null) + return new ErrorResponse("SetFaceColor method not found."); + + foreach (var face in faces) + setColorMethod.Invoke(pbMesh, new object[] { face, color }); + + RefreshMesh(pbMesh); + + // Auto-swap to vertex-color shader if current material is Standard + bool skipSwap = props["skipMaterialSwap"]?.Value() ?? props["skip_material_swap"]?.Value() ?? false; + if (!skipSwap) + { + var go = pbMesh.gameObject; + var renderer = go.GetComponent(); + if (renderer != null && renderer.sharedMaterial != null && + renderer.sharedMaterial.shader.name.Contains("Standard")) + { + var vcShader = Shader.Find("ProBuilder/Standard Vertex Color") + ?? Shader.Find("ProBuilder/Diffuse Vertex Color") + ?? Shader.Find("Sprites/Default"); + if (vcShader != null) + { + var vcMat = new Material(vcShader); + renderer.sharedMaterial = vcMat; + } + } + } + + return new SuccessResponse($"Set color on {faces.Length} face(s)", new + { + facesModified = faces.Length, + color = new[] { color.r, color.g, color.b, color.a }, + }); + } + + private static object SetFaceUVs(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var faces = GetFacesByIndices(pbMesh, props["faceIndices"] ?? props["face_indices"]); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Set Face UVs"); + + // AutoUnwrapSettings is a struct on each Face + var uvProperty = _faceType.GetProperty("uv"); + if (uvProperty == null) + return new ErrorResponse("Face.uv property not found."); + + var autoUnwrapType = uvProperty.PropertyType; + + foreach (var face in faces) + { + var uvSettings = uvProperty.GetValue(face); + + // Apply scale + var scaleToken = props["scale"]; + if (scaleToken != null) + { + var scaleProp = autoUnwrapType.GetField("scale") ?? (MemberInfo)autoUnwrapType.GetProperty("scale"); + if (scaleProp is FieldInfo fi) + { + var scaleArr = scaleToken.ToObject(); + fi.SetValue(uvSettings, new Vector2(scaleArr[0], scaleArr.Length > 1 ? scaleArr[1] : scaleArr[0])); + } + } + + // Apply offset + var offsetToken = props["offset"]; + if (offsetToken != null) + { + var offsetField = autoUnwrapType.GetField("offset"); + if (offsetField != null) + { + var offsetArr = offsetToken.ToObject(); + offsetField.SetValue(uvSettings, new Vector2(offsetArr[0], offsetArr.Length > 1 ? offsetArr[1] : 0f)); + } + } + + // Apply rotation + var rotationToken = props["rotation"]; + if (rotationToken != null) + { + var rotField = autoUnwrapType.GetField("rotation"); + if (rotField != null) + rotField.SetValue(uvSettings, rotationToken.Value()); + } + + // Apply flipU + var flipUToken = props["flipU"] ?? props["flip_u"]; + if (flipUToken != null) + { + var flipUField = autoUnwrapType.GetField("flipU"); + if (flipUField != null) + flipUField.SetValue(uvSettings, flipUToken.Value()); + } + + // Apply flipV + var flipVToken = props["flipV"] ?? props["flip_v"]; + if (flipVToken != null) + { + var flipVField = autoUnwrapType.GetField("flipV"); + if (flipVField != null) + flipVField.SetValue(uvSettings, flipVToken.Value()); + } + + uvProperty.SetValue(face, uvSettings); + } + + // RefreshUV + var refreshUVMethod = _proBuilderMeshType.GetMethod("RefreshUV", + BindingFlags.Instance | BindingFlags.Public); + if (refreshUVMethod != null) + { + var allFaces = GetFacesArray(pbMesh); + refreshUVMethod.Invoke(pbMesh, new[] { allFaces }); + } + + RefreshMesh(pbMesh); + + return new SuccessResponse($"Set UV parameters on {faces.Length} face(s)", new + { + facesModified = faces.Length, + }); + } + + // ===================================================================== + // Query + // ===================================================================== + + private static object GetMeshInfo(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var include = (props["include"]?.ToString() ?? "summary").ToLowerInvariant(); + + var allFaces = GetFacesArray(pbMesh); + var facesList = (System.Collections.IList)allFaces; + + // Get bounds + var renderer = pbMesh.gameObject.GetComponent(); + Bounds bounds = renderer != null ? renderer.bounds : new Bounds(); + + // Get materials + var materials = new List(); + if (renderer != null) + { + foreach (var mat in renderer.sharedMaterials) + materials.Add(mat != null ? mat.name : "(none)"); + } + + // Always include summary data + var data = new Dictionary + { + ["gameObjectName"] = pbMesh.gameObject.name, + ["instanceId"] = pbMesh.gameObject.GetInstanceID(), + ["faceCount"] = GetFaceCount(pbMesh), + ["vertexCount"] = GetVertexCount(pbMesh), + ["bounds"] = new + { + center = new[] { bounds.center.x, bounds.center.y, bounds.center.z }, + size = new[] { bounds.size.x, bounds.size.y, bounds.size.z }, + }, + ["materials"] = materials, + }; + + // Include face details when requested + if (include == "faces" || include == "all") + { + var faceDetails = new List(); + for (int i = 0; i < facesList.Count && i < 100; i++) + { + var face = facesList[i]; + var smGroup = _faceType.GetProperty("smoothingGroup")?.GetValue(face); + var manualUV = _faceType.GetProperty("manualUV")?.GetValue(face); + var normal = ComputeFaceNormal(pbMesh, face); + var center = ComputeFaceCenter(pbMesh, face); + var direction = ClassifyDirection(normal); + + faceDetails.Add(new + { + index = i, + smoothingGroup = smGroup, + manualUV = manualUV, + normal = new[] { Round(normal.x), Round(normal.y), Round(normal.z) }, + center = new[] { Round(center.x), Round(center.y), Round(center.z) }, + direction, + }); + } + data["faces"] = faceDetails; + data["truncated"] = facesList.Count > 100; + } + + // Include edge data when requested + if (include == "edges" || include == "all") + { + var allEdges = CollectAllEdges(pbMesh); + var edgeDetails = new List(); + var aField = _edgeType.GetField("a"); + var bField = _edgeType.GetField("b"); + var aProp = aField == null ? _edgeType.GetProperty("a") : null; + var bProp = bField == null ? _edgeType.GetProperty("b") : null; + + for (int i = 0; i < allEdges.Count && i < 200; i++) + { + var edge = allEdges[i]; + int vertA = aField != null ? (int)aField.GetValue(edge) : + aProp != null ? (int)aProp.GetValue(edge) : -1; + int vertB = bField != null ? (int)bField.GetValue(edge) : + bProp != null ? (int)bProp.GetValue(edge) : -1; + edgeDetails.Add(new { index = i, vertexA = vertA, vertexB = vertB }); + } + data["edges"] = edgeDetails; + data["edgesTruncated"] = allEdges.Count > 200; + } + + return new SuccessResponse("ProBuilder mesh info", data); + } + + private static Vector3 ComputeFaceNormal(Component pbMesh, object face) + { + var positionsProp = _proBuilderMeshType.GetProperty("positions"); + var positions = positionsProp?.GetValue(pbMesh) as System.Collections.IList; + var indexesProp = _faceType.GetProperty("indexes"); + var indexes = indexesProp?.GetValue(face) as System.Collections.IList; + + if (positions == null || indexes == null || indexes.Count < 3) + return Vector3.up; + + var p0 = (Vector3)positions[(int)indexes[0]]; + var p1 = (Vector3)positions[(int)indexes[1]]; + var p2 = (Vector3)positions[(int)indexes[2]]; + + var localNormal = Vector3.Cross(p1 - p0, p2 - p0).normalized; + return pbMesh.transform.rotation * localNormal; + } + + private static Vector3 ComputeFaceCenter(Component pbMesh, object face) + { + var positionsProp = _proBuilderMeshType.GetProperty("positions"); + var positions = positionsProp?.GetValue(pbMesh) as System.Collections.IList; + var indexesProp = _faceType.GetProperty("indexes"); + var indexes = indexesProp?.GetValue(face) as System.Collections.IList; + + if (positions == null || indexes == null || indexes.Count == 0) + return pbMesh.transform.position; + + var sum = Vector3.zero; + foreach (int idx in indexes) + sum += (Vector3)positions[idx]; + + var localCenter = sum / indexes.Count; + return pbMesh.transform.TransformPoint(localCenter); + } + + private static string ClassifyDirection(Vector3 normal) + { + var dirs = new (Vector3 dir, string label)[] + { + (Vector3.up, "top"), + (Vector3.down, "bottom"), + (Vector3.forward, "front"), + (Vector3.back, "back"), + (Vector3.left, "left"), + (Vector3.right, "right"), + }; + + foreach (var (dir, label) in dirs) + { + if (Vector3.Dot(normal, dir) > 0.7f) + return label; + } + return null; + } + + private static float Round(float v) => (float)Math.Round(v, 4); + + private static object ConvertToProBuilder(JObject @params) + { + var go = FindTarget(@params); + if (go == null) + return new ErrorResponse("Target GameObject not found."); + + var existingPB = GetProBuilderMesh(go); + if (existingPB != null) + return new ErrorResponse($"GameObject '{go.name}' already has a ProBuilderMesh component."); + + var meshFilter = go.GetComponent(); + if (meshFilter == null || meshFilter.sharedMesh == null) + return new ErrorResponse($"GameObject '{go.name}' does not have a MeshFilter with a valid mesh."); + + if (_meshImporterType == null) + return new ErrorResponse("MeshImporter type not found."); + + Undo.RegisterCompleteObjectUndo(go, "Convert to ProBuilder"); + + // Add ProBuilderMesh component + var pbMesh = go.AddComponent(_proBuilderMeshType); + + // Create MeshImporter and import + var importerCtor = _meshImporterType.GetConstructor(new[] { _proBuilderMeshType }); + if (importerCtor == null) + { + // Try alternative constructor + var importMethod = _meshImporterType.GetMethod("Import", + BindingFlags.Instance | BindingFlags.Public); + + if (importMethod == null) + return new ErrorResponse("MeshImporter could not be initialized."); + } + + var importer = importerCtor.Invoke(new object[] { pbMesh }); + var importM = _meshImporterType.GetMethod("Import", + BindingFlags.Instance | BindingFlags.Public, + null, + new[] { typeof(Mesh) }, + null); + + if (importM == null) + { + // Try with MeshImportSettings + importM = _meshImporterType.GetMethod("Import", + BindingFlags.Instance | BindingFlags.Public); + } + + if (importM != null) + { + importM.Invoke(importer, new object[] { meshFilter.sharedMesh }); + } + + RefreshMesh(pbMesh); + + return new SuccessResponse($"Converted '{go.name}' to ProBuilder", new + { + gameObjectName = go.name, + faceCount = GetFaceCount(pbMesh), + vertexCount = GetVertexCount(pbMesh), + }); + } + + // ===================================================================== + // Edge Collection Helper + // ===================================================================== + + internal static List CollectAllEdges(Component pbMesh) + { + var allFaces = (System.Collections.IList)GetFacesArray(pbMesh); + var allEdges = new List(); + var edgesProp = _faceType.GetProperty("edges"); + + if (allFaces != null && edgesProp != null) + { + foreach (var face in allFaces) + { + var faceEdges = edgesProp.GetValue(face) as System.Collections.IList; + if (faceEdges != null) + { + foreach (var edge in faceEdges) + allEdges.Add(edge); + } + } + } + return allEdges; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs.meta b/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs.meta new file mode 100644 index 000000000..5ea764cae --- /dev/null +++ b/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7e89d5c862a0468baa683348c1ceff31 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs b/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs new file mode 100644 index 000000000..2841a1e05 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.ProBuilder +{ + internal static class ProBuilderMeshUtils + { + internal static object CenterPivot(JObject @params) + { + var pbMesh = ManageProBuilder.RequireProBuilderMesh(@params); + + var positionsProp = ManageProBuilder._proBuilderMeshType.GetProperty("positions"); + var positions = positionsProp?.GetValue(pbMesh) as System.Collections.IList; + if (positions == null || positions.Count == 0) + return new ErrorResponse("Could not read vertex positions."); + + // Compute local-space bounds center + var min = (Vector3)positions[0]; + var max = min; + foreach (Vector3 pos in positions) + { + min = Vector3.Min(min, pos); + max = Vector3.Max(max, pos); + } + var localCenter = (min + max) * 0.5f; + + if (localCenter.sqrMagnitude < 0.0001f) + return new SuccessResponse("Pivot is already centered", new { offset = new[] { 0f, 0f, 0f } }); + + Undo.RecordObject(pbMesh, "Center Pivot"); + Undo.RecordObject(pbMesh.transform, "Center Pivot"); + + // Offset all vertices by -localCenter + var newPositions = new Vector3[positions.Count]; + for (int i = 0; i < positions.Count; i++) + newPositions[i] = (Vector3)positions[i] - localCenter; + + // Set positions via reflection + var setPositionsMethod = ManageProBuilder._proBuilderMeshType.GetMethod("SetPositions", + BindingFlags.Instance | BindingFlags.Public); + if (setPositionsMethod == null) + { + // Try property setter via positions + var positionsField = ManageProBuilder._proBuilderMeshType.GetProperty("positions"); + if (positionsField != null && positionsField.CanWrite) + positionsField.SetValue(pbMesh, new List(newPositions)); + else + return new ErrorResponse("Cannot set vertex positions on ProBuilderMesh."); + } + else + { + setPositionsMethod.Invoke(pbMesh, new object[] { newPositions }); + } + + // Move transform to compensate + var worldOffset = pbMesh.transform.TransformVector(localCenter); + pbMesh.transform.position += worldOffset; + + ManageProBuilder.RefreshMesh(pbMesh); + + return new SuccessResponse("Pivot centered to mesh bounds center", new + { + offset = new[] { Round(localCenter.x), Round(localCenter.y), Round(localCenter.z) }, + newPosition = new[] + { + Round(pbMesh.transform.position.x), + Round(pbMesh.transform.position.y), + Round(pbMesh.transform.position.z), + }, + }); + } + + internal static object FreezeTransform(JObject @params) + { + var pbMesh = ManageProBuilder.RequireProBuilderMesh(@params); + + var positionsProp = ManageProBuilder._proBuilderMeshType.GetProperty("positions"); + var positions = positionsProp?.GetValue(pbMesh) as System.Collections.IList; + if (positions == null || positions.Count == 0) + return new ErrorResponse("Could not read vertex positions."); + + Undo.RecordObject(pbMesh, "Freeze Transform"); + Undo.RecordObject(pbMesh.transform, "Freeze Transform"); + + // Transform each vertex to world space, then back to identity local space + var worldPositions = new Vector3[positions.Count]; + for (int i = 0; i < positions.Count; i++) + worldPositions[i] = pbMesh.transform.TransformPoint((Vector3)positions[i]); + + // Reset transform + pbMesh.transform.position = Vector3.zero; + pbMesh.transform.rotation = Quaternion.identity; + pbMesh.transform.localScale = Vector3.one; + + // Set new positions (now in world space = new local space since identity) + var setPositionsMethod = ManageProBuilder._proBuilderMeshType.GetMethod("SetPositions", + BindingFlags.Instance | BindingFlags.Public); + if (setPositionsMethod == null) + { + var positionsProperty = ManageProBuilder._proBuilderMeshType.GetProperty("positions"); + if (positionsProperty != null && positionsProperty.CanWrite) + positionsProperty.SetValue(pbMesh, new List(worldPositions)); + else + return new ErrorResponse("Cannot set vertex positions on ProBuilderMesh."); + } + else + { + setPositionsMethod.Invoke(pbMesh, new object[] { worldPositions }); + } + + ManageProBuilder.RefreshMesh(pbMesh); + + return new SuccessResponse("Transform frozen into vertex data", new + { + vertexCount = worldPositions.Length, + }); + } + + internal static object ValidateMesh(JObject @params) + { + var pbMesh = ManageProBuilder.RequireProBuilderMesh(@params); + + var positionsProp = ManageProBuilder._proBuilderMeshType.GetProperty("positions"); + var positions = positionsProp?.GetValue(pbMesh) as System.Collections.IList; + var allFaces = ManageProBuilder.GetFacesArray(pbMesh); + var facesList = (System.Collections.IList)allFaces; + + int degenerateCount = 0; + var indexesProp = ManageProBuilder._faceType.GetProperty("indexes"); + + if (indexesProp != null && positions != null) + { + foreach (var face in facesList) + { + var indexes = indexesProp.GetValue(face) as System.Collections.IList; + if (indexes == null) continue; + + // Check triangles in groups of 3 + for (int i = 0; i + 2 < indexes.Count; i += 3) + { + var p0 = (Vector3)positions[(int)indexes[i]]; + var p1 = (Vector3)positions[(int)indexes[i + 1]]; + var p2 = (Vector3)positions[(int)indexes[i + 2]]; + + var area = Vector3.Cross(p1 - p0, p2 - p0).magnitude * 0.5f; + if (area < 1e-6f) + degenerateCount++; + } + } + } + + // Check for unused vertices + var usedVertices = new HashSet(); + if (indexesProp != null) + { + foreach (var face in facesList) + { + var indexes = indexesProp.GetValue(face) as System.Collections.IList; + if (indexes == null) continue; + foreach (int idx in indexes) + usedVertices.Add(idx); + } + } + + int totalVertices = positions?.Count ?? 0; + int unusedVertices = totalVertices - usedVertices.Count; + + var issues = new List(); + if (degenerateCount > 0) + issues.Add($"{degenerateCount} degenerate triangle(s)"); + if (unusedVertices > 0) + issues.Add($"{unusedVertices} unused vertex/vertices"); + + return new SuccessResponse( + issues.Count == 0 ? "Mesh is clean" : $"Found {issues.Count} issue type(s)", + new + { + healthy = issues.Count == 0, + faceCount = facesList.Count, + vertexCount = totalVertices, + degenerateTriangles = degenerateCount, + unusedVertices, + issues, + }); + } + + internal static object RepairMesh(JObject @params) + { + var pbMesh = ManageProBuilder.RequireProBuilderMesh(@params); + + Undo.RecordObject(pbMesh, "Repair Mesh"); + + int repaired = 0; + + // Try MeshValidation.RemoveDegenerateTriangles + if (ManageProBuilder._meshValidationType != null) + { + var removeMethod = ManageProBuilder._meshValidationType.GetMethod("RemoveDegenerateTriangles", + BindingFlags.Static | BindingFlags.Public); + + if (removeMethod != null) + { + var allFaces = ManageProBuilder.GetFacesArray(pbMesh); + try + { + var result = removeMethod.Invoke(null, new object[] { pbMesh, allFaces }); + if (result is int count) + repaired = count; + } + catch + { + // Some overloads differ; try without faces param + try + { + var altMethod = ManageProBuilder._meshValidationType.GetMethod("RemoveDegenerateTriangles", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { ManageProBuilder._proBuilderMeshType }, + null); + if (altMethod != null) + { + var result = altMethod.Invoke(null, new object[] { pbMesh }); + if (result is int count) + repaired = count; + } + } + catch + { + // Ignore fallback failure + } + } + } + } + + ManageProBuilder.RefreshMesh(pbMesh); + + return new SuccessResponse( + repaired > 0 ? $"Repaired {repaired} degenerate triangle(s)" : "No repairs needed", + new + { + degenerateTrianglesRemoved = repaired, + faceCount = ManageProBuilder.GetFaceCount(pbMesh), + vertexCount = ManageProBuilder.GetVertexCount(pbMesh), + }); + } + + private static float Round(float v) => (float)Math.Round(v, 4); + } +} diff --git a/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs.meta b/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs.meta new file mode 100644 index 000000000..0b2e4c665 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b4e2c8d5f6a74b9e0c3d2e1f4a5b6c7d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderSmoothing.cs b/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderSmoothing.cs new file mode 100644 index 000000000..a8446152d --- /dev/null +++ b/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderSmoothing.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.ProBuilder +{ + internal static class ProBuilderSmoothing + { + internal static object SetSmoothing(JObject @params) + { + var pbMesh = ManageProBuilder.RequireProBuilderMesh(@params); + var props = ManageProBuilder.ExtractProperties(@params); + + var faceIndicesToken = props["faceIndices"] ?? props["face_indices"]; + if (faceIndicesToken == null) + return new ErrorResponse("faceIndices parameter is required."); + + var smoothingGroup = props["smoothingGroup"]?.Value() + ?? props["smoothing_group"]?.Value() + ?? 0; + + var faces = ManageProBuilder.GetFacesByIndices(pbMesh, faceIndicesToken); + var smProp = ManageProBuilder._faceType.GetProperty("smoothingGroup"); + if (smProp == null) + return new ErrorResponse("Could not find smoothingGroup property on Face type."); + + Undo.RecordObject(pbMesh, "Set Smoothing Groups"); + + foreach (var face in faces) + smProp.SetValue(face, smoothingGroup); + + ManageProBuilder.RefreshMesh(pbMesh); + + return new SuccessResponse($"Set smoothing group {smoothingGroup} on {faces.Length} face(s)", new + { + facesModified = faces.Length, + smoothingGroup, + }); + } + + internal static object AutoSmooth(JObject @params) + { + var pbMesh = ManageProBuilder.RequireProBuilderMesh(@params); + var props = ManageProBuilder.ExtractProperties(@params); + + var angleThreshold = props["angleThreshold"]?.Value() + ?? props["angle_threshold"]?.Value() + ?? 30f; + + if (ManageProBuilder._smoothingType == null) + return new ErrorResponse("Smoothing type not found in ProBuilder assembly."); + + var allFaces = ManageProBuilder.GetFacesArray(pbMesh); + var facesList = (System.Collections.IList)allFaces; + + // Check for faceIndices to limit scope + var faceIndicesToken = props["faceIndices"] ?? props["face_indices"]; + object facesToSmooth; + if (faceIndicesToken != null) + { + facesToSmooth = ManageProBuilder.GetFacesByIndices(pbMesh, faceIndicesToken); + } + else + { + facesToSmooth = allFaces; + } + + Undo.RecordObject(pbMesh, "Auto Smooth"); + + // Smoothing.ApplySmoothingGroups(ProBuilderMesh mesh, IEnumerable faces, float angle) + var applyMethod = ManageProBuilder._smoothingType.GetMethod("ApplySmoothingGroups", + BindingFlags.Static | BindingFlags.Public); + + if (applyMethod != null) + { + applyMethod.Invoke(null, new object[] { pbMesh, facesToSmooth, angleThreshold }); + } + else + { + // Fallback: manually set smoothing groups based on angle + return new ErrorResponse("Smoothing.ApplySmoothingGroups method not found."); + } + + ManageProBuilder.RefreshMesh(pbMesh); + + return new SuccessResponse($"Auto-smoothed with angle threshold {angleThreshold}°", new + { + angleThreshold, + faceCount = facesList.Count, + }); + } + } +} diff --git a/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderSmoothing.cs.meta b/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderSmoothing.cs.meta new file mode 100644 index 000000000..801e60999 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderSmoothing.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a3f1b7c4d5e64a8f9b2c1d0e3f4a5b6c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md index 4a017bf6d..1e0848266 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ openupm add com.coplaydev.unity-mcp * **Extensible** — Works with various MCP Clients ### Available Tools -`apply_text_edits` • `batch_execute` • `create_script` • `debug_request_context` • `delete_script` • `execute_custom_tool` • `execute_menu_item` • `find_gameobjects` • `find_in_file` • `get_sha` • `get_test_job` • `manage_animation` • `manage_asset` • `manage_components` • `manage_editor` • `manage_gameobject` • `manage_material` • `manage_prefabs` • `manage_scene` • `manage_script` • `manage_script_capabilities` • `manage_scriptable_object` • `manage_shader` • `manage_texture` • `manage_tools` • `manage_ui` • `manage_vfx` • `read_console` • `refresh_unity` • `run_tests` • `script_apply_edits` • `set_active_instance` • `validate_script` +`apply_text_edits` • `batch_execute` • `create_script` • `debug_request_context` • `delete_script` • `execute_custom_tool` • `execute_menu_item` • `find_gameobjects` • `find_in_file` • `get_sha` • `get_test_job` • `manage_animation` • `manage_asset` • `manage_components` • `manage_editor` • `manage_gameobject` • `manage_material` • `manage_prefabs` • `manage_probuilder` • `manage_scene` • `manage_script` • `manage_script_capabilities` • `manage_scriptable_object` • `manage_shader` • `manage_texture` • `manage_tools` • `manage_ui` • `manage_vfx` • `read_console` • `refresh_unity` • `run_tests` • `script_apply_edits` • `set_active_instance` • `validate_script` ### Available Resources `custom_tools` • `editor_active_tool` • `editor_prefab_stage` • `editor_selection` • `editor_state` • `editor_windows` • `gameobject` • `gameobject_api` • `gameobject_component` • `gameobject_components` • `get_tests` • `get_tests_for_mode` • `menu_items` • `prefab_api` • `prefab_hierarchy` • `prefab_info` • `project_info` • `project_layers` • `project_tags` • `tool_groups` • `unity_instances` diff --git a/Server/src/cli/commands/probuilder.py b/Server/src/cli/commands/probuilder.py new file mode 100644 index 000000000..b251ce377 --- /dev/null +++ b/Server/src/cli/commands/probuilder.py @@ -0,0 +1,341 @@ +"""ProBuilder CLI commands for managing Unity ProBuilder meshes.""" + +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, handle_unity_errors +from cli.utils.parsers import parse_json_dict_or_exit, parse_json_list_or_exit +from cli.utils.constants import SEARCH_METHOD_CHOICE_TAGGED + + +_PB_TOP_LEVEL_KEYS = {"action", "target", "searchMethod", "properties"} + + +def _normalize_pb_params(params: dict[str, Any]) -> dict[str, Any]: + params = dict(params) + properties: dict[str, Any] = {} + for key in list(params.keys()): + if key in _PB_TOP_LEVEL_KEYS: + continue + properties[key] = params.pop(key) + + if properties: + existing = params.get("properties") + if isinstance(existing, dict): + params["properties"] = {**properties, **existing} + else: + params["properties"] = properties + + return {k: v for k, v in params.items() if v is not None} + + +@click.group() +def probuilder(): + """ProBuilder operations - 3D modeling, mesh editing, UV management.""" + pass + + +# ============================================================================= +# Shape Creation +# ============================================================================= + +@probuilder.command("create-shape") +@click.argument("shape_type") +@click.option("--name", "-n", default=None, help="Name for the created GameObject.") +@click.option("--position", nargs=3, type=float, default=None, help="Position X Y Z.") +@click.option("--rotation", nargs=3, type=float, default=None, help="Rotation X Y Z (euler).") +@click.option("--params", "-p", default="{}", help="Shape-specific parameters as JSON.") +@handle_unity_errors +def create_shape(shape_type: str, name: Optional[str], position, rotation, params: str): + """Create a ProBuilder shape. + + \\b + Shape types: Cube, Cylinder, Sphere, Plane, Cone, Torus, Pipe, Arch, + Stair, CurvedStair, Door, Prism + + \\b + Examples: + unity-mcp probuilder create-shape Cube + unity-mcp probuilder create-shape Torus --name "MyTorus" --params '{"rows": 16, "columns": 16}' + unity-mcp probuilder create-shape Stair --position 0 0 5 --params '{"steps": 10}' + """ + config = get_config() + extra = parse_json_dict_or_exit(params, "params") + + request: dict[str, Any] = { + "action": "create_shape", + "shapeType": shape_type, + } + if name: + request["name"] = name + if position: + request["position"] = list(position) + if rotation: + request["rotation"] = list(rotation) + request.update(extra) + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created ProBuilder {shape_type}") + + +@probuilder.command("create-poly") +@click.option("--points", "-p", required=True, help='Points as JSON: [[x,y,z], ...]') +@click.option("--height", "-h", type=float, default=1.0, help="Extrude height.") +@click.option("--name", "-n", default=None, help="Name for the created GameObject.") +@click.option("--flip-normals", is_flag=True, help="Flip face normals.") +@handle_unity_errors +def create_poly(points: str, height: float, name: Optional[str], flip_normals: bool): + """Create a ProBuilder mesh from a 2D polygon footprint. + + \\b + Examples: + unity-mcp probuilder create-poly --points "[[0,0,0],[5,0,0],[5,0,5],[0,0,5]]" --height 3 + """ + config = get_config() + points_list = parse_json_list_or_exit(points, "points") + + request: dict[str, Any] = { + "action": "create_poly_shape", + "points": points_list, + "extrudeHeight": height, + } + if name: + request["name"] = name + if flip_normals: + request["flipNormals"] = True + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Created ProBuilder poly shape") + + +# ============================================================================= +# Mesh Info +# ============================================================================= + +@probuilder.command("info") +@click.argument("target") +@click.option("--include", type=click.Choice(["summary", "faces", "edges", "all"]), + default="summary", help="Detail level: summary, faces, edges, or all.") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def mesh_info(target: str, include: str, search_method: Optional[str]): + """Get ProBuilder mesh info. + + \\b + Examples: + unity-mcp probuilder info "MyCube" + unity-mcp probuilder info "MyCube" --include faces + unity-mcp probuilder info "-12345" --search-method by_id --include all + """ + config = get_config() + request: dict[str, Any] = {"action": "get_mesh_info", "target": target, "include": include} + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + + +# ============================================================================= +# Smoothing +# ============================================================================= + +@probuilder.command("auto-smooth") +@click.argument("target") +@click.option("--angle", type=float, default=30.0, help="Angle threshold in degrees (default: 30).") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def auto_smooth(target: str, angle: float, search_method: Optional[str]): + """Auto-assign smoothing groups by angle threshold. + + \\b + Examples: + unity-mcp probuilder auto-smooth "MyCube" + unity-mcp probuilder auto-smooth "MyCube" --angle 45 + """ + config = get_config() + request: dict[str, Any] = { + "action": "auto_smooth", + "target": target, + "angleThreshold": angle, + } + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Auto-smoothed with angle {angle}°") + + +@probuilder.command("set-smoothing") +@click.argument("target") +@click.option("--faces", required=True, help="Face indices as JSON array, e.g. '[0,1,2]'.") +@click.option("--group", type=int, required=True, help="Smoothing group (0=hard, 1+=smooth).") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def set_smoothing(target: str, faces: str, group: int, search_method: Optional[str]): + """Set smoothing group on specific faces. + + \\b + Examples: + unity-mcp probuilder set-smoothing "MyCube" --faces '[0,1,2]' --group 1 + unity-mcp probuilder set-smoothing "MyCube" --faces '[3,4,5]' --group 0 + """ + config = get_config() + face_indices = parse_json_list_or_exit(faces, "faces") + + request: dict[str, Any] = { + "action": "set_smoothing", + "target": target, + "faceIndices": face_indices, + "smoothingGroup": group, + } + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Set smoothing group {group}") + + +# ============================================================================= +# Mesh Utilities +# ============================================================================= + +@probuilder.command("center-pivot") +@click.argument("target") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def center_pivot(target: str, search_method: Optional[str]): + """Move pivot point to mesh bounds center. + + \\b + Examples: + unity-mcp probuilder center-pivot "MyCube" + """ + config = get_config() + request: dict[str, Any] = {"action": "center_pivot", "target": target} + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Pivot centered") + + +@probuilder.command("freeze-transform") +@click.argument("target") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def freeze_transform(target: str, search_method: Optional[str]): + """Bake position/rotation/scale into vertex data, reset transform. + + \\b + Examples: + unity-mcp probuilder freeze-transform "MyCube" + """ + config = get_config() + request: dict[str, Any] = {"action": "freeze_transform", "target": target} + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Transform frozen") + + +@probuilder.command("validate") +@click.argument("target") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def validate_mesh(target: str, search_method: Optional[str]): + """Check mesh health (degenerate triangles, unused vertices). + + \\b + Examples: + unity-mcp probuilder validate "MyCube" + """ + config = get_config() + request: dict[str, Any] = {"action": "validate_mesh", "target": target} + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + + +@probuilder.command("repair") +@click.argument("target") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def repair_mesh(target: str, search_method: Optional[str]): + """Auto-fix degenerate triangles and unused vertices. + + \\b + Examples: + unity-mcp probuilder repair "MyCube" + """ + config = get_config() + request: dict[str, Any] = {"action": "repair_mesh", "target": target} + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Mesh repaired") + + +# ============================================================================= +# Raw Command (escape hatch) +# ============================================================================= + +@probuilder.command("raw") +@click.argument("action") +@click.argument("target", required=False) +@click.option("--params", "-p", default="{}", help="Additional parameters as JSON.") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def pb_raw(action: str, target: Optional[str], params: str, search_method: Optional[str]): + """Execute any ProBuilder action directly. + + \\b + Actions include: + create_shape, create_poly_shape, + extrude_faces, extrude_edges, bevel_edges, subdivide, + delete_faces, bridge_edges, connect_elements, detach_faces, + flip_normals, merge_faces, combine_meshes, + merge_vertices, split_vertices, move_vertices, + set_face_material, set_face_color, set_face_uvs, + get_mesh_info, convert_to_probuilder, + set_smoothing, auto_smooth, + center_pivot, freeze_transform, validate_mesh, repair_mesh + + \\b + Examples: + unity-mcp probuilder raw extrude_faces "MyCube" --params '{"faceIndices": [0], "distance": 1.0}' + unity-mcp probuilder raw bevel_edges "MyCube" --params '{"edgeIndices": [0,1], "amount": 0.2}' + unity-mcp probuilder raw set_face_material "MyCube" --params '{"faceIndices": [0], "materialPath": "Assets/Materials/Red.mat"}' + """ + config = get_config() + extra = parse_json_dict_or_exit(params, "params") + + request: dict[str, Any] = {"action": action} + if target: + request["target"] = target + if search_method: + request["searchMethod"] = search_method + request.update(extra) + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) diff --git a/Server/src/cli/main.py b/Server/src/cli/main.py index 83b1fe9c0..495517ba3 100644 --- a/Server/src/cli/main.py +++ b/Server/src/cli/main.py @@ -266,6 +266,7 @@ def register_optional_command(module_name: str, command_name: str) -> None: ("cli.commands.vfx", "vfx"), ("cli.commands.batch", "batch"), ("cli.commands.texture", "texture"), + ("cli.commands.probuilder", "probuilder"), ] for module_name, command_name in optional_commands: diff --git a/Server/src/services/registry/tool_registry.py b/Server/src/services/registry/tool_registry.py index 346fa7ebe..a91886095 100644 --- a/Server/src/services/registry/tool_registry.py +++ b/Server/src/services/registry/tool_registry.py @@ -22,6 +22,7 @@ "ui": "UI Toolkit (UXML, USS, UIDocument)", "scripting_ext": "ScriptableObject management", "testing": "Test runner & async test jobs", + "probuilder": "ProBuilder 3D modeling – requires com.unity.probuilder package", } DEFAULT_ENABLED_GROUPS: set[str] = {"core"} diff --git a/Server/src/services/tools/manage_material.py b/Server/src/services/tools/manage_material.py index 875eba326..1ad025b0f 100644 --- a/Server/src/services/tools/manage_material.py +++ b/Server/src/services/tools/manage_material.py @@ -55,7 +55,7 @@ async def manage_material( # assign_material_to_renderer / set_renderer_color target: Annotated[str, "Target GameObject (name, path, or find instruction)"] | None = None, - search_method: Annotated[Literal["by_name", "by_path", "by_tag", + search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"], "Search method for target"] | None = None, slot: Annotated[int, "Material slot index (0-based)"] | None = None, mode: Annotated[Literal["shared", "instance", "property_block"], diff --git a/Server/src/services/tools/manage_probuilder.py b/Server/src/services/tools/manage_probuilder.py new file mode 100644 index 000000000..54457a1d4 --- /dev/null +++ b/Server/src/services/tools/manage_probuilder.py @@ -0,0 +1,176 @@ +from typing import Annotated, Any, Literal + +from fastmcp import Context +from mcp.types import ToolAnnotations + +from services.registry import mcp_for_unity_tool +from services.tools import get_unity_instance_from_context +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry + +# All possible actions grouped by category +SHAPE_ACTIONS = [ + "create_shape", "create_poly_shape", +] + +MESH_ACTIONS = [ + "extrude_faces", "extrude_edges", "bevel_edges", "subdivide", + "delete_faces", "bridge_edges", "connect_elements", "detach_faces", + "flip_normals", "merge_faces", "combine_meshes", "merge_objects", +] + +VERTEX_ACTIONS = [ + "merge_vertices", "split_vertices", "move_vertices", +] + +UV_MATERIAL_ACTIONS = [ + "set_face_material", "set_face_color", "set_face_uvs", +] + +QUERY_ACTIONS = [ + "get_mesh_info", "convert_to_probuilder", +] + +SMOOTHING_ACTIONS = ["set_smoothing", "auto_smooth"] + +UTILITY_ACTIONS = ["center_pivot", "freeze_transform", "validate_mesh", "repair_mesh"] + +ALL_ACTIONS = ( + ["ping"] + SHAPE_ACTIONS + MESH_ACTIONS + VERTEX_ACTIONS + + UV_MATERIAL_ACTIONS + QUERY_ACTIONS + SMOOTHING_ACTIONS + UTILITY_ACTIONS +) + +_PROBUILDER_TOP_LEVEL_KEYS = {"action", "target", "searchMethod", "properties"} + + +def _normalize_probuilder_params(params: dict[str, Any]) -> dict[str, Any]: + params = dict(params) + properties: dict[str, Any] = {} + for key in list(params.keys()): + if key in _PROBUILDER_TOP_LEVEL_KEYS: + continue + properties[key] = params.pop(key) + + if properties: + existing = params.get("properties") + if isinstance(existing, dict): + params["properties"] = {**properties, **existing} + else: + params["properties"] = properties + + return {k: v for k, v in params.items() if v is not None} + + +@mcp_for_unity_tool( + group="probuilder", + description=( + "Manage ProBuilder meshes for in-editor 3D modeling. Requires com.unity.probuilder package.\n\n" + "SHAPE CREATION:\n" + "- create_shape: Create a ProBuilder primitive (shape_type: Cube/Cylinder/Sphere/Plane/Cone/" + "Torus/Pipe/Arch/Stair/CurvedStair/Door/Prism). Shape-specific params in properties " + "(size, radius, height, depth, width, segments, rows, columns, innerRadius, outerRadius, etc.).\n" + "- create_poly_shape: Create mesh from 2D polygon footprint (points: [[x,y,z],...], " + "extrudeHeight, flipNormals).\n\n" + "MESH EDITING:\n" + "- extrude_faces: Extrude faces outward (faceIndices, distance, method: FaceNormal/VertexNormal/IndividualFaces).\n" + "- extrude_edges: Extrude edges (edgeIndices, distance, asGroup).\n" + "- bevel_edges: Bevel edges (edgeIndices, amount 0-1).\n" + "- subdivide: Subdivide faces (faceIndices optional, all if omitted).\n" + "- delete_faces: Delete faces (faceIndices).\n" + "- bridge_edges: Bridge two open edges (edgeA, edgeB as {a,b} vertex index pairs).\n" + "- connect_elements: Connect edges or faces (edgeIndices or faceIndices).\n" + "- detach_faces: Detach faces to new object (faceIndices, deleteSource).\n" + "- flip_normals: Flip face normals (faceIndices).\n" + "- merge_faces: Merge faces into one (faceIndices).\n" + "- combine_meshes: Combine multiple ProBuilder objects (targets: list of GameObjects).\n" + "- merge_objects: Merge multiple objects into one ProBuilder mesh (targets list, auto-converts non-ProBuilder objects).\n\n" + "VERTEX OPERATIONS:\n" + "- merge_vertices: Merge/weld vertices (vertexIndices).\n" + "- split_vertices: Split shared vertices (vertexIndices).\n" + "- move_vertices: Translate vertices (vertexIndices, offset [x,y,z]).\n\n" + "UV & MATERIALS:\n" + "- set_face_material: Assign material to faces (faceIndices optional — all faces when omitted, materialPath).\n" + "- set_face_color: Set vertex color on faces (faceIndices optional — all faces when omitted, color [r,g,b,a]).\n" + "- set_face_uvs: Set UV auto-unwrap params (faceIndices optional — all faces when omitted, scale, offset, rotation, flipU, flipV).\n\n" + "QUERY:\n" + "- get_mesh_info: Get ProBuilder mesh details. Use include parameter to control detail level: " + "'summary' (default: counts, bounds, materials), 'faces' (+ face normals/centers/directions), " + "'edges' (+ edge vertex pairs), 'all' (everything). Each face includes direction " + "('top','bottom','front','back','left','right') for semantic selection.\n" + "- convert_to_probuilder: Convert a standard Unity mesh into ProBuilder for editing.\n\n" + "SMOOTHING:\n" + "- set_smoothing: Set smoothing group on faces (faceIndices, smoothingGroup: 0=hard, 1+=smooth).\n" + "- auto_smooth: Auto-assign smoothing groups by angle (angleThreshold: default 30).\n\n" + "MESH UTILITIES:\n" + "- center_pivot: Move pivot point to mesh bounds center.\n" + "- freeze_transform: Bake position/rotation/scale into vertex data, reset transform.\n" + "- validate_mesh: Check mesh health (degenerate triangles, unused vertices). Read-only.\n" + "- repair_mesh: Auto-fix degenerate triangles and unused vertices.\n\n" + "WORKFLOW TIP: Call get_mesh_info with include='faces' to see face normals and directions " + "before editing. Each face shows its direction ('top','bottom','front','back','left','right') " + "so you can pick the right indices for operations like extrude_faces or delete_faces." + ), + annotations=ToolAnnotations( + title="Manage ProBuilder", + destructiveHint=True, + ), +) +async def manage_probuilder( + ctx: Context, + action: Annotated[str, "Action to perform."], + target: Annotated[str | None, "Target GameObject (name/path/id)."] = None, + search_method: Annotated[ + Literal["by_id", "by_name", "by_path", "by_tag", "by_layer"] | None, + "How to find the target GameObject.", + ] = None, + properties: Annotated[ + dict[str, Any] | str | None, + "Action-specific parameters (dict or JSON string).", + ] = None, +) -> dict[str, Any]: + """Unified ProBuilder mesh management tool.""" + + action_normalized = action.lower() + + if action_normalized not in ALL_ACTIONS: + # Provide helpful category-based suggestions + categories = { + "Shape creation": SHAPE_ACTIONS, + "Mesh editing": MESH_ACTIONS, + "Vertex operations": VERTEX_ACTIONS, + "UV & materials": UV_MATERIAL_ACTIONS, + "Query": QUERY_ACTIONS, + "Smoothing": SMOOTHING_ACTIONS, + "Mesh utilities": UTILITY_ACTIONS, + } + category_list = "; ".join( + f"{cat}: {', '.join(actions)}" for cat, actions in categories.items() + ) + return { + "success": False, + "message": ( + f"Unknown action '{action}'. Available actions by category — {category_list}. " + "Run with action='ping' to test connection." + ), + } + + unity_instance = await get_unity_instance_from_context(ctx) + + params_dict: dict[str, Any] = {"action": action_normalized} + if properties is not None: + params_dict["properties"] = properties + if target is not None: + params_dict["target"] = target + if search_method is not None: + params_dict["searchMethod"] = search_method + + params_dict = {k: v for k, v in params_dict.items() if v is not None} + + result = await send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "manage_probuilder", + params_dict, + ) + + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/Server/tests/test_manage_probuilder.py b/Server/tests/test_manage_probuilder.py new file mode 100644 index 000000000..e6496c7de --- /dev/null +++ b/Server/tests/test_manage_probuilder.py @@ -0,0 +1,465 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from services.tools.manage_probuilder import ( + manage_probuilder, + ALL_ACTIONS, + SHAPE_ACTIONS, + MESH_ACTIONS, + VERTEX_ACTIONS, + UV_MATERIAL_ACTIONS, + QUERY_ACTIONS, + SMOOTHING_ACTIONS, + UTILITY_ACTIONS, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def mock_unity(monkeypatch): + """Patch Unity transport layer and return captured call dict.""" + captured: dict[str, object] = {} + + async def fake_send(send_fn, unity_instance, tool_name, params): + captured["unity_instance"] = unity_instance + captured["tool_name"] = tool_name + captured["params"] = params + return {"success": True, "message": "ok"} + + monkeypatch.setattr( + "services.tools.manage_probuilder.get_unity_instance_from_context", + AsyncMock(return_value="unity-instance-1"), + ) + monkeypatch.setattr( + "services.tools.manage_probuilder.send_with_unity_instance", + fake_send, + ) + return captured + + +# --------------------------------------------------------------------------- +# Action list completeness +# --------------------------------------------------------------------------- + +def test_all_actions_is_union_of_sub_lists(): + expected = set( + ["ping"] + SHAPE_ACTIONS + MESH_ACTIONS + VERTEX_ACTIONS + + UV_MATERIAL_ACTIONS + QUERY_ACTIONS + SMOOTHING_ACTIONS + UTILITY_ACTIONS + ) + assert set(ALL_ACTIONS) == expected + + +def test_no_duplicate_actions(): + assert len(ALL_ACTIONS) == len(set(ALL_ACTIONS)) + + +# --------------------------------------------------------------------------- +# Invalid / missing action +# --------------------------------------------------------------------------- + +def test_unknown_action_returns_error(mock_unity): + result = asyncio.run( + manage_probuilder(SimpleNamespace(), action="nonexistent_action") + ) + assert result["success"] is False + assert "Unknown action" in result["message"] + assert "tool_name" not in mock_unity # Should NOT call Unity + + +def test_empty_action_returns_error(mock_unity): + result = asyncio.run( + manage_probuilder(SimpleNamespace(), action="") + ) + assert result["success"] is False + + +# --------------------------------------------------------------------------- +# Shape creation +# --------------------------------------------------------------------------- + +def test_create_shape_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="create_shape", + properties={"shapeType": "Cube", "size": [2, 2, 2]}, + ) + ) + assert result["success"] is True + assert mock_unity["tool_name"] == "manage_probuilder" + assert mock_unity["params"]["action"] == "create_shape" + assert mock_unity["params"]["properties"]["shapeType"] == "Cube" + + +def test_create_shape_with_target(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="create_shape", + target="MyParent", + properties={"shapeType": "Torus"}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["target"] == "MyParent" + + +def test_create_poly_shape_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="create_poly_shape", + properties={ + "points": [[0, 0, 0], [5, 0, 0], [5, 0, 5], [0, 0, 5]], + "extrudeHeight": 3.0, + }, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "create_poly_shape" + assert mock_unity["params"]["properties"]["extrudeHeight"] == 3.0 + + +# --------------------------------------------------------------------------- +# Mesh editing +# --------------------------------------------------------------------------- + +def test_extrude_faces_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="extrude_faces", + target="MyCube", + properties={"faceIndices": [0, 1], "distance": 1.5}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "extrude_faces" + assert mock_unity["params"]["target"] == "MyCube" + assert mock_unity["params"]["properties"]["faceIndices"] == [0, 1] + assert mock_unity["params"]["properties"]["distance"] == 1.5 + + +def test_bevel_edges_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="bevel_edges", + target="MyCube", + properties={"edgeIndices": [0, 2], "amount": 0.2}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "bevel_edges" + assert mock_unity["params"]["properties"]["amount"] == 0.2 + + +def test_delete_faces_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="delete_faces", + target="MyCube", + properties={"faceIndices": [3]}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "delete_faces" + + +def test_subdivide_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="subdivide", + target="MyCube", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "subdivide" + + +def test_combine_meshes_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="combine_meshes", + properties={"targets": ["Cube1", "Cube2"]}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "combine_meshes" + + +# --------------------------------------------------------------------------- +# Vertex operations +# --------------------------------------------------------------------------- + +def test_move_vertices_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="move_vertices", + target="MyCube", + properties={"vertexIndices": [0, 1, 2], "offset": [0, 1, 0]}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "move_vertices" + assert mock_unity["params"]["properties"]["offset"] == [0, 1, 0] + + +# --------------------------------------------------------------------------- +# UV & materials +# --------------------------------------------------------------------------- + +def test_set_face_material_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="set_face_material", + target="MyCube", + properties={"faceIndices": [0], "materialPath": "Assets/Materials/Red.mat"}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "set_face_material" + assert mock_unity["params"]["properties"]["materialPath"] == "Assets/Materials/Red.mat" + + +def test_set_face_uvs_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="set_face_uvs", + target="MyCube", + properties={"faceIndices": [0, 1], "scale": [2, 2], "rotation": 45}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "set_face_uvs" + + +# --------------------------------------------------------------------------- +# Query +# --------------------------------------------------------------------------- + +def test_get_mesh_info_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="get_mesh_info", + target="MyCube", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "get_mesh_info" + assert mock_unity["params"]["target"] == "MyCube" + + +def test_convert_to_probuilder_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="convert_to_probuilder", + target="StandardMesh", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "convert_to_probuilder" + + +# --------------------------------------------------------------------------- +# Search method passthrough +# --------------------------------------------------------------------------- + +def test_search_method_passed_through(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="get_mesh_info", + target="-12345", + search_method="by_id", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["searchMethod"] == "by_id" + + +# --------------------------------------------------------------------------- +# Ping +# --------------------------------------------------------------------------- + +def test_ping_sends_to_unity(mock_unity): + result = asyncio.run( + manage_probuilder(SimpleNamespace(), action="ping") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "ping" + + +# --------------------------------------------------------------------------- +# All actions are lowercase-normalized +# --------------------------------------------------------------------------- + +def test_action_case_insensitive(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="Create_Shape", + properties={"shapeType": "Cube"}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "create_shape" + + +# --------------------------------------------------------------------------- +# Non-dict result from Unity +# --------------------------------------------------------------------------- + +def test_non_dict_result_wrapped(monkeypatch): + async def fake_send(send_fn, unity_instance, tool_name, params): + return "unexpected string result" + + monkeypatch.setattr( + "services.tools.manage_probuilder.get_unity_instance_from_context", + AsyncMock(return_value="unity-instance-1"), + ) + monkeypatch.setattr( + "services.tools.manage_probuilder.send_with_unity_instance", + fake_send, + ) + + result = asyncio.run( + manage_probuilder(SimpleNamespace(), action="ping") + ) + assert result["success"] is False + assert "unexpected string result" in result["message"] + + +# --------------------------------------------------------------------------- +# New action categories +# --------------------------------------------------------------------------- + +def test_smoothing_actions_in_all(): + for action in SMOOTHING_ACTIONS: + assert action in ALL_ACTIONS, f"{action} should be in ALL_ACTIONS" + + +def test_utility_actions_in_all(): + for action in UTILITY_ACTIONS: + assert action in ALL_ACTIONS, f"{action} should be in ALL_ACTIONS" + + +# --------------------------------------------------------------------------- +# Smoothing actions +# --------------------------------------------------------------------------- + +def test_auto_smooth_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="auto_smooth", + target="MyCube", + properties={"angleThreshold": 45}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "auto_smooth" + assert mock_unity["params"]["target"] == "MyCube" + assert mock_unity["params"]["properties"]["angleThreshold"] == 45 + + +def test_set_smoothing_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="set_smoothing", + target="MyCube", + properties={"faceIndices": [0, 1, 2], "smoothingGroup": 1}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "set_smoothing" + assert mock_unity["params"]["properties"]["faceIndices"] == [0, 1, 2] + assert mock_unity["params"]["properties"]["smoothingGroup"] == 1 + + +# --------------------------------------------------------------------------- +# Mesh utility actions +# --------------------------------------------------------------------------- + +def test_center_pivot_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="center_pivot", + target="MyCube", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "center_pivot" + assert mock_unity["params"]["target"] == "MyCube" + + +def test_freeze_transform_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="freeze_transform", + target="MyCube", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "freeze_transform" + + +def test_validate_mesh_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="validate_mesh", + target="MyCube", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "validate_mesh" + + +def test_repair_mesh_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="repair_mesh", + target="MyCube", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "repair_mesh" + + +# --------------------------------------------------------------------------- +# get_mesh_info include parameter passthrough +# --------------------------------------------------------------------------- + +def test_get_mesh_info_include_param_passthrough(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="get_mesh_info", + target="MyCube", + properties={"include": "faces"}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "get_mesh_info" + assert mock_unity["params"]["properties"]["include"] == "faces" diff --git a/Server/uv.lock b/Server/uv.lock index 6d8807693..859814a58 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -123,14 +123,24 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db wheels = [ { url = "https://files.pythonhosted.org/packages/6a/80/ea4ead0c5d52a9828692e7df20f0eafe8d26e671ce4883a0a146bb91049e/caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619", size = 36836, upload-time = "2025-12-26T15:22:04.662Z" }, { url = "https://files.pythonhosted.org/packages/17/b9/36715c97c873649d1029001578f901b50250916295e3dddf20c865438865/caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241", size = 79695, upload-time = "2025-12-26T15:22:18.818Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/07080ecb1adb55a02cbd8ec0126aa8e43af343ffabb6a71125b42670e9a1/caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b", size = 79457, upload-time = "2026-03-04T22:08:16.024Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/dd55757bb671eb4c376e006c04e83beb413486821f517792ea603ef216e9/caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae", size = 77705, upload-time = "2026-03-04T22:08:17.202Z" }, { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] @@ -872,7 +882,7 @@ dev = [ requires-dist = [ { name = "click", specifier = ">=8.1.0" }, { name = "fastapi", specifier = ">=0.104.0" }, - { name = "fastmcp", specifier = ">=3.0.0,<4" }, + { name = "fastmcp", specifier = ">=3.0.2,<4" }, { name = "httpx", specifier = ">=0.27.2" }, { name = "mcp", specifier = ">=1.16.0" }, { name = "pydantic", specifier = ">=2.12.5" }, diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProBuilderTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProBuilderTests.cs new file mode 100644 index 000000000..f8e140e06 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProBuilderTests.cs @@ -0,0 +1,860 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Tools.ProBuilder; +using static MCPForUnityTests.Editor.TestUtilities; + +namespace MCPForUnityTests.Editor.Tools +{ + public class ManageProBuilderTests + { + private readonly List _createdObjects = new List(); + private bool _proBuilderInstalled; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + _proBuilderInstalled = Type.GetType( + "UnityEngine.ProBuilder.ProBuilderMesh, Unity.ProBuilder" + ) != null; + } + + [TearDown] + public void TearDown() + { + foreach (var go in _createdObjects) + { + if (go != null) + UnityEngine.Object.DestroyImmediate(go); + } + _createdObjects.Clear(); + } + + // ===================================================================== + // Basic action validation (works regardless of ProBuilder installation) + // ===================================================================== + + [Test] + public void HandleCommand_MissingAction_ReturnsError() + { + var paramsObj = new JObject(); + var result = ToJObject(ManageProBuilder.HandleCommand(paramsObj)); + + Assert.IsFalse(result.Value("success"), result.ToString()); + } + + [Test] + public void HandleCommand_UnknownAction_ReturnsError() + { + var paramsObj = new JObject { ["action"] = "nonexistent_action" }; + var result = ToJObject(ManageProBuilder.HandleCommand(paramsObj)); + + Assert.IsFalse(result.Value("success"), result.ToString()); + Assert.That(result["error"]?.ToString() ?? result["message"]?.ToString(), + Does.Contain("Unknown action")); + } + + [Test] + public void HandleCommand_Ping_ReturnsSuccess() + { + var paramsObj = new JObject { ["action"] = "ping" }; + var result = ToJObject(ManageProBuilder.HandleCommand(paramsObj)); + + if (!_proBuilderInstalled) + { + // Without ProBuilder, should return error about missing package + Assert.IsFalse(result.Value("success"), result.ToString()); + Assert.That(result["error"]?.ToString(), + Does.Contain("ProBuilder").IgnoreCase); + return; + } + + Assert.IsTrue(result.Value("success"), result.ToString()); + } + + // ===================================================================== + // Shape creation (requires ProBuilder) + // ===================================================================== + + [Test] + public void CreateShape_MissingShapeType_ReturnsError() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var paramsObj = new JObject + { + ["action"] = "create_shape", + }; + var result = ToJObject(ManageProBuilder.HandleCommand(paramsObj)); + Assert.IsFalse(result.Value("success"), result.ToString()); + } + + [Test] + public void CreateShape_InvalidShapeType_ReturnsError() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var paramsObj = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject { ["shapeType"] = "InvalidShape" }, + }; + var result = ToJObject(ManageProBuilder.HandleCommand(paramsObj)); + Assert.IsFalse(result.Value("success"), result.ToString()); + } + + [Test] + public void CreateShape_Cube_CreatesGameObject() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var paramsObj = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestCube", + }, + }; + var result = ToJObject(ManageProBuilder.HandleCommand(paramsObj)); + Assert.IsTrue(result.Value("success"), result.ToString()); + + var data = result["data"] as JObject; + Assert.IsNotNull(data, "Result should contain data"); + Assert.AreEqual("PBTestCube", data.Value("gameObjectName")); + Assert.Greater(data.Value("faceCount"), 0); + Assert.Greater(data.Value("vertexCount"), 0); + + // Track for cleanup + var go = GameObject.Find("PBTestCube"); + if (go != null) _createdObjects.Add(go); + } + + [Test] + public void CreateShape_WithPosition_SetsTransform() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var paramsObj = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestCubePos", + ["position"] = new JArray(5f, 10f, 15f), + }, + }; + var result = ToJObject(ManageProBuilder.HandleCommand(paramsObj)); + Assert.IsTrue(result.Value("success"), result.ToString()); + + var go = GameObject.Find("PBTestCubePos"); + Assert.IsNotNull(go, "Created GameObject should exist"); + Assert.AreEqual(new Vector3(5f, 10f, 15f), go.transform.position); + + _createdObjects.Add(go); + } + + [Test] + public void CreatePolyShape_CreatesFromPoints() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var paramsObj = new JObject + { + ["action"] = "create_poly_shape", + ["properties"] = new JObject + { + ["points"] = new JArray( + new JArray(0f, 0f, 0f), + new JArray(5f, 0f, 0f), + new JArray(5f, 0f, 5f), + new JArray(0f, 0f, 5f) + ), + ["extrudeHeight"] = 3f, + ["name"] = "PBTestPoly", + }, + }; + var result = ToJObject(ManageProBuilder.HandleCommand(paramsObj)); + Assert.IsTrue(result.Value("success"), result.ToString()); + + var go = GameObject.Find("PBTestPoly"); + if (go != null) _createdObjects.Add(go); + } + + // ===================================================================== + // Mesh editing (requires ProBuilder + created shape) + // ===================================================================== + + [Test] + public void GetMeshInfo_ReturnsDetails() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + // First create a shape + var createParams = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestInfoCube", + }, + }; + var createResult = ToJObject(ManageProBuilder.HandleCommand(createParams)); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var go = GameObject.Find("PBTestInfoCube"); + Assert.IsNotNull(go); + _createdObjects.Add(go); + + // Now get mesh info + var infoParams = new JObject + { + ["action"] = "get_mesh_info", + ["target"] = "PBTestInfoCube", + }; + var result = ToJObject(ManageProBuilder.HandleCommand(infoParams)); + Assert.IsTrue(result.Value("success"), result.ToString()); + + var data = result["data"] as JObject; + Assert.IsNotNull(data); + Assert.Greater(data.Value("faceCount"), 0); + Assert.Greater(data.Value("vertexCount"), 0); + Assert.IsNotNull(data["bounds"]); + Assert.IsNotNull(data["faces"]); + } + + [Test] + public void ExtrudeFaces_IncreaseFaceCount() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + // Create a cube + var createParams = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestExtrudeCube", + }, + }; + var createResult = ToJObject(ManageProBuilder.HandleCommand(createParams)); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var go = GameObject.Find("PBTestExtrudeCube"); + _createdObjects.Add(go); + + int initialFaceCount = createResult["data"].Value("faceCount"); + + // Extrude face 0 + var extrudeParams = new JObject + { + ["action"] = "extrude_faces", + ["target"] = "PBTestExtrudeCube", + ["properties"] = new JObject + { + ["faceIndices"] = new JArray(0), + ["distance"] = 1.0f, + }, + }; + var result = ToJObject(ManageProBuilder.HandleCommand(extrudeParams)); + Assert.IsTrue(result.Value("success"), result.ToString()); + + int newFaceCount = result["data"].Value("faceCount"); + Assert.Greater(newFaceCount, initialFaceCount, + "Face count should increase after extrusion"); + } + + [Test] + public void DeleteFaces_DecreasesFaceCount() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var createParams = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestDeleteCube", + }, + }; + var createResult = ToJObject(ManageProBuilder.HandleCommand(createParams)); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var go = GameObject.Find("PBTestDeleteCube"); + _createdObjects.Add(go); + + int initialFaceCount = createResult["data"].Value("faceCount"); + + var deleteParams = new JObject + { + ["action"] = "delete_faces", + ["target"] = "PBTestDeleteCube", + ["properties"] = new JObject + { + ["faceIndices"] = new JArray(0), + }, + }; + var result = ToJObject(ManageProBuilder.HandleCommand(deleteParams)); + Assert.IsTrue(result.Value("success"), result.ToString()); + + int newFaceCount = result["data"].Value("faceCount"); + Assert.Less(newFaceCount, initialFaceCount, + "Face count should decrease after deletion"); + } + + [Test] + public void SetFaceMaterial_WithMissingTarget_ReturnsError() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var paramsObj = new JObject + { + ["action"] = "set_face_material", + ["target"] = "NonExistentObject999", + ["properties"] = new JObject + { + ["faceIndices"] = new JArray(0), + ["materialPath"] = "Assets/Materials/Test.mat", + }, + }; + var result = ToJObject(ManageProBuilder.HandleCommand(paramsObj)); + Assert.IsFalse(result.Value("success"), result.ToString()); + } + + [Test] + public void FlipNormals_SucceedsOnValidMesh() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var createParams = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestFlipCube", + }, + }; + var createResult = ToJObject(ManageProBuilder.HandleCommand(createParams)); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var go = GameObject.Find("PBTestFlipCube"); + _createdObjects.Add(go); + + var flipParams = new JObject + { + ["action"] = "flip_normals", + ["target"] = "PBTestFlipCube", + ["properties"] = new JObject + { + ["faceIndices"] = new JArray(0, 1), + }, + }; + var result = ToJObject(ManageProBuilder.HandleCommand(flipParams)); + Assert.IsTrue(result.Value("success"), result.ToString()); + } + + // ===================================================================== + // Enhanced get_mesh_info with include parameter + // ===================================================================== + + [Test] + public void GetMeshInfo_DefaultInclude_ReturnsSummaryOnly() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var createParams = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestSummaryCube", + }, + }; + var createResult = ToJObject(ManageProBuilder.HandleCommand(createParams)); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var go = GameObject.Find("PBTestSummaryCube"); + Assert.IsNotNull(go); + _createdObjects.Add(go); + + var infoParams = new JObject + { + ["action"] = "get_mesh_info", + ["target"] = "PBTestSummaryCube", + }; + var result = ToJObject(ManageProBuilder.HandleCommand(infoParams)); + Assert.IsTrue(result.Value("success"), result.ToString()); + + var data = result["data"] as JObject; + Assert.IsNotNull(data); + Assert.Greater(data.Value("faceCount"), 0); + // Default "summary" should NOT include faces array + Assert.IsNull(data["faces"], "Summary mode should not include faces array"); + } + + [Test] + public void GetMeshInfo_IncludeFaces_ReturnsFaceNormalsAndDirections() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var createParams = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestFacesCube", + }, + }; + var createResult = ToJObject(ManageProBuilder.HandleCommand(createParams)); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var go = GameObject.Find("PBTestFacesCube"); + Assert.IsNotNull(go); + _createdObjects.Add(go); + + var infoParams = new JObject + { + ["action"] = "get_mesh_info", + ["target"] = "PBTestFacesCube", + ["properties"] = new JObject { ["include"] = "faces" }, + }; + var result = ToJObject(ManageProBuilder.HandleCommand(infoParams)); + Assert.IsTrue(result.Value("success"), result.ToString()); + + var data = result["data"] as JObject; + Assert.IsNotNull(data); + var faces = data["faces"] as JArray; + Assert.IsNotNull(faces, "Faces mode should include faces array"); + Assert.Greater(faces.Count, 0); + + // Each face should have normal, center, direction + var firstFace = faces[0] as JObject; + Assert.IsNotNull(firstFace); + Assert.IsNotNull(firstFace["normal"], "Face should have normal"); + Assert.IsNotNull(firstFace["center"], "Face should have center"); + // direction may be null for angled faces, but should exist as key + Assert.IsTrue(firstFace.ContainsKey("direction"), "Face should have direction key"); + } + + [Test] + public void GetMeshInfo_IncludeEdges_ReturnsEdgeData() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var createParams = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestEdgesCube", + }, + }; + var createResult = ToJObject(ManageProBuilder.HandleCommand(createParams)); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var go = GameObject.Find("PBTestEdgesCube"); + Assert.IsNotNull(go); + _createdObjects.Add(go); + + var infoParams = new JObject + { + ["action"] = "get_mesh_info", + ["target"] = "PBTestEdgesCube", + ["properties"] = new JObject { ["include"] = "edges" }, + }; + var result = ToJObject(ManageProBuilder.HandleCommand(infoParams)); + Assert.IsTrue(result.Value("success"), result.ToString()); + + var data = result["data"] as JObject; + Assert.IsNotNull(data); + var edges = data["edges"] as JArray; + Assert.IsNotNull(edges, "Edges mode should include edges array"); + Assert.Greater(edges.Count, 0); + + var firstEdge = edges[0] as JObject; + Assert.IsNotNull(firstEdge); + Assert.IsTrue(firstEdge.ContainsKey("vertexA"), "Edge should have vertexA"); + Assert.IsTrue(firstEdge.ContainsKey("vertexB"), "Edge should have vertexB"); + } + + [Test] + public void GetMeshInfo_CubeTopFace_HasUpNormal() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var createParams = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestTopNormalCube", + }, + }; + var createResult = ToJObject(ManageProBuilder.HandleCommand(createParams)); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var go = GameObject.Find("PBTestTopNormalCube"); + Assert.IsNotNull(go); + _createdObjects.Add(go); + + var infoParams = new JObject + { + ["action"] = "get_mesh_info", + ["target"] = "PBTestTopNormalCube", + ["properties"] = new JObject { ["include"] = "faces" }, + }; + var result = ToJObject(ManageProBuilder.HandleCommand(infoParams)); + Assert.IsTrue(result.Value("success"), result.ToString()); + + var faces = result["data"]["faces"] as JArray; + Assert.IsNotNull(faces); + + // At least one face should have direction "top" + bool hasTop = false; + foreach (JObject face in faces) + { + if (face["direction"]?.ToString() == "top") + { + hasTop = true; + break; + } + } + Assert.IsTrue(hasTop, "A cube should have at least one face with direction 'top'"); + } + + // ===================================================================== + // Smoothing + // ===================================================================== + + [Test] + public void AutoSmooth_DefaultAngle_AssignsGroups() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var createParams = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestAutoSmoothCube", + }, + }; + var createResult = ToJObject(ManageProBuilder.HandleCommand(createParams)); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var go = GameObject.Find("PBTestAutoSmoothCube"); + Assert.IsNotNull(go); + _createdObjects.Add(go); + + var smoothParams = new JObject + { + ["action"] = "auto_smooth", + ["target"] = "PBTestAutoSmoothCube", + ["properties"] = new JObject { ["angleThreshold"] = 30 }, + }; + var result = ToJObject(ManageProBuilder.HandleCommand(smoothParams)); + Assert.IsTrue(result.Value("success"), result.ToString()); + } + + [Test] + public void SetSmoothing_OnSpecificFaces_SetsGroup() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var createParams = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestSetSmoothCube", + }, + }; + var createResult = ToJObject(ManageProBuilder.HandleCommand(createParams)); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var go = GameObject.Find("PBTestSetSmoothCube"); + Assert.IsNotNull(go); + _createdObjects.Add(go); + + var smoothParams = new JObject + { + ["action"] = "set_smoothing", + ["target"] = "PBTestSetSmoothCube", + ["properties"] = new JObject + { + ["faceIndices"] = new JArray(0, 1), + ["smoothingGroup"] = 1, + }, + }; + var result = ToJObject(ManageProBuilder.HandleCommand(smoothParams)); + Assert.IsTrue(result.Value("success"), result.ToString()); + + var data = result["data"] as JObject; + Assert.IsNotNull(data); + Assert.AreEqual(2, data.Value("facesModified")); + } + + // ===================================================================== + // Mesh Utilities + // ===================================================================== + + [Test] + public void CenterPivot_MovesPivotToCenter() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var createParams = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestCenterPivot", + }, + }; + var createResult = ToJObject(ManageProBuilder.HandleCommand(createParams)); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var go = GameObject.Find("PBTestCenterPivot"); + Assert.IsNotNull(go); + _createdObjects.Add(go); + + var pivotParams = new JObject + { + ["action"] = "center_pivot", + ["target"] = "PBTestCenterPivot", + }; + var result = ToJObject(ManageProBuilder.HandleCommand(pivotParams)); + Assert.IsTrue(result.Value("success"), result.ToString()); + } + + [Test] + public void FreezeTransform_ResetsTransformKeepsShape() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var createParams = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestFreeze", + ["position"] = new JArray(5f, 3f, 2f), + }, + }; + var createResult = ToJObject(ManageProBuilder.HandleCommand(createParams)); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var go = GameObject.Find("PBTestFreeze"); + Assert.IsNotNull(go); + _createdObjects.Add(go); + + var freezeParams = new JObject + { + ["action"] = "freeze_transform", + ["target"] = "PBTestFreeze", + }; + var result = ToJObject(ManageProBuilder.HandleCommand(freezeParams)); + Assert.IsTrue(result.Value("success"), result.ToString()); + + // Transform should be reset to identity + Assert.AreEqual(Vector3.zero, go.transform.position); + Assert.AreEqual(Quaternion.identity, go.transform.rotation); + Assert.AreEqual(Vector3.one, go.transform.localScale); + } + + [Test] + public void ValidateMesh_CleanMesh_ReturnsNoIssues() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var createParams = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestValidate", + }, + }; + var createResult = ToJObject(ManageProBuilder.HandleCommand(createParams)); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var go = GameObject.Find("PBTestValidate"); + Assert.IsNotNull(go); + _createdObjects.Add(go); + + var validateParams = new JObject + { + ["action"] = "validate_mesh", + ["target"] = "PBTestValidate", + }; + var result = ToJObject(ManageProBuilder.HandleCommand(validateParams)); + Assert.IsTrue(result.Value("success"), result.ToString()); + + var data = result["data"] as JObject; + Assert.IsNotNull(data); + Assert.IsTrue(data.Value("healthy"), "Fresh cube should be healthy"); + Assert.AreEqual(0, data.Value("degenerateTriangles")); + } + + [Test] + public void RepairMesh_OnCleanMesh_ReportsNoChanges() + { + if (!_proBuilderInstalled) + { + Assert.Pass("ProBuilder not installed - skipping."); + return; + } + + var createParams = new JObject + { + ["action"] = "create_shape", + ["properties"] = new JObject + { + ["shapeType"] = "Cube", + ["name"] = "PBTestRepair", + }, + }; + var createResult = ToJObject(ManageProBuilder.HandleCommand(createParams)); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var go = GameObject.Find("PBTestRepair"); + Assert.IsNotNull(go); + _createdObjects.Add(go); + + var repairParams = new JObject + { + ["action"] = "repair_mesh", + ["target"] = "PBTestRepair", + }; + var result = ToJObject(ManageProBuilder.HandleCommand(repairParams)); + Assert.IsTrue(result.Value("success"), result.ToString()); + + var data = result["data"] as JObject; + Assert.IsNotNull(data); + Assert.AreEqual(0, data.Value("degenerateTrianglesRemoved")); + } + + // ===================================================================== + // ProBuilder not installed fallback + // ===================================================================== + + [Test] + public void AllActions_WithoutProBuilder_ReturnPackageError() + { + if (_proBuilderInstalled) + { + Assert.Pass("ProBuilder IS installed - this test verifies the not-installed path."); + return; + } + + string[] testActions = { + "ping", "create_shape", "get_mesh_info", "extrude_faces", + "auto_smooth", "set_smoothing", "center_pivot", "validate_mesh", + }; + foreach (var action in testActions) + { + var paramsObj = new JObject { ["action"] = action }; + var result = ToJObject(ManageProBuilder.HandleCommand(paramsObj)); + Assert.IsFalse(result.Value("success"), + $"Action '{action}' should fail without ProBuilder: {result}"); + Assert.That(result["error"]?.ToString(), + Does.Contain("ProBuilder").IgnoreCase, + $"Error for '{action}' should mention ProBuilder"); + } + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProBuilderTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProBuilderTests.cs.meta new file mode 100644 index 000000000..e8d04bc9e --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProBuilderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f2a1efebc27c48258b6ce356fed59a35 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/guides/CLI_USAGE.md b/docs/guides/CLI_USAGE.md index 8f5c17988..7e8a2852a 100644 --- a/docs/guides/CLI_USAGE.md +++ b/docs/guides/CLI_USAGE.md @@ -266,6 +266,28 @@ unity-mcp vfx trail set-time "Trail" 2.0 unity-mcp vfx raw particle_set_main "Fire" --params '{"duration": 5}' ``` +### ProBuilder Operations + +Note: Requires com.unity.probuilder package installed in your Unity project. + +```bash +# Create shapes +unity-mcp probuilder create-shape Cube +unity-mcp probuilder create-shape Torus --name "MyTorus" --params '{"rows": 16, "columns": 16}' +unity-mcp probuilder create-shape Stair --position 0 0 5 --params '{"steps": 10}' + +# Create from polygon footprint +unity-mcp probuilder create-poly --points "[[0,0,0],[5,0,0],[5,0,5],[0,0,5]]" --height 3 + +# Get mesh info +unity-mcp probuilder info "MyCube" + +# Raw ProBuilder actions (access all 21 actions) +unity-mcp probuilder raw extrude_faces "MyCube" --params '{"faceIndices": [0], "distance": 1.0}' +unity-mcp probuilder raw bevel_edges "MyCube" --params '{"edgeIndices": [0,1], "amount": 0.2}' +unity-mcp probuilder raw set_face_material "MyCube" --params '{"faceIndices": [0], "materialPath": "Assets/Materials/Red.mat"}' +``` + ### Batch Operations ```bash @@ -388,6 +410,7 @@ unity-mcp raw read_console '{"count": 20}' | `vfx line` | `info`, `set-positions`, `create-line`, `create-circle`, `clear` | | `vfx trail` | `info`, `set-time`, `clear` | | `vfx` | `raw` (access all 60+ actions) | +| `probuilder` | `create-shape`, `create-poly`, `info`, `raw` (access all 21 actions) | | `batch` | `run`, `inline`, `template` | | `animation` | `play`, `set-parameter` | | `audio` | `play`, `stop`, `volume` | From 64ac4bb1ce97915e4f754c65418cadc9543905f2 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:09:40 -0500 Subject: [PATCH 2/8] Bug fix and doc update --- .claude/skills/unity-mcp-skill/SKILL.md | 1 + .../references/tools-reference.md | 54 +- .../unity-mcp-skill/references/workflows.md | 83 + MCPForUnity/Editor/Tools/ManageScene.cs | 26 +- .../Tools/ProBuilder/ManageProBuilder.cs | 1505 ++++++++++++----- .../Tools/ProBuilder/ProBuilderMeshUtils.cs | 84 +- Server/src/cli/commands/probuilder.py | 397 ++++- .../src/services/tools/manage_probuilder.py | 39 +- Server/tests/test_manage_probuilder.py | 209 ++- unity-mcp-skill/SKILL.md | 70 +- .../references/probuilder-guide.md | 444 +++++ unity-mcp-skill/references/tools-reference.md | 116 ++ unity-mcp-skill/references/workflows.md | 83 + 13 files changed, 2583 insertions(+), 528 deletions(-) create mode 100644 unity-mcp-skill/references/probuilder-guide.md diff --git a/.claude/skills/unity-mcp-skill/SKILL.md b/.claude/skills/unity-mcp-skill/SKILL.md index 5b7fb421a..04942a0f8 100644 --- a/.claude/skills/unity-mcp-skill/SKILL.md +++ b/.claude/skills/unity-mcp-skill/SKILL.md @@ -158,6 +158,7 @@ uri="file:///full/path/to/file.cs" | **Editor** | `manage_editor`, `execute_menu_item`, `read_console` | Editor control | | **Testing** | `run_tests`, `get_test_job` | Unity Test Framework | | **Batch** | `batch_execute` | Parallel/bulk operations | +| **ProBuilder** | `manage_probuilder` | 3D modeling, mesh editing, complex geometry. **When `com.unity.probuilder` is installed, prefer ProBuilder shapes over primitive GameObjects** for editable geometry, multi-material faces, or complex shapes. Supports 13 shape types, face/edge/vertex editing, smoothing, and per-face materials. See [ProBuilder Guide](references/probuilder-guide.md). | | **UI** | `manage_ui`, `batch_execute` with `manage_gameobject` + `manage_components` | **UI Toolkit**: Use `manage_ui` to create UXML/USS files, attach UIDocument, inspect visual trees. **uGUI (Canvas)**: Use `batch_execute` for Canvas, Panel, Button, Text, Slider, Toggle, Input Field. **Read `mcpforunity://project/info` first** to detect uGUI/TMP/Input System/UI Toolkit availability. (see [UI workflows](references/workflows.md#ui-creation-workflows)) | ## Common Workflows diff --git a/.claude/skills/unity-mcp-skill/references/tools-reference.md b/.claude/skills/unity-mcp-skill/references/tools-reference.md index cdd644bbc..22ce1f71f 100644 --- a/.claude/skills/unity-mcp-skill/references/tools-reference.md +++ b/.claude/skills/unity-mcp-skill/references/tools-reference.md @@ -797,7 +797,7 @@ Discover available custom tools via `mcpforunity://custom-tools` resource. ### manage_probuilder -Unified tool for ProBuilder mesh operations. Requires `com.unity.probuilder` package. +Unified tool for ProBuilder mesh operations. Requires `com.unity.probuilder` package. When available, **prefer ProBuilder over primitive GameObjects** for editable geometry, multi-material faces, or complex shapes. **Parameters:** @@ -811,26 +811,35 @@ Unified tool for ProBuilder mesh operations. Requires `com.unity.probuilder` pac **Actions by category:** **Shape Creation:** -- `create_shape` — Create ProBuilder primitive (shape_type, size, position, rotation, name) +- `create_shape` — Create ProBuilder primitive (shape_type, size, position, rotation, name). 13 types: Cube, Cylinder, Sphere, Plane, Cone, Torus, Pipe, Arch, Stair, CurvedStair, Door, Prism - `create_poly_shape` — Create from 2D polygon footprint (points, extrudeHeight, flipNormals) **Mesh Editing:** -- `extrude_faces` — Extrude faces (faceIndices, distance, method) -- `extrude_edges` — Extrude edges (edgeIndices, distance, asGroup) -- `bevel_edges` — Bevel edges (edgeIndices, amount 0-1) -- `subdivide` — Subdivide faces (faceIndices optional) +- `extrude_faces` — Extrude faces (faceIndices, distance, method: FaceNormal/VertexNormal/IndividualFaces) +- `extrude_edges` — Extrude edges (edgeIndices or edges [{a,b},...], distance, asGroup) +- `bevel_edges` — Bevel edges (edgeIndices or edges [{a,b},...], amount 0-1) +- `subdivide` — Subdivide faces via ConnectElements (faceIndices optional) - `delete_faces` — Delete faces (faceIndices) -- `bridge_edges` — Bridge two open edges (edgeA, edgeB as {a,b} pairs) +- `bridge_edges` — Bridge two open edges (edgeA, edgeB as {a,b} pairs, allowNonManifold) - `connect_elements` — Connect edges/faces (edgeIndices or faceIndices) -- `detach_faces` — Detach faces to new object (faceIndices, deleteSource) +- `detach_faces` — Detach faces to new object (faceIndices, deleteSourceFaces) - `flip_normals` — Flip face normals (faceIndices) - `merge_faces` — Merge faces into one (faceIndices) - `combine_meshes` — Combine ProBuilder objects (targets list) +- `merge_objects` — Merge objects with auto-convert (targets, name) +- `duplicate_and_flip` — Create double-sided geometry (faceIndices) +- `create_polygon` — Connect existing vertices into a new face (vertexIndices, unordered) **Vertex Operations:** -- `merge_vertices` — Merge/weld vertices (vertexIndices) +- `merge_vertices` — Collapse vertices to single point (vertexIndices, collapseToFirst) +- `weld_vertices` — Weld vertices within proximity radius (vertexIndices, radius) - `split_vertices` — Split shared vertices (vertexIndices) - `move_vertices` — Translate vertices (vertexIndices, offset [x,y,z]) +- `insert_vertex` — Insert vertex on edge or face (edge {a,b} or faceIndex + point [x,y,z]) +- `append_vertices_to_edge` — Insert evenly-spaced points on edges (edgeIndices or edges, count) + +**Selection:** +- `select_faces` — Select faces by criteria (direction + tolerance, growFrom + growAngle) **UV & Materials:** - `set_face_material` — Assign material to faces (faceIndices, materialPath) @@ -840,10 +849,10 @@ Unified tool for ProBuilder mesh operations. Requires `com.unity.probuilder` pac **Query:** - `get_mesh_info` — Get mesh details with `include` parameter: - `"summary"` (default): counts, bounds, materials - - `"faces"`: + face normals, centers, and direction labels - - `"edges"`: + edge vertex pairs (capped at 200) + - `"faces"`: + face normals, centers, and direction labels (capped at 100) + - `"edges"`: + edge vertex pairs with world positions (capped at 200, deduplicated) - `"all"`: everything -- `convert_to_probuilder` — Convert standard mesh to ProBuilder +- `ping` — Check if ProBuilder is available **Smoothing:** - `set_smoothing` — Set smoothing group on faces (faceIndices, smoothingGroup: 0=hard, 1+=smooth) @@ -855,9 +864,16 @@ Unified tool for ProBuilder mesh operations. Requires `com.unity.probuilder` pac - `validate_mesh` — Check mesh health (read-only diagnostics) - `repair_mesh` — Auto-fix degenerate triangles +**Not Yet Working (known bugs):** +- `set_pivot` — Vertex positions don't persist through mesh rebuild. Use `center_pivot` or Transform positioning instead. +- `convert_to_probuilder` — MeshImporter throws internally. Create shapes natively instead. + **Examples:** ```python +# Check availability +manage_probuilder(action="ping") + # Create a cube manage_probuilder(action="create_shape", properties={"shape_type": "Cube", "name": "MyCube"}) @@ -868,6 +884,18 @@ manage_probuilder(action="get_mesh_info", target="MyCube", properties={"include" manage_probuilder(action="extrude_faces", target="MyCube", properties={"faceIndices": [2], "distance": 1.5}) +# Select all upward-facing faces +manage_probuilder(action="select_faces", target="MyCube", + properties={"direction": "up", "tolerance": 0.7}) + +# Create double-sided geometry (for room interiors) +manage_probuilder(action="duplicate_and_flip", target="Room", + properties={"faceIndices": [0, 1, 2, 3, 4, 5]}) + +# Weld nearby vertices +manage_probuilder(action="weld_vertices", target="MyCube", + properties={"vertexIndices": [0, 1, 2, 3], "radius": 0.1}) + # Auto-smooth manage_probuilder(action="auto_smooth", target="MyCube", properties={"angleThreshold": 30}) @@ -876,4 +904,4 @@ manage_probuilder(action="center_pivot", target="MyCube") manage_probuilder(action="validate_mesh", target="MyCube") ``` -See also: [ProBuilder Workflow Guide](probuilder-guide.md) for detailed patterns. +See also: [ProBuilder Workflow Guide](probuilder-guide.md) for detailed patterns and complex object examples. diff --git a/.claude/skills/unity-mcp-skill/references/workflows.md b/.claude/skills/unity-mcp-skill/references/workflows.md index 577f21ef7..40b3e9e91 100644 --- a/.claude/skills/unity-mcp-skill/references/workflows.md +++ b/.claude/skills/unity-mcp-skill/references/workflows.md @@ -11,6 +11,7 @@ Common workflows and patterns for effective Unity-MCP usage. - [Testing Workflows](#testing-workflows) - [Debugging Workflows](#debugging-workflows) - [UI Creation Workflows](#ui-creation-workflows) +- [ProBuilder Workflows](#probuilder-workflows) - [Batch Operations](#batch-operations) --- @@ -1425,6 +1426,88 @@ Both systems are active simultaneously. For UI, prefer `InputSystemUIInputModule --- +## ProBuilder Workflows + +When `com.unity.probuilder` is installed, prefer ProBuilder shapes over primitive GameObjects for any geometry that needs editing, multi-material faces, or non-trivial shapes. Check availability first with `manage_probuilder(action="ping")`. + +See [ProBuilder Workflow Guide](probuilder-guide.md) for full reference with complex object examples. + +### ProBuilder vs Primitives Decision + +| Need | Use Primitives | Use ProBuilder | +|------|---------------|----------------| +| Simple placeholder cube | `manage_gameobject(action="create", primitive_type="Cube")` | - | +| Editable geometry | - | `manage_probuilder(action="create_shape", ...)` | +| Per-face materials | - | `set_face_material` | +| Custom shapes (L-rooms, arches) | - | `create_poly_shape` or `create_shape` | +| Mesh editing (extrude, bevel) | - | Face/edge/vertex operations | +| Batch environment building | Either | ProBuilder + `batch_execute` | + +### Basic ProBuilder Scene Build + +```python +# 1. Check ProBuilder availability +manage_probuilder(action="ping") + +# 2. Create shapes (use batch for multiple) +batch_execute(commands=[ + {"tool": "manage_probuilder", "params": { + "action": "create_shape", + "properties": {"shape_type": "Cube", "name": "Floor", "width": 20, "height": 0.2, "depth": 20} + }}, + {"tool": "manage_probuilder", "params": { + "action": "create_shape", + "properties": {"shape_type": "Cube", "name": "Wall1", "width": 20, "height": 3, "depth": 0.3, + "position": [0, 1.5, 10]} + }}, + {"tool": "manage_probuilder", "params": { + "action": "create_shape", + "properties": {"shape_type": "Cylinder", "name": "Pillar1", "radius": 0.4, "height": 3, + "position": [5, 1.5, 5]} + }}, +]) + +# 3. Edit geometry (always get_mesh_info first!) +info = manage_probuilder(action="get_mesh_info", target="Wall1", + properties={"include": "faces"}) +# Find direction="front" face, subdivide it, delete center for a window + +# 4. Apply materials per face +manage_probuilder(action="set_face_material", target="Floor", + properties={"faceIndices": [0], "materialPath": "Assets/Materials/Stone.mat"}) + +# 5. Smooth organic shapes +manage_probuilder(action="auto_smooth", target="Pillar1", + properties={"angleThreshold": 45}) + +# 6. Screenshot to verify +manage_scene(action="screenshot", include_image=True, max_resolution=512) +``` + +### Edit-Verify Loop Pattern + +Face indices change after every edit. Always re-query: + +```python +# WRONG: Assume face indices are stable +manage_probuilder(action="subdivide", target="Obj", properties={"faceIndices": [2]}) +manage_probuilder(action="delete_faces", target="Obj", properties={"faceIndices": [5]}) # Index may be wrong! + +# RIGHT: Re-query after each edit +manage_probuilder(action="subdivide", target="Obj", properties={"faceIndices": [2]}) +info = manage_probuilder(action="get_mesh_info", target="Obj", properties={"include": "faces"}) +# Find the correct face by direction/center, then delete +manage_probuilder(action="delete_faces", target="Obj", properties={"faceIndices": [correct_index]}) +``` + +### Known Limitations + +- **`set_pivot`**: Broken -- vertex positions don't persist through mesh rebuild. Use `center_pivot` or Transform positioning. +- **`convert_to_probuilder`**: Broken -- MeshImporter throws. Create shapes natively with `create_shape`/`create_poly_shape`. +- **`subdivide`**: Uses `ConnectElements.Connect` (not traditional quad subdivision). Connects face midpoints. + +--- + ## Batch Operations ### Mass Property Update diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index 31a6e2a1e..b2b70a282 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -859,7 +859,7 @@ private static object CaptureOrbitBatch(SceneCommand cmd) /// /// Captures a single screenshot from a temporary camera placed at view_position and aimed at look_at. - /// Returns inline base64 PNG (no file saved to disk). + /// Returns inline base64 PNG and also saves the image to Assets/Screenshots/. /// private static object CapturePositionedScreenshot(SceneCommand cmd) { @@ -920,7 +920,28 @@ private static object CapturePositionedScreenshot(SceneCommand cmd) var (b64, w, h) = ScreenshotUtility.RenderCameraToBase64(tempCam, maxRes); + // Save to disk string screenshotsFolder = Path.Combine(Application.dataPath, "Screenshots"); + Directory.CreateDirectory(screenshotsFolder); + string fileName = $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png"; + string fullPath = Path.Combine(screenshotsFolder, fileName); + // Ensure unique filename + if (File.Exists(fullPath)) + { + string baseName = Path.GetFileNameWithoutExtension(fullPath); + string ext = Path.GetExtension(fullPath); + int counter = 1; + while (File.Exists(fullPath)) + { + fullPath = Path.Combine(screenshotsFolder, $"{baseName}_{counter}{ext}"); + counter++; + } + } + byte[] pngBytes = System.Convert.FromBase64String(b64); + File.WriteAllBytes(fullPath, pngBytes); + + string assetsRelativePath = "Assets/Screenshots/" + Path.GetFileName(fullPath); + var data = new Dictionary { { "imageBase64", b64 }, @@ -928,12 +949,13 @@ private static object CapturePositionedScreenshot(SceneCommand cmd) { "imageHeight", h }, { "viewPosition", new[] { camPos.x, camPos.y, camPos.z } }, { "screenshotsFolder", screenshotsFolder }, + { "filePath", assetsRelativePath }, }; if (targetPos.HasValue) data["lookAt"] = new[] { targetPos.Value.x, targetPos.Value.y, targetPos.Value.z }; return new SuccessResponse( - $"Positioned screenshot captured (max {maxRes}px).", + $"Positioned screenshot captured (max {maxRes}px) and saved to '{assetsRelativePath}'.", data ); } diff --git a/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs b/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs index 2da47821f..6fc8ed1d6 100644 --- a/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs +++ b/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs @@ -14,28 +14,37 @@ namespace MCPForUnity.Editor.Tools.ProBuilder /// Requires com.unity.probuilder package to be installed. /// /// SHAPE CREATION: - /// - create_shape: Create ProBuilder primitive (shapeType, size/radius/height, position, rotation, name) + /// - create_shape: Create ProBuilder primitive with real dimensions via Generate* methods /// Shape types: Cube, Cylinder, Sphere, Plane, Cone, Torus, Pipe, Arch, Stair, CurvedStair, Door, Prism + /// Each shape accepts type-specific parameters (radius, height, steps, segments, etc.) /// - create_poly_shape: Create from 2D polygon footprint (points, extrudeHeight, flipNormals) /// /// MESH EDITING: /// - extrude_faces: Extrude faces (faceIndices, distance, method: FaceNormal/VertexNormal/IndividualFaces) - /// - extrude_edges: Extrude edges (edgeIndices, distance, asGroup) - /// - bevel_edges: Bevel edges (edgeIndices, amount 0-1) + /// - extrude_edges: Extrude edges (edgeIndices or edges [{a,b},...], distance, asGroup) + /// - bevel_edges: Bevel edges (edgeIndices or edges [{a,b},...], amount 0-1) /// - subdivide: Subdivide faces (faceIndices optional) /// - delete_faces: Delete faces (faceIndices) - /// - bridge_edges: Bridge two open edges (edgeA, edgeB as {a,b} pairs) + /// - bridge_edges: Bridge two open edges (edgeA, edgeB as {a,b} pairs, allowNonManifold) /// - connect_elements: Connect edges/faces (edgeIndices or faceIndices) - /// - detach_faces: Detach faces to new object (faceIndices, deleteSource) + /// - detach_faces: Detach faces (faceIndices, deleteSourceFaces) /// - flip_normals: Flip face normals (faceIndices) /// - merge_faces: Merge faces into one (faceIndices) /// - combine_meshes: Combine ProBuilder objects (targets list) /// - merge_objects: Merge objects (auto-converts non-ProBuilder), convenience wrapper (targets, name) + /// - duplicate_and_flip: Create double-sided geometry (faceIndices) + /// - create_polygon: Connect existing vertices into a new face (vertexIndices, unordered) /// /// VERTEX OPERATIONS: - /// - merge_vertices: Merge/weld vertices (vertexIndices) + /// - merge_vertices: Collapse vertices to single point (vertexIndices, collapseToFirst) + /// - weld_vertices: Weld vertices within proximity radius (vertexIndices, radius) /// - split_vertices: Split shared vertices (vertexIndices) /// - move_vertices: Translate vertices (vertexIndices, offset [x,y,z]) + /// - insert_vertex: Insert vertex on edge or face (edge {a,b} or faceIndex + point [x,y,z]) + /// - append_vertices_to_edge: Insert evenly-spaced points on edges (edgeIndices or edges, count) + /// + /// SELECTION: + /// - select_faces: Select faces by criteria (direction, growAngle, floodAngle, loop, ring) /// /// UV & MATERIALS: /// - set_face_material: Assign material to faces (faceIndices, materialPath) @@ -43,7 +52,7 @@ namespace MCPForUnity.Editor.Tools.ProBuilder /// - set_face_uvs: Set UV params (faceIndices, scale, offset, rotation, flipU, flipV) /// /// QUERY: - /// - get_mesh_info: Get mesh details (face count, vertex count, bounds, materials) + /// - get_mesh_info: Get mesh details (face count, vertex count, bounds, materials, edges with positions) /// - convert_to_probuilder: Convert standard mesh to ProBuilder /// [McpForUnityTool("manage_probuilder", AutoRegister = false, Group = "probuilder")] @@ -68,6 +77,10 @@ public static class ManageProBuilder private static Type _meshImporterType; internal static Type _smoothingType; internal static Type _meshValidationType; + private static Type _pivotLocationType; + private static Type _vertexEditingType; + private static Type _elementSelectionType; + private static Type _axisEnum; private static bool _typesResolved; private static bool _proBuilderAvailable; @@ -98,6 +111,12 @@ private static bool EnsureProBuilder() _mergeElementsType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.MergeElements, Unity.ProBuilder"); _combineMeshesType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.CombineMeshes, Unity.ProBuilder"); _surfaceTopologyType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.SurfaceTopology, Unity.ProBuilder"); + _vertexEditingType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.VertexEditing, Unity.ProBuilder"); + _elementSelectionType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.ElementSelection, Unity.ProBuilder"); + + // Enums & structs + _pivotLocationType = Type.GetType("UnityEngine.ProBuilder.PivotLocation, Unity.ProBuilder"); + _axisEnum = Type.GetType("UnityEngine.ProBuilder.Axis, Unity.ProBuilder"); // Editor utilities _editorMeshUtilityType = Type.GetType("UnityEditor.ProBuilder.EditorMeshUtility, Unity.ProBuilder.Editor"); @@ -147,11 +166,19 @@ public static object HandleCommand(JObject @params) case "merge_faces": return MergeFaces(@params); case "combine_meshes": return CombineMeshes(@params); case "merge_objects": return MergeObjects(@params); + case "duplicate_and_flip": return DuplicateAndFlip(@params); + case "create_polygon": return CreatePolygon(@params); // Vertex operations case "merge_vertices": return MergeVertices(@params); + case "weld_vertices": return WeldVertices(@params); case "split_vertices": return SplitVertices(@params); case "move_vertices": return MoveVertices(@params); + case "insert_vertex": return InsertVertex(@params); + case "append_vertices_to_edge": return AppendVerticesToEdge(@params); + + // Selection + case "select_faces": return SelectFaces(@params); // UV & materials case "set_face_material": return SetFaceMaterial(@params); @@ -169,6 +196,7 @@ public static object HandleCommand(JObject @params) // Mesh utilities case "center_pivot": return ProBuilderMeshUtils.CenterPivot(@params); case "freeze_transform": return ProBuilderMeshUtils.FreezeTransform(@params); + case "set_pivot": return ProBuilderMeshUtils.SetPivot(@params); case "validate_mesh": return ProBuilderMeshUtils.ValidateMesh(@params); case "repair_mesh": return ProBuilderMeshUtils.RepairMesh(@params); @@ -209,8 +237,19 @@ internal static Component RequireProBuilderMesh(JObject @params) internal static void RefreshMesh(Component pbMesh) { - _proBuilderMeshType.GetMethod("ToMesh", Type.EmptyTypes)?.Invoke(pbMesh, null); - _proBuilderMeshType.GetMethod("Refresh", Type.EmptyTypes)?.Invoke(pbMesh, null); + // ToMesh and Refresh have optional parameters (MeshTopology, RefreshMask) — + // Type.EmptyTypes won't find them. Use name-only lookup with default args. + var toMeshMethod = _proBuilderMeshType.GetMethod("ToMesh", Type.EmptyTypes) + ?? _proBuilderMeshType.GetMethod("ToMesh", BindingFlags.Instance | BindingFlags.Public); + toMeshMethod?.Invoke(pbMesh, toMeshMethod.GetParameters().Length > 0 + ? new object[toMeshMethod.GetParameters().Length] + : null); + + var refreshMethod = _proBuilderMeshType.GetMethod("Refresh", Type.EmptyTypes) + ?? _proBuilderMeshType.GetMethod("Refresh", BindingFlags.Instance | BindingFlags.Public); + refreshMethod?.Invoke(pbMesh, refreshMethod.GetParameters().Length > 0 + ? new object[refreshMethod.GetParameters().Length] + : null); if (_editorMeshUtilityType != null) { @@ -288,71 +327,189 @@ internal static int GetVertexCount(Component pbMesh) return vertexCount != null ? (int)vertexCount.GetValue(pbMesh) : -1; } + private static object GetPivotCenter() + { + if (_pivotLocationType == null) return null; + // PivotLocation.Center = 0 + return Enum.ToObject(_pivotLocationType, 0); + } + + private static Component InvokeGenerator(string methodName, Type[] paramTypes, object[] args) + { + if (_shapeGeneratorType == null) return null; + var method = _shapeGeneratorType.GetMethod(methodName, + BindingFlags.Static | BindingFlags.Public, + null, paramTypes, null); + return method?.Invoke(null, args) as Component; + } + // ===================================================================== - // Shape Creation + // Edge Helpers // ===================================================================== - private static object CreateShape(JObject @params) + private static int GetEdgeVertexA(object edge) { - var props = ExtractProperties(@params); - string shapeTypeStr = props["shapeType"]?.ToString() ?? props["shape_type"]?.ToString(); - if (string.IsNullOrEmpty(shapeTypeStr)) - return new ErrorResponse("shapeType parameter is required."); + var f = _edgeType.GetField("a"); + if (f != null) return (int)f.GetValue(edge); + var p = _edgeType.GetProperty("a"); + return p != null ? (int)p.GetValue(edge) : -1; + } - if (_shapeGeneratorType == null || _shapeTypeEnum == null) - return new ErrorResponse("ShapeGenerator or ShapeType not found in ProBuilder assembly."); + private static int GetEdgeVertexB(object edge) + { + var f = _edgeType.GetField("b"); + if (f != null) return (int)f.GetValue(edge); + var p = _edgeType.GetProperty("b"); + return p != null ? (int)p.GetValue(edge) : -1; + } - // Parse shape type enum - object shapeTypeValue; - try - { - shapeTypeValue = Enum.Parse(_shapeTypeEnum, shapeTypeStr, true); - } - catch + private static object CreateEdge(int a, int b) + { + var ctor = _edgeType.GetConstructor(new[] { typeof(int), typeof(int) }); + return ctor?.Invoke(new object[] { a, b }); + } + + /// + /// Collect unique (deduplicated) edges from the mesh. + /// Edges shared between faces appear only once. + /// + internal static List CollectUniqueEdges(Component pbMesh) + { + var allFaces = (System.Collections.IList)GetFacesArray(pbMesh); + var uniqueEdges = new List(); + var edgeSet = new HashSet<(int, int)>(); + var edgesProp = _faceType.GetProperty("edges"); + + // Build shared vertex lookup so edges on different faces with different + // vertex indices but the same spatial position are correctly deduplicated. + var sharedLookup = BuildSharedVertexLookup(pbMesh); + + if (allFaces != null && edgesProp != null) { - var validTypes = string.Join(", ", Enum.GetNames(_shapeTypeEnum)); - return new ErrorResponse($"Unknown shape type '{shapeTypeStr}'. Valid types: {validTypes}"); + foreach (var face in allFaces) + { + var faceEdges = edgesProp.GetValue(face) as System.Collections.IList; + if (faceEdges == null) continue; + foreach (var edge in faceEdges) + { + int a = GetEdgeVertexA(edge); + int b = GetEdgeVertexB(edge); + int sa = sharedLookup != null && sharedLookup.ContainsKey(a) ? sharedLookup[a] : a; + int sb = sharedLookup != null && sharedLookup.ContainsKey(b) ? sharedLookup[b] : b; + var key = (Math.Min(sa, sb), Math.Max(sa, sb)); + if (edgeSet.Add(key)) + uniqueEdges.Add(edge); + } + } } + return uniqueEdges; + } - // Use ShapeGenerator.CreateShape(ShapeType) or CreateShape(ShapeType, PivotLocation) - var createMethod = _shapeGeneratorType.GetMethod("CreateShape", - BindingFlags.Static | BindingFlags.Public, - null, - new[] { _shapeTypeEnum }, - null); + private static Dictionary BuildSharedVertexLookup(Component pbMesh) + { + var sharedVerticesProp = _proBuilderMeshType.GetProperty("sharedVertices"); + var sharedVertices = sharedVerticesProp?.GetValue(pbMesh) as System.Collections.IList; + if (sharedVertices == null) return null; - // Fallback: look for overload with PivotLocation (ProBuilder 4.x+) - object[] invokeArgs; - if (createMethod != null) + var lookup = new Dictionary(); + for (int groupIdx = 0; groupIdx < sharedVertices.Count; groupIdx++) { - invokeArgs = new[] { shapeTypeValue }; + var group = sharedVertices[groupIdx] as System.Collections.IEnumerable; + if (group == null) continue; + foreach (object vertIdx in group) + lookup[(int)vertIdx] = groupIdx; } - else + return lookup; + } + + /// + /// Resolve edges from parameters. Supports: + /// - "edgeIndices" / "edge_indices": flat array of indices into unique edge list + /// - "edges": array of {a, b} vertex pair objects + /// Returns a typed Edge[] array suitable for reflection calls. + /// + private static Array ResolveEdges(Component pbMesh, JObject props, out int count) + { + var edgeIndicesToken = props["edgeIndices"] ?? props["edge_indices"]; + var edgePairsToken = props["edges"]; + + var edgeList = new List(); + + if (edgePairsToken != null && edgePairsToken.Type == JTokenType.Array) { - var pivotLocationType = Type.GetType("UnityEngine.ProBuilder.PivotLocation, Unity.ProBuilder"); - if (pivotLocationType != null) + // Edge specification by vertex pairs: [{a: 0, b: 1}, ...] + foreach (var pair in edgePairsToken) { - createMethod = _shapeGeneratorType.GetMethod("CreateShape", - BindingFlags.Static | BindingFlags.Public, - null, - new[] { _shapeTypeEnum, pivotLocationType }, - null); - // PivotLocation.Center = 0 - invokeArgs = new[] { shapeTypeValue, Enum.ToObject(pivotLocationType, 0) }; + int a = pair["a"]?.Value() ?? 0; + int b = pair["b"]?.Value() ?? 0; + edgeList.Add(CreateEdge(a, b)); } - else + } + else if (edgeIndicesToken != null) + { + // Edge specification by index into unique edges + var allEdges = CollectUniqueEdges(pbMesh); + var edgeIndices = edgeIndicesToken.ToObject(); + foreach (int idx in edgeIndices) { - invokeArgs = null; + if (idx < 0 || idx >= allEdges.Count) + throw new Exception($"Edge index {idx} out of range (0-{allEdges.Count - 1})."); + edgeList.Add(allEdges[idx]); } } + else + { + throw new Exception("edgeIndices or edges parameter is required."); + } + + count = edgeList.Count; + var edgeArray = Array.CreateInstance(_edgeType, edgeList.Count); + for (int i = 0; i < edgeList.Count; i++) + edgeArray.SetValue(edgeList[i], i); + return edgeArray; + } + + /// + /// Create a typed List<Edge> from an Edge[] array for APIs that require IList<Edge>. + /// + private static System.Collections.IList ToTypedEdgeList(Array edgeArray) + { + var edgeListType = typeof(List<>).MakeGenericType(_edgeType); + var typedList = Activator.CreateInstance(edgeListType) as System.Collections.IList; + foreach (var e in edgeArray) + typedList.Add(e); + return typedList; + } + + // ===================================================================== + // Shape Creation + // ===================================================================== + + private static object CreateShape(JObject @params) + { + var props = ExtractProperties(@params); + string shapeTypeStr = props["shapeType"]?.ToString() ?? props["shape_type"]?.ToString(); + if (string.IsNullOrEmpty(shapeTypeStr)) + return new ErrorResponse("shapeType parameter is required."); - if (createMethod == null) - return new ErrorResponse("ShapeGenerator.CreateShape method not found. Check your ProBuilder version."); + if (_shapeGeneratorType == null || _shapeTypeEnum == null) + return new ErrorResponse("ShapeGenerator or ShapeType not found in ProBuilder assembly."); Undo.IncrementCurrentGroup(); - var pbMesh = createMethod.Invoke(null, invokeArgs) as Component; + + Component pbMesh = null; + var pivot = GetPivotCenter(); + + // Try shape-specific generators with real dimension parameters + if (pivot != null) + pbMesh = CreateShapeViaGenerator(shapeTypeStr, props, pivot); + + // Fallback: generic CreateShape(ShapeType) for unknown shapes or if generator failed + if (pbMesh == null) + pbMesh = CreateShapeGeneric(shapeTypeStr); + if (pbMesh == null) - return new ErrorResponse("Failed to create ProBuilder shape."); + return new ErrorResponse($"Failed to create ProBuilder shape '{shapeTypeStr}'."); var go = pbMesh.gameObject; Undo.RegisterCreatedObjectUndo(go, $"Create ProBuilder {shapeTypeStr}"); @@ -372,9 +529,6 @@ private static object CreateShape(JObject @params) if (rotToken != null) go.transform.eulerAngles = ParseVector3(rotToken); - // Apply size/dimensions via scale (ShapeGenerator creates shapes with known defaults) - ApplyShapeDimensions(go, shapeTypeStr, props); - RefreshMesh(pbMesh); return new SuccessResponse($"Created ProBuilder {shapeTypeStr}: {go.name}", new @@ -387,7 +541,7 @@ private static object CreateShape(JObject @params) }); } - private static void ApplyShapeDimensions(GameObject go, string shapeType, JObject props) + private static Component CreateShapeViaGenerator(string shapeType, JObject props, object pivot) { float size = props["size"]?.Value() ?? 0; float width = props["width"]?.Value() ?? 0; @@ -395,116 +549,242 @@ private static void ApplyShapeDimensions(GameObject go, string shapeType, JObjec float depth = props["depth"]?.Value() ?? 0; float radius = props["radius"]?.Value() ?? 0; - if (size <= 0 && width <= 0 && height <= 0 && depth <= 0 && radius <= 0) - return; - - // Each shape type has known default dimensions from ProBuilder's ShapeGenerator. - // We compute a scale factor relative to those defaults. - Vector3 scale; - string shapeUpper = shapeType.ToUpperInvariant(); - - switch (shapeUpper) + switch (shapeType.ToUpperInvariant()) { case "CUBE": - // Default: 1x1x1 - scale = new Vector3( - width > 0 ? width : (size > 0 ? size : 1f), - height > 0 ? height : (size > 0 ? size : 1f), - depth > 0 ? depth : (size > 0 ? size : 1f)); - break; + { + float w = width > 0 ? width : (size > 0 ? size : 1f); + float h = height > 0 ? height : (size > 0 ? size : 1f); + float d = depth > 0 ? depth : (size > 0 ? size : 1f); + return InvokeGenerator("GenerateCube", + new[] { _pivotLocationType, typeof(Vector3) }, + new object[] { pivot, new Vector3(w, h, d) }); + } case "PRISM": - // Default: 1x1x1 - scale = new Vector3( - width > 0 ? width : (size > 0 ? size : 1f), - height > 0 ? height : (size > 0 ? size : 1f), - depth > 0 ? depth : (size > 0 ? size : 1f)); - break; + { + float w = width > 0 ? width : (size > 0 ? size : 1f); + float h = height > 0 ? height : (size > 0 ? size : 1f); + float d = depth > 0 ? depth : (size > 0 ? size : 1f); + return InvokeGenerator("GeneratePrism", + new[] { _pivotLocationType, typeof(Vector3) }, + new object[] { pivot, new Vector3(w, h, d) }); + } case "CYLINDER": - // Default: radius=0.5 (diameter=1), height=2 - float cylRadius = radius > 0 ? radius : (size > 0 ? size / 2f : 0.5f); - float cylHeight = height > 0 ? height : (size > 0 ? size : 2f); - scale = new Vector3(cylRadius / 0.5f, cylHeight / 2f, cylRadius / 0.5f); - break; + { + float r = radius > 0 ? radius : (size > 0 ? size / 2f : 0.5f); + float h = height > 0 ? height : (size > 0 ? size : 2f); + int axisDivisions = props["axisDivisions"]?.Value() + ?? props["axis_divisions"]?.Value() + ?? props["segments"]?.Value() ?? 24; + int heightCuts = props["heightCuts"]?.Value() + ?? props["height_cuts"]?.Value() ?? 0; + int smoothing = props["smoothing"]?.Value() ?? -1; + return InvokeGenerator("GenerateCylinder", + new[] { _pivotLocationType, typeof(int), typeof(float), typeof(float), typeof(int), typeof(int) }, + new object[] { pivot, axisDivisions, r, h, heightCuts, smoothing }); + } case "CONE": - // Default: 1x1x1 (radius 0.5) - float coneRadius = radius > 0 ? radius : (size > 0 ? size / 2f : 0.5f); - float coneHeight = height > 0 ? height : (size > 0 ? size : 1f); - scale = new Vector3(coneRadius / 0.5f, coneHeight, coneRadius / 0.5f); - break; + { + float r = radius > 0 ? radius : (size > 0 ? size / 2f : 0.5f); + float h = height > 0 ? height : (size > 0 ? size : 1f); + int subdivAxis = props["subdivAxis"]?.Value() + ?? props["subdiv_axis"]?.Value() + ?? props["segments"]?.Value() ?? 6; + return InvokeGenerator("GenerateCone", + new[] { _pivotLocationType, typeof(float), typeof(float), typeof(int) }, + new object[] { pivot, r, h, subdivAxis }); + } case "SPHERE": - // Default: radius=0.5 (diameter=1) - float sphereRadius = radius > 0 ? radius : (size > 0 ? size / 2f : 0.5f); - scale = Vector3.one * (sphereRadius / 0.5f); - break; + { + float r = radius > 0 ? radius : (size > 0 ? size / 2f : 0.5f); + int subdivisions = props["subdivisions"]?.Value() ?? 2; + return InvokeGenerator("GenerateIcosahedron", + new[] { _pivotLocationType, typeof(float), typeof(int), typeof(bool), typeof(bool) }, + new object[] { pivot, r, subdivisions, true, false }); + } case "TORUS": - // Default: fits in ~1x1x1 - float torusScale = radius > 0 ? radius * 2f : (size > 0 ? size : 1f); - scale = Vector3.one * torusScale; - break; + { + int rows = props["rows"]?.Value() ?? 8; + int columns = props["columns"]?.Value() ?? 16; + // ProBuilder convention: innerRadius = ring radius (major), outerRadius = tube radius (minor). + // Our API uses the intuitive naming: outerRadius = ring, innerRadius = tube. + // So we swap when passing to ProBuilder's GenerateTorus. + float tubeRadius = props["innerRadius"]?.Value() + ?? props["inner_radius"]?.Value() + ?? props["tubeRadius"]?.Value() + ?? props["tube_radius"]?.Value() + ?? (radius > 0 ? radius * 0.1f : 0.1f); + float ringRadius = props["outerRadius"]?.Value() + ?? props["outer_radius"]?.Value() + ?? props["ringRadius"]?.Value() + ?? props["ring_radius"]?.Value() + ?? (radius > 0 ? radius : (size > 0 ? size / 2f : 0.5f)); + bool smooth = props["smooth"]?.Value() ?? true; + float hCirc = props["horizontalCircumference"]?.Value() + ?? props["horizontal_circumference"]?.Value() ?? 360f; + float vCirc = props["verticalCircumference"]?.Value() + ?? props["vertical_circumference"]?.Value() ?? 360f; + return InvokeGenerator("GenerateTorus", + new[] { _pivotLocationType, typeof(int), typeof(int), typeof(float), typeof(float), + typeof(bool), typeof(float), typeof(float), typeof(bool) }, + new object[] { pivot, rows, columns, ringRadius, tubeRadius, smooth, hCirc, vCirc, false }); + } - case "ARCH": - // Default: approximately 4x2x1 - scale = new Vector3( - width > 0 ? width / 4f : (size > 0 ? size / 4f : 1f), - height > 0 ? height / 2f : (size > 0 ? size / 2f : 1f), - depth > 0 ? depth : (size > 0 ? size : 1f)); - break; + case "PIPE": + { + float r = radius > 0 ? radius : (size > 0 ? size / 2f : 1f); + float h = height > 0 ? height : (size > 0 ? size : 2f); + float thickness = props["thickness"]?.Value() ?? 0.2f; + int subdivAxis = props["subdivAxis"]?.Value() + ?? props["subdiv_axis"]?.Value() + ?? props["segments"]?.Value() ?? 6; + int subdivHeight = props["subdivHeight"]?.Value() + ?? props["subdiv_height"]?.Value() ?? 1; + return InvokeGenerator("GeneratePipe", + new[] { _pivotLocationType, typeof(float), typeof(float), typeof(float), typeof(int), typeof(int) }, + new object[] { pivot, r, h, thickness, subdivAxis, subdivHeight }); + } + + case "PLANE": + { + float w = width > 0 ? width : (size > 0 ? size : 1f); + float h = height > 0 ? height : (depth > 0 ? depth : (size > 0 ? size : 1f)); + int widthCuts = props["widthCuts"]?.Value() + ?? props["width_cuts"]?.Value() ?? 0; + int heightCuts = props["heightCuts"]?.Value() + ?? props["height_cuts"]?.Value() ?? 0; + // Axis enum: default Y-up (2) + if (_axisEnum != null) + { + int axisVal = props["axis"]?.Value() ?? 2; + var axisObj = Enum.ToObject(_axisEnum, axisVal); + return InvokeGenerator("GeneratePlane", + new[] { _pivotLocationType, typeof(float), typeof(float), typeof(int), typeof(int), _axisEnum }, + new object[] { pivot, w, h, widthCuts, heightCuts, axisObj }); + } + return InvokeGenerator("GeneratePlane", + new[] { _pivotLocationType, typeof(float), typeof(float), typeof(int), typeof(int) }, + new object[] { pivot, w, h, widthCuts, heightCuts }); + } case "STAIR": - // Default: approximately 2x2.5x4 - scale = new Vector3( - width > 0 ? width / 2f : (size > 0 ? size / 2f : 1f), - height > 0 ? height / 2.5f : (size > 0 ? size / 2.5f : 1f), - depth > 0 ? depth / 4f : (size > 0 ? size / 4f : 1f)); - break; + { + float w = width > 0 ? width : (size > 0 ? size : 2f); + float h = height > 0 ? height : (size > 0 ? size : 2.5f); + float d = depth > 0 ? depth : (size > 0 ? size : 4f); + int steps = props["steps"]?.Value() ?? 10; + bool buildSides = props["buildSides"]?.Value() + ?? props["build_sides"]?.Value() ?? true; + return InvokeGenerator("GenerateStair", + new[] { _pivotLocationType, typeof(Vector3), typeof(int), typeof(bool) }, + new object[] { pivot, new Vector3(w, h, d), steps, buildSides }); + } case "CURVEDSTAIR": - // Default: similar to stair - scale = new Vector3( - width > 0 ? width / 2f : (size > 0 ? size / 2f : 1f), - height > 0 ? height / 2.5f : (size > 0 ? size / 2.5f : 1f), - depth > 0 ? depth / 2f : (size > 0 ? size / 2f : 1f)); - break; - - case "PIPE": - // Default: radius=1, height=2 - float pipeRadius = radius > 0 ? radius : (size > 0 ? size / 2f : 1f); - float pipeHeight = height > 0 ? height : (size > 0 ? size : 2f); - scale = new Vector3(pipeRadius, pipeHeight / 2f, pipeRadius); - break; + { + float stairWidth = width > 0 ? width : (size > 0 ? size : 2f); + float h = height > 0 ? height : (size > 0 ? size : 2.5f); + float innerR = props["innerRadius"]?.Value() + ?? props["inner_radius"]?.Value() + ?? (radius > 0 ? radius : 2f); + float circumference = props["circumference"]?.Value() ?? 90f; + int steps = props["steps"]?.Value() ?? 10; + bool buildSides = props["buildSides"]?.Value() + ?? props["build_sides"]?.Value() ?? true; + return InvokeGenerator("GenerateCurvedStair", + new[] { _pivotLocationType, typeof(float), typeof(float), typeof(float), typeof(float), typeof(int), typeof(bool) }, + new object[] { pivot, stairWidth, h, innerR, circumference, steps, buildSides }); + } - case "PLANE": - // Default: 1x1 - float planeSize = size > 0 ? size : 1f; - scale = new Vector3( - width > 0 ? width : planeSize, - 1f, - depth > 0 ? depth : planeSize); - break; + case "ARCH": + { + float angle = props["angle"]?.Value() ?? 180f; + float r = radius > 0 ? radius : (size > 0 ? size / 2f : 2f); + float w = width > 0 ? width : 0.5f; + float d = depth > 0 ? depth : 0.5f; + int radialCuts = props["radialCuts"]?.Value() + ?? props["radial_cuts"]?.Value() ?? 6; + bool insideFaces = props["insideFaces"]?.Value() + ?? props["inside_faces"]?.Value() ?? true; + bool outsideFaces = props["outsideFaces"]?.Value() + ?? props["outside_faces"]?.Value() ?? true; + bool frontFaces = props["frontFaces"]?.Value() + ?? props["front_faces"]?.Value() ?? true; + bool backFaces = props["backFaces"]?.Value() + ?? props["back_faces"]?.Value() ?? true; + bool endCaps = props["endCaps"]?.Value() + ?? props["end_caps"]?.Value() ?? true; + return InvokeGenerator("GenerateArch", + new[] { _pivotLocationType, typeof(float), typeof(float), typeof(float), typeof(float), + typeof(int), typeof(bool), typeof(bool), typeof(bool), typeof(bool), typeof(bool) }, + new object[] { pivot, angle, r, w, d, radialCuts, + insideFaces, outsideFaces, frontFaces, backFaces, endCaps }); + } case "DOOR": - // Default: approximately 4x4x1 - scale = new Vector3( - width > 0 ? width / 4f : (size > 0 ? size / 4f : 1f), - height > 0 ? height / 4f : (size > 0 ? size / 4f : 1f), - depth > 0 ? depth : (size > 0 ? size : 1f)); - break; + { + float totalWidth = width > 0 ? width : (size > 0 ? size : 4f); + float totalHeight = height > 0 ? height : (size > 0 ? size : 4f); + float ledgeHeight = props["ledgeHeight"]?.Value() + ?? props["ledge_height"]?.Value() ?? 0.1f; + float legWidth = props["legWidth"]?.Value() + ?? props["leg_width"]?.Value() ?? 1f; + float d = depth > 0 ? depth : (size > 0 ? size : 0.5f); + return InvokeGenerator("GenerateDoor", + new[] { _pivotLocationType, typeof(float), typeof(float), typeof(float), typeof(float), typeof(float) }, + new object[] { pivot, totalWidth, totalHeight, ledgeHeight, legWidth, d }); + } default: - // Generic fallback: uniform scale from size - if (size > 0) - scale = Vector3.one * size; - else - return; // No dimensions to apply - break; + return null; + } + } + + private static Component CreateShapeGeneric(string shapeTypeStr) + { + object shapeTypeValue; + try + { + shapeTypeValue = Enum.Parse(_shapeTypeEnum, shapeTypeStr, true); + } + catch + { + var validTypes = string.Join(", ", Enum.GetNames(_shapeTypeEnum)); + throw new Exception($"Unknown shape type '{shapeTypeStr}'. Valid types: {validTypes}"); + } + + // Try CreateShape(ShapeType) first + var createMethod = _shapeGeneratorType.GetMethod("CreateShape", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _shapeTypeEnum }, + null); + + object[] invokeArgs; + if (createMethod != null) + { + invokeArgs = new[] { shapeTypeValue }; + } + else if (_pivotLocationType != null) + { + createMethod = _shapeGeneratorType.GetMethod("CreateShape", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _shapeTypeEnum, _pivotLocationType }, + null); + invokeArgs = new[] { shapeTypeValue, GetPivotCenter() }; + } + else + { + return null; } - go.transform.localScale = scale; + return createMethod?.Invoke(null, invokeArgs) as Component; } private static object CreatePolyShape(JObject @params) @@ -529,7 +809,6 @@ private static object CreatePolyShape(JObject @params) Undo.RegisterCreatedObjectUndo(go, "Create ProBuilder PolyShape"); var pbMesh = go.AddComponent(_proBuilderMeshType); - // Use AppendElements.CreateShapeFromPolygon if (_appendElementsType == null) { UnityEngine.Object.DestroyImmediate(go); @@ -548,7 +827,7 @@ private static object CreatePolyShape(JObject @params) return new ErrorResponse("CreateShapeFromPolygon method not found."); } - var actionResult = createFromPolygonMethod.Invoke(null, new object[] { pbMesh, points, extrudeHeight, flipNormals }); + createFromPolygonMethod.Invoke(null, new object[] { pbMesh, points, extrudeHeight, flipNormals }); string name = props["name"]?.ToString(); if (!string.IsNullOrEmpty(name)) @@ -616,52 +895,22 @@ private static object ExtrudeEdges(JObject @params) { var pbMesh = RequireProBuilderMesh(@params); var props = ExtractProperties(@params); - var edgeIndicesToken = props["edgeIndices"] ?? props["edge_indices"]; - if (edgeIndicesToken == null) - return new ErrorResponse("edgeIndices parameter is required."); - - float distance = props["distance"]?.Value() ?? 0.5f; - bool asGroup = props["asGroup"]?.Value() ?? props["as_group"]?.Value() ?? true; - - var edgeIndices = edgeIndicesToken.ToObject(); - - // Get edges from the mesh - var edgesProperty = _proBuilderMeshType.GetProperty("faces"); - var allFaces = (System.Collections.IList)edgesProperty?.GetValue(pbMesh); - if (allFaces == null) - return new ErrorResponse("Could not read faces from mesh."); - - // Collect edges from specified indices - var edgeList = new List(); - var allEdges = new List(); - // Get all edges via face edges - foreach (var face in allFaces) + int edgeCount; + Array edgeArray; + try { - var edgesProp = _faceType.GetProperty("edges"); - if (edgesProp != null) - { - var faceEdges = edgesProp.GetValue(face) as System.Collections.IList; - if (faceEdges != null) - { - foreach (var edge in faceEdges) - allEdges.Add(edge); - } - } + edgeArray = ResolveEdges(pbMesh, props, out edgeCount); } - - foreach (int idx in edgeIndices) + catch (Exception ex) { - if (idx < 0 || idx >= allEdges.Count) - return new ErrorResponse($"Edge index {idx} out of range (0-{allEdges.Count - 1})."); - edgeList.Add(allEdges[idx]); + return new ErrorResponse(ex.Message); } - Undo.RegisterCompleteObjectUndo(pbMesh, "Extrude Edges"); + float distance = props["distance"]?.Value() ?? 0.5f; + bool asGroup = props["asGroup"]?.Value() ?? props["as_group"]?.Value() ?? true; - var edgeArray = Array.CreateInstance(_edgeType, edgeList.Count); - for (int i = 0; i < edgeList.Count; i++) - edgeArray.SetValue(edgeList[i], i); + Undo.RegisterCompleteObjectUndo(pbMesh, "Extrude Edges"); var extrudeMethod = _extrudeElementsType?.GetMethod("Extrude", BindingFlags.Static | BindingFlags.Public, @@ -675,9 +924,9 @@ private static object ExtrudeEdges(JObject @params) extrudeMethod.Invoke(null, new object[] { pbMesh, edgeArray, distance, asGroup, true }); RefreshMesh(pbMesh); - return new SuccessResponse($"Extruded {edgeList.Count} edge(s) by {distance}", new + return new SuccessResponse($"Extruded {edgeCount} edge(s) by {distance}", new { - edgesExtruded = edgeList.Count, + edgesExtruded = edgeCount, distance, faceCount = GetFaceCount(pbMesh), }); @@ -687,33 +936,26 @@ private static object BevelEdges(JObject @params) { var pbMesh = RequireProBuilderMesh(@params); var props = ExtractProperties(@params); - var edgeIndicesToken = props["edgeIndices"] ?? props["edge_indices"]; - if (edgeIndicesToken == null) - return new ErrorResponse("edgeIndices parameter is required."); + + int edgeCount; + Array edgeArray; + try + { + edgeArray = ResolveEdges(pbMesh, props, out edgeCount); + } + catch (Exception ex) + { + return new ErrorResponse(ex.Message); + } float amount = props["amount"]?.Value() ?? 0.1f; if (_bevelType == null) return new ErrorResponse("Bevel type not found in ProBuilder assembly."); - // Collect edges - var allEdges = CollectAllEdges(pbMesh); - var edgeIndices = edgeIndicesToken.ToObject(); - var selectedEdges = new List(); - foreach (int idx in edgeIndices) - { - if (idx < 0 || idx >= allEdges.Count) - return new ErrorResponse($"Edge index {idx} out of range (0-{allEdges.Count - 1})."); - selectedEdges.Add(allEdges[idx]); - } - Undo.RegisterCompleteObjectUndo(pbMesh, "Bevel Edges"); - // BevelEdges expects IList - var edgeListType = typeof(List<>).MakeGenericType(_edgeType); - var typedList = Activator.CreateInstance(edgeListType) as System.Collections.IList; - foreach (var e in selectedEdges) - typedList.Add(e); + var typedList = ToTypedEdgeList(edgeArray); var bevelMethod = _bevelType.GetMethod("BevelEdges", BindingFlags.Static | BindingFlags.Public); @@ -724,9 +966,9 @@ private static object BevelEdges(JObject @params) bevelMethod.Invoke(null, new object[] { pbMesh, typedList, amount }); RefreshMesh(pbMesh); - return new SuccessResponse($"Beveled {selectedEdges.Count} edge(s) with amount {amount}", new + return new SuccessResponse($"Beveled {edgeCount} edge(s) with amount {amount}", new { - edgesBeveled = selectedEdges.Count, + edgesBeveled = edgeCount, amount, faceCount = GetFaceCount(pbMesh), }); @@ -737,39 +979,29 @@ private static object Subdivide(JObject @params) var pbMesh = RequireProBuilderMesh(@params); var props = ExtractProperties(@params); - if (_surfaceTopologyType == null) - return new ErrorResponse("SurfaceTopology type not found."); + if (_connectElementsType == null) + return new ErrorResponse("ConnectElements type not found."); Undo.RegisterCompleteObjectUndo(pbMesh, "Subdivide"); - // Find Subdivide method - try by parameter count first to avoid fragile generic type matching - var subdivideMethod = _surfaceTopologyType.GetMethods(BindingFlags.Static | BindingFlags.Public) - .FirstOrDefault(m => m.Name == "Subdivide" && m.GetParameters().Length == 2); + var faceIndicesToken = props["faceIndices"] ?? props["face_indices"]; - if (subdivideMethod == null) - { - subdivideMethod = _surfaceTopologyType.GetMethods(BindingFlags.Static | BindingFlags.Public) - .FirstOrDefault(m => m.Name == "Subdivide"); - } + // Get faces to subdivide (all faces if none specified) + var faces = GetFacesByIndices(pbMesh, faceIndicesToken); + var faceListType = typeof(List<>).MakeGenericType(_faceType); + var faceList = Activator.CreateInstance(faceListType) as System.Collections.IList; + foreach (var f in faces) + faceList.Add(f); - if (subdivideMethod == null) - return new ErrorResponse("SurfaceTopology.Subdivide method not found."); + // ProBuilder uses ConnectElements.Connect(mesh, faces) for face subdivision + var connectMethod = _connectElementsType.GetMethods(BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault(m => m.Name == "Connect" && m.GetParameters().Length == 2 + && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceListType)); - var faceIndicesToken = props["faceIndices"] ?? props["face_indices"]; - if (faceIndicesToken != null) - { - var faces = GetFacesByIndices(pbMesh, faceIndicesToken); - var faceListType = typeof(List<>).MakeGenericType(_faceType); - var faceList = Activator.CreateInstance(faceListType) as System.Collections.IList; - foreach (var f in faces) - faceList.Add(f); - subdivideMethod.Invoke(null, new object[] { pbMesh, faceList }); - } - else - { - // Subdivide all - pass null or all faces - subdivideMethod.Invoke(null, new object[] { pbMesh, null }); - } + if (connectMethod == null) + return new ErrorResponse("ConnectElements.Connect (faces) method not found."); + + connectMethod.Invoke(null, new object[] { pbMesh, faceList }); RefreshMesh(pbMesh); @@ -795,31 +1027,45 @@ private static object DeleteFaces(JObject @params) Undo.RegisterCompleteObjectUndo(pbMesh, "Delete Faces"); - // DeleteElements.DeleteFaces(ProBuilderMesh, int[]) + // Prefer DeleteFaces(ProBuilderMesh, IList) overload var deleteMethod = _deleteElementsType.GetMethod("DeleteFaces", BindingFlags.Static | BindingFlags.Public, null, - new[] { _proBuilderMeshType, typeof(int[]) }, + new[] { _proBuilderMeshType, typeof(IList) }, null); - if (deleteMethod == null) + if (deleteMethod != null) { - // Try with IEnumerable - var faces = GetFacesByIndices(pbMesh, faceIndicesToken); + deleteMethod.Invoke(null, new object[] { pbMesh, faceIndices.ToList() }); + } + else + { + // Try int[] overload deleteMethod = _deleteElementsType.GetMethod("DeleteFaces", BindingFlags.Static | BindingFlags.Public, null, - new[] { _proBuilderMeshType, faces.GetType() }, + new[] { _proBuilderMeshType, typeof(int[]) }, null); if (deleteMethod == null) - return new ErrorResponse("DeleteElements.DeleteFaces method not found."); + { + // Try IEnumerable overload + var faces = GetFacesByIndices(pbMesh, faceIndicesToken); + deleteMethod = _deleteElementsType.GetMethod("DeleteFaces", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, faces.GetType() }, + null); - deleteMethod.Invoke(null, new object[] { pbMesh, faces }); - } - else - { - deleteMethod.Invoke(null, new object[] { pbMesh, faceIndices }); + if (deleteMethod == null) + return new ErrorResponse("DeleteElements.DeleteFaces method not found."); + + deleteMethod.Invoke(null, new object[] { pbMesh, faces }); + } + else + { + deleteMethod.Invoke(null, new object[] { pbMesh, faceIndices }); + } } RefreshMesh(pbMesh); @@ -844,33 +1090,50 @@ private static object BridgeEdges(JObject @params) if (edgeAToken == null || edgeBToken == null) return new ErrorResponse("edgeA and edgeB parameters are required (as {a, b} vertex index pairs)."); - // Create Edge instances from vertex index pairs - var edgeACtor = _edgeType.GetConstructor(new[] { typeof(int), typeof(int) }); - var edgeBCtor = _edgeType.GetConstructor(new[] { typeof(int), typeof(int) }); - if (edgeACtor == null) - return new ErrorResponse("Edge constructor not found."); - int aA = edgeAToken["a"]?.Value() ?? 0; int aB = edgeAToken["b"]?.Value() ?? 0; int bA = edgeBToken["a"]?.Value() ?? 0; int bB = edgeBToken["b"]?.Value() ?? 0; - var edgeA = edgeACtor.Invoke(new object[] { aA, aB }); - var edgeB = edgeBCtor.Invoke(new object[] { bA, bB }); + var edgeA = CreateEdge(aA, aB); + var edgeB = CreateEdge(bA, bB); + + bool allowNonManifold = props["allowNonManifold"]?.Value() + ?? props["allow_non_manifold"]?.Value() + ?? props["allowNonManifoldGeometry"]?.Value() + ?? props["allow_non_manifold_geometry"]?.Value() + ?? false; Undo.RegisterCompleteObjectUndo(pbMesh, "Bridge Edges"); + // Try overload with allowNonManifoldGeometry parameter first var bridgeMethod = _appendElementsType.GetMethod("Bridge", BindingFlags.Static | BindingFlags.Public, null, - new[] { _proBuilderMeshType, _edgeType, _edgeType }, + new[] { _proBuilderMeshType, _edgeType, _edgeType, typeof(bool) }, null); - if (bridgeMethod == null) - return new ErrorResponse("AppendElements.Bridge method not found."); + object result; + if (bridgeMethod != null) + { + result = bridgeMethod.Invoke(null, new object[] { pbMesh, edgeA, edgeB, allowNonManifold }); + } + else + { + // Fallback without allowNonManifold + bridgeMethod = _appendElementsType.GetMethod("Bridge", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, _edgeType, _edgeType }, + null); - var result = bridgeMethod.Invoke(null, new object[] { pbMesh, edgeA, edgeB }); - RefreshMesh(pbMesh); + if (bridgeMethod == null) + return new ErrorResponse("AppendElements.Bridge method not found."); + + result = bridgeMethod.Invoke(null, new object[] { pbMesh, edgeA, edgeB }); + } + + RefreshMesh(pbMesh); return new SuccessResponse("Bridged edges", new { @@ -891,39 +1154,47 @@ private static object ConnectElements(JObject @params) var faceIndicesToken = props["faceIndices"] ?? props["face_indices"]; var edgeIndicesToken = props["edgeIndices"] ?? props["edge_indices"]; + var edgePairsToken = props["edges"]; if (faceIndicesToken != null) { var faces = GetFacesByIndices(pbMesh, faceIndicesToken); - var connectMethod = _connectElementsType.GetMethod("Connect", - BindingFlags.Static | BindingFlags.Public, - null, - new[] { _proBuilderMeshType, faces.GetType() }, - null); + + // Build IEnumerable compatible type (List) + var faceListType = typeof(List<>).MakeGenericType(_faceType); + var faceList = Activator.CreateInstance(faceListType) as System.Collections.IList; + foreach (var f in faces) + faceList.Add(f); + + // Try Connect(ProBuilderMesh, IEnumerable) + var connectMethod = _connectElementsType.GetMethods(BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault(m => m.Name == "Connect" && m.GetParameters().Length == 2 + && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceListType)); if (connectMethod == null) return new ErrorResponse("ConnectElements.Connect (faces) method not found."); - connectMethod.Invoke(null, new object[] { pbMesh, faces }); + connectMethod.Invoke(null, new object[] { pbMesh, faceList }); } - else if (edgeIndicesToken != null) + else if (edgeIndicesToken != null || edgePairsToken != null) { - var allEdges = CollectAllEdges(pbMesh); - var edgeIndices = edgeIndicesToken.ToObject(); - var edgeListType = typeof(List<>).MakeGenericType(_edgeType); - var typedList = Activator.CreateInstance(edgeListType) as System.Collections.IList; - foreach (int idx in edgeIndices) + int edgeCount; + Array edgeArray; + try { - if (idx < 0 || idx >= allEdges.Count) - return new ErrorResponse($"Edge index {idx} out of range."); - typedList.Add(allEdges[idx]); + edgeArray = ResolveEdges(pbMesh, props, out edgeCount); + } + catch (Exception ex) + { + return new ErrorResponse(ex.Message); } - var connectMethod = _connectElementsType.GetMethod("Connect", - BindingFlags.Static | BindingFlags.Public, - null, - new[] { _proBuilderMeshType, edgeListType }, - null); + var typedList = ToTypedEdgeList(edgeArray); + var edgeListType = typedList.GetType(); + + var connectMethod = _connectElementsType.GetMethods(BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault(m => m.Name == "Connect" && m.GetParameters().Length == 2 + && m.GetParameters()[1].ParameterType.IsAssignableFrom(edgeListType)); if (connectMethod == null) return new ErrorResponse("ConnectElements.Connect (edges) method not found."); @@ -932,7 +1203,7 @@ private static object ConnectElements(JObject @params) } else { - return new ErrorResponse("Either faceIndices or edgeIndices parameter is required."); + return new ErrorResponse("Either faceIndices or edgeIndices/edges parameter is required."); } RefreshMesh(pbMesh); @@ -952,23 +1223,49 @@ private static object DetachFaces(JObject @params) if (_extrudeElementsType == null) return new ErrorResponse("ExtrudeElements type not found."); + bool deleteSource = props["deleteSourceFaces"]?.Value() + ?? props["delete_source_faces"]?.Value() + ?? props["deleteSource"]?.Value() + ?? props["delete_source"]?.Value() + ?? false; + Undo.RegisterCompleteObjectUndo(pbMesh, "Detach Faces"); - var detachMethod = _extrudeElementsType.GetMethod("DetachFaces", - BindingFlags.Static | BindingFlags.Public, - null, - new[] { _proBuilderMeshType, faces.GetType() }, - null); + // Build IEnumerable compatible list for reflection matching + var faceListType = typeof(List<>).MakeGenericType(_faceType); + var faceList = Activator.CreateInstance(faceListType) as System.Collections.IList; + foreach (var f in faces) + faceList.Add(f); + + // Try overload: DetachFaces(ProBuilderMesh, IEnumerable, bool) + var detachMethod = _extrudeElementsType.GetMethods(BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault(m => m.Name == "DetachFaces" && m.GetParameters().Length == 3 + && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceListType) + && m.GetParameters()[2].ParameterType == typeof(bool)); + + if (detachMethod != null) + { + detachMethod.Invoke(null, new object[] { pbMesh, faceList, deleteSource }); + } + else + { + // Fallback: DetachFaces(ProBuilderMesh, IEnumerable) + detachMethod = _extrudeElementsType.GetMethods(BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault(m => m.Name == "DetachFaces" && m.GetParameters().Length == 2 + && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceListType)); - if (detachMethod == null) - return new ErrorResponse("ExtrudeElements.DetachFaces method not found."); + if (detachMethod == null) + return new ErrorResponse("ExtrudeElements.DetachFaces method not found."); + + detachMethod.Invoke(null, new object[] { pbMesh, faceList }); + } - var detachedFaces = detachMethod.Invoke(null, new object[] { pbMesh, faces }); RefreshMesh(pbMesh); return new SuccessResponse($"Detached {faces.Length} face(s)", new { facesDetached = faces.Length, + deleteSourceFaces = deleteSource, faceCount = GetFaceCount(pbMesh), }); } @@ -981,7 +1278,6 @@ private static object FlipNormals(JObject @params) Undo.RegisterCompleteObjectUndo(pbMesh, "Flip Normals"); - // Face.Reverse() flips the normal of each face var reverseMethod = _faceType.GetMethod("Reverse"); if (reverseMethod == null) return new ErrorResponse("Face.Reverse method not found."); @@ -1008,16 +1304,20 @@ private static object MergeFaces(JObject @params) Undo.RegisterCompleteObjectUndo(pbMesh, "Merge Faces"); - var mergeMethod = _mergeElementsType.GetMethod("Merge", - BindingFlags.Static | BindingFlags.Public, - null, - new[] { _proBuilderMeshType, faces.GetType() }, - null); + // Build IEnumerable compatible type + var faceListType = typeof(List<>).MakeGenericType(_faceType); + var faceList = Activator.CreateInstance(faceListType) as System.Collections.IList; + foreach (var f in faces) + faceList.Add(f); + + var mergeMethod = _mergeElementsType.GetMethods(BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault(m => m.Name == "Merge" && m.GetParameters().Length == 2 + && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceListType)); if (mergeMethod == null) return new ErrorResponse("MergeElements.Merge method not found."); - mergeMethod.Invoke(null, new object[] { pbMesh, faces }); + mergeMethod.Invoke(null, new object[] { pbMesh, faceList }); RefreshMesh(pbMesh); return new SuccessResponse($"Merged {faces.Length} face(s)", new @@ -1056,7 +1356,6 @@ private static object CombineMeshes(JObject @params) Undo.RegisterCompleteObjectUndo(pbMeshes[0], "Combine Meshes"); - // Create typed list var listType = typeof(List<>).MakeGenericType(_proBuilderMeshType); var typedList = Activator.CreateInstance(listType) as System.Collections.IList; foreach (var m in pbMeshes) @@ -1145,7 +1444,6 @@ private static object MergeObjects(JObject @params) nonPbObjects.Add(go); } - // Convert non-ProBuilder objects first foreach (var go in nonPbObjects) { var converted = ConvertToProBuilderInternal(go); @@ -1187,6 +1485,77 @@ private static object MergeObjects(JObject @params) }); } + private static object DuplicateAndFlip(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var faces = GetFacesByIndices(pbMesh, props["faceIndices"] ?? props["face_indices"]); + + if (_appendElementsType == null) + return new ErrorResponse("AppendElements type not found."); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Duplicate and Flip"); + + // DuplicateAndFlip(ProBuilderMesh, Face[]) + var faceArrayType = Array.CreateInstance(_faceType, 0).GetType(); + var dupMethod = _appendElementsType.GetMethod("DuplicateAndFlip", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, faceArrayType }, + null); + + if (dupMethod == null) + return new ErrorResponse("AppendElements.DuplicateAndFlip method not found."); + + dupMethod.Invoke(null, new object[] { pbMesh, faces }); + RefreshMesh(pbMesh); + + return new SuccessResponse($"Duplicated and flipped {faces.Length} face(s)", new + { + facesDuplicated = faces.Length, + faceCount = GetFaceCount(pbMesh), + }); + } + + private static object CreatePolygon(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + + var vertexIndicesToken = props["vertexIndices"] ?? props["vertex_indices"]; + if (vertexIndicesToken == null) + return new ErrorResponse("vertexIndices parameter is required."); + + if (_appendElementsType == null) + return new ErrorResponse("AppendElements type not found."); + + var vertexIndices = vertexIndicesToken.ToObject(); + bool unordered = props["unordered"]?.Value() ?? true; + + Undo.RegisterCompleteObjectUndo(pbMesh, "Create Polygon"); + + // CreatePolygon(ProBuilderMesh, IList, bool) + var createPolyMethod = _appendElementsType.GetMethod("CreatePolygon", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, typeof(IList), typeof(bool) }, + null); + + if (createPolyMethod == null) + return new ErrorResponse("AppendElements.CreatePolygon method not found."); + + var result = createPolyMethod.Invoke(null, new object[] { pbMesh, vertexIndices.ToList(), unordered }); + RefreshMesh(pbMesh); + + return new SuccessResponse($"Created polygon from {vertexIndices.Length} vertices", new + { + vertexCount = vertexIndices.Length, + unordered, + faceCreated = result != null, + faceCount = GetFaceCount(pbMesh), + }); + } + // ===================================================================== // Vertex Operations // ===================================================================== @@ -1200,33 +1569,70 @@ private static object MergeVertices(JObject @params) return new ErrorResponse("vertexIndices parameter is required."); var vertexIndices = vertexIndicesToken.ToObject(); + bool collapseToFirst = props["collapseToFirst"]?.Value() + ?? props["collapse_to_first"]?.Value() + ?? false; - Undo.RegisterCompleteObjectUndo(pbMesh, "Merge Vertices"); - - // Use reflection to find the WeldVertices or MergeVertices method - var vertexEditingType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.VertexEditing, Unity.ProBuilder"); - if (vertexEditingType == null) + if (_vertexEditingType == null) return new ErrorResponse("VertexEditing type not found."); - var mergeMethod = vertexEditingType.GetMethod("MergeVertices", - BindingFlags.Static | BindingFlags.Public); + Undo.RegisterCompleteObjectUndo(pbMesh, "Merge Vertices"); - if (mergeMethod == null) - { - // Try WeldVertices - mergeMethod = vertexEditingType.GetMethod("WeldVertices", - BindingFlags.Static | BindingFlags.Public); - } + // MergeVertices(ProBuilderMesh mesh, int[] indexes, bool collapseToFirst = false) + var mergeMethod = _vertexEditingType.GetMethod("MergeVertices", + BindingFlags.Static | BindingFlags.Public); if (mergeMethod == null) - return new ErrorResponse("MergeVertices/WeldVertices method not found."); + return new ErrorResponse("VertexEditing.MergeVertices method not found."); - mergeMethod.Invoke(null, new object[] { pbMesh, vertexIndices, true }); + var result = mergeMethod.Invoke(null, new object[] { pbMesh, vertexIndices, collapseToFirst }); RefreshMesh(pbMesh); return new SuccessResponse($"Merged {vertexIndices.Length} vertices", new { verticesMerged = vertexIndices.Length, + collapseToFirst, + resultIndex = result is int idx ? idx : -1, + vertexCount = GetVertexCount(pbMesh), + }); + } + + private static object WeldVertices(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + var vertexIndicesToken = props["vertexIndices"] ?? props["vertex_indices"]; + if (vertexIndicesToken == null) + return new ErrorResponse("vertexIndices parameter is required."); + + var vertexIndices = vertexIndicesToken.ToObject(); + float neighborRadius = props["radius"]?.Value() + ?? props["neighborRadius"]?.Value() + ?? props["neighbor_radius"]?.Value() + ?? 0.01f; + + if (_vertexEditingType == null) + return new ErrorResponse("VertexEditing type not found."); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Weld Vertices"); + + // WeldVertices(ProBuilderMesh mesh, IEnumerable indexes, float neighborRadius) + var weldMethod = _vertexEditingType.GetMethod("WeldVertices", + BindingFlags.Static | BindingFlags.Public); + + if (weldMethod == null) + return new ErrorResponse("VertexEditing.WeldVertices method not found."); + + var result = weldMethod.Invoke(null, new object[] { pbMesh, vertexIndices.ToList(), neighborRadius }); + RefreshMesh(pbMesh); + + int[] newIndices = result as int[] ?? Array.Empty(); + + return new SuccessResponse($"Welded vertices within radius {neighborRadius}", new + { + inputCount = vertexIndices.Length, + resultCount = newIndices.Length, + radius = neighborRadius, vertexCount = GetVertexCount(pbMesh), }); } @@ -1241,19 +1647,30 @@ private static object SplitVertices(JObject @params) var vertexIndices = vertexIndicesToken.ToObject(); + if (_vertexEditingType == null) + return new ErrorResponse("VertexEditing type not found."); + Undo.RegisterCompleteObjectUndo(pbMesh, "Split Vertices"); - var vertexEditingType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.VertexEditing, Unity.ProBuilder"); - if (vertexEditingType == null) - return new ErrorResponse("VertexEditing type not found."); + // SplitVertices(ProBuilderMesh mesh, IEnumerable vertices) + var splitMethod = _vertexEditingType.GetMethod("SplitVertices", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, typeof(IEnumerable) }, + null); - var splitMethod = vertexEditingType.GetMethod("SplitVertices", - BindingFlags.Static | BindingFlags.Public); + if (splitMethod == null) + { + // Fallback: try any 2-param overload + splitMethod = _vertexEditingType.GetMethods(BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault(m => m.Name == "SplitVertices" && m.GetParameters().Length == 2 + && m.GetParameters()[0].ParameterType == _proBuilderMeshType); + } if (splitMethod == null) - return new ErrorResponse("SplitVertices method not found."); + return new ErrorResponse("VertexEditing.SplitVertices method not found."); - splitMethod.Invoke(null, new object[] { pbMesh, vertexIndices }); + splitMethod.Invoke(null, new object[] { pbMesh, vertexIndices.ToList() }); RefreshMesh(pbMesh); return new SuccessResponse($"Split {vertexIndices.Length} vertices", new @@ -1280,7 +1697,7 @@ private static object MoveVertices(JObject @params) Undo.RegisterCompleteObjectUndo(pbMesh, "Move Vertices"); - // Get positions array and modify + // Get positions via property and modify directly var positionsProperty = _proBuilderMeshType.GetProperty("positions"); if (positionsProperty == null) return new ErrorResponse("Could not access positions property."); @@ -1297,21 +1714,37 @@ private static object MoveVertices(JObject @params) posList[idx] += offset; } - // Set positions back - var setPositionsMethod = _proBuilderMeshType.GetMethod("SetVertices", - BindingFlags.Instance | BindingFlags.Public, - null, - new[] { typeof(IList) }, - null); - - if (setPositionsMethod == null) + // Set positions back via property setter + if (positionsProperty.CanWrite) { - // Try alternative: RebuildWithPositionsAndFaces or direct positions - var posField = _proBuilderMeshType.GetProperty("positions"); - return new ErrorResponse("SetVertices method not found. Use vertex editing tools instead."); + positionsProperty.SetValue(pbMesh, posList); + } + else + { + // Try SetPositions method + var setPositionsMethod = _proBuilderMeshType.GetMethod("SetPositions", + BindingFlags.Instance | BindingFlags.Public); + if (setPositionsMethod != null) + { + setPositionsMethod.Invoke(pbMesh, new object[] { posList.ToArray() }); + } + else + { + // Try RebuildWithPositionsAndFaces + var rebuildMethod = _proBuilderMeshType.GetMethod("RebuildWithPositionsAndFaces", + BindingFlags.Instance | BindingFlags.Public); + if (rebuildMethod != null) + { + var allFaces = GetFacesArray(pbMesh); + rebuildMethod.Invoke(pbMesh, new object[] { posList, allFaces }); + } + else + { + return new ErrorResponse("Cannot set vertex positions on ProBuilderMesh."); + } + } } - setPositionsMethod.Invoke(pbMesh, new object[] { posList }); RefreshMesh(pbMesh); return new SuccessResponse($"Moved {vertexIndices.Length} vertices by ({offset.x}, {offset.y}, {offset.z})", new @@ -1321,6 +1754,281 @@ private static object MoveVertices(JObject @params) }); } + private static object InsertVertex(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + + if (_appendElementsType == null) + return new ErrorResponse("AppendElements type not found."); + + var pointToken = props["point"] ?? props["position"]; + if (pointToken == null) + return new ErrorResponse("point parameter is required ([x,y,z] in local space)."); + + var point = ParseVector3(pointToken); + + Undo.RegisterCompleteObjectUndo(pbMesh, "Insert Vertex"); + + var edgeToken = props["edge"]; + if (edgeToken != null) + { + // InsertVertexOnEdge(ProBuilderMesh mesh, Edge edge, Vector3 point) + int a = edgeToken["a"]?.Value() ?? 0; + int b = edgeToken["b"]?.Value() ?? 0; + var edge = CreateEdge(a, b); + + var insertMethod = _appendElementsType.GetMethod("InsertVertexOnEdge", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, _edgeType, typeof(Vector3) }, + null); + + if (insertMethod == null) + return new ErrorResponse("AppendElements.InsertVertexOnEdge method not found."); + + insertMethod.Invoke(null, new object[] { pbMesh, edge, point }); + } + else + { + var faceIndexToken = props["faceIndex"] ?? props["face_index"]; + if (faceIndexToken == null) + return new ErrorResponse("Either edge ({a,b}) or faceIndex parameter is required."); + + int faceIndex = faceIndexToken.Value(); + var allFaces = (System.Collections.IList)GetFacesArray(pbMesh); + if (faceIndex < 0 || faceIndex >= allFaces.Count) + return new ErrorResponse($"Face index {faceIndex} out of range (0-{allFaces.Count - 1})."); + + var face = allFaces[faceIndex]; + + // InsertVertexInFace(ProBuilderMesh mesh, Face face, Vector3 point) + var insertMethod = _appendElementsType.GetMethod("InsertVertexInFace", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, _faceType, typeof(Vector3) }, + null); + + if (insertMethod == null) + return new ErrorResponse("AppendElements.InsertVertexInFace method not found."); + + insertMethod.Invoke(null, new object[] { pbMesh, face, point }); + } + + RefreshMesh(pbMesh); + + return new SuccessResponse("Inserted vertex", new + { + point = new[] { point.x, point.y, point.z }, + vertexCount = GetVertexCount(pbMesh), + faceCount = GetFaceCount(pbMesh), + }); + } + + private static object AppendVerticesToEdge(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + + if (_appendElementsType == null) + return new ErrorResponse("AppendElements type not found."); + + int count = props["count"]?.Value() ?? 1; + + Undo.RegisterCompleteObjectUndo(pbMesh, "Append Vertices to Edge"); + + int edgeCount; + Array edgeArray; + try + { + edgeArray = ResolveEdges(pbMesh, props, out edgeCount); + } + catch (Exception ex) + { + return new ErrorResponse(ex.Message); + } + + var typedList = ToTypedEdgeList(edgeArray); + var edgeListType = typedList.GetType(); + + // AppendVerticesToEdge(ProBuilderMesh mesh, IList edges, int count) + var appendMethod = _appendElementsType.GetMethod("AppendVerticesToEdge", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { _proBuilderMeshType, edgeListType, typeof(int) }, + null); + + if (appendMethod == null) + { + // Try IList interface match + appendMethod = _appendElementsType.GetMethods(BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault(m => m.Name == "AppendVerticesToEdge" && m.GetParameters().Length == 3 + && m.GetParameters()[2].ParameterType == typeof(int)); + } + + if (appendMethod == null) + return new ErrorResponse("AppendElements.AppendVerticesToEdge method not found."); + + appendMethod.Invoke(null, new object[] { pbMesh, typedList, count }); + RefreshMesh(pbMesh); + + return new SuccessResponse($"Inserted {count} point(s) on {edgeCount} edge(s)", new + { + edgesModified = edgeCount, + pointsPerEdge = count, + vertexCount = GetVertexCount(pbMesh), + faceCount = GetFaceCount(pbMesh), + }); + } + + // ===================================================================== + // Selection + // ===================================================================== + + private static object SelectFaces(JObject @params) + { + var pbMesh = RequireProBuilderMesh(@params); + var props = ExtractProperties(@params); + + var allFaces = GetFacesArray(pbMesh); + var facesList = (System.Collections.IList)allFaces; + var selectedIndices = new List(); + + // Selection by direction + var directionStr = props["direction"]?.ToString(); + if (!string.IsNullOrEmpty(directionStr)) + { + float tolerance = props["tolerance"]?.Value() ?? 0.7f; + Vector3 targetDir; + switch (directionStr.ToLowerInvariant()) + { + case "up": case "top": targetDir = Vector3.up; break; + case "down": case "bottom": targetDir = Vector3.down; break; + case "forward": case "front": targetDir = Vector3.forward; break; + case "back": case "backward": targetDir = Vector3.back; break; + case "left": targetDir = Vector3.left; break; + case "right": targetDir = Vector3.right; break; + default: + return new ErrorResponse($"Unknown direction '{directionStr}'. Valid: up/down/forward/back/left/right"); + } + + for (int i = 0; i < facesList.Count; i++) + { + var normal = ComputeFaceNormal(pbMesh, facesList[i]); + if (Vector3.Dot(normal, targetDir) > tolerance) + selectedIndices.Add(i); + } + } + + // Grow selection from existing faces + var growFromToken = props["growFrom"] ?? props["grow_from"]; + var growAngle = props["growAngle"]?.Value() ?? props["grow_angle"]?.Value() ?? -1f; + if (growFromToken != null && _elementSelectionType != null) + { + var seedFaces = GetFacesByIndices(pbMesh, growFromToken); + var faceListType = typeof(List<>).MakeGenericType(_faceType); + var seedList = Activator.CreateInstance(faceListType) as System.Collections.IList; + foreach (var f in seedFaces) + seedList.Add(f); + + var growMethod = _elementSelectionType.GetMethods(BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault(m => m.Name == "GrowSelection" && m.GetParameters().Length == 3); + + if (growMethod != null) + { + var result = growMethod.Invoke(null, new object[] { pbMesh, seedList, growAngle }); + if (result is System.Collections.IEnumerable resultFaces) + { + foreach (var face in resultFaces) + { + int idx = IndexOfFace(facesList, face); + if (idx >= 0 && !selectedIndices.Contains(idx)) + selectedIndices.Add(idx); + } + } + } + } + + // Flood selection from existing faces + var floodFromToken = props["floodFrom"] ?? props["flood_from"]; + var floodAngle = props["floodAngle"]?.Value() ?? props["flood_angle"]?.Value() ?? 15f; + if (floodFromToken != null && _elementSelectionType != null) + { + var seedFaces = GetFacesByIndices(pbMesh, floodFromToken); + var faceListType = typeof(List<>).MakeGenericType(_faceType); + var seedList = Activator.CreateInstance(faceListType) as System.Collections.IList; + foreach (var f in seedFaces) + seedList.Add(f); + + var floodMethod = _elementSelectionType.GetMethods(BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault(m => m.Name == "FloodSelection" && m.GetParameters().Length == 3); + + if (floodMethod != null) + { + var result = floodMethod.Invoke(null, new object[] { pbMesh, seedList, floodAngle }); + if (result is System.Collections.IEnumerable resultFaces) + { + foreach (var face in resultFaces) + { + int idx = IndexOfFace(facesList, face); + if (idx >= 0 && !selectedIndices.Contains(idx)) + selectedIndices.Add(idx); + } + } + } + } + + // Loop/ring selection + var loopFromToken = props["loopFrom"] ?? props["loop_from"]; + bool ring = props["ring"]?.Value() ?? false; + if (loopFromToken != null && _elementSelectionType != null) + { + var seedFaces = GetFacesByIndices(pbMesh, loopFromToken); + var faceArrayType = Array.CreateInstance(_faceType, 0).GetType(); + + var loopMethod = _elementSelectionType.GetMethods(BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault(m => m.Name == "GetFaceLoop" && m.GetParameters().Length >= 2); + + if (loopMethod != null) + { + object result; + if (loopMethod.GetParameters().Length == 3) + result = loopMethod.Invoke(null, new object[] { pbMesh, seedFaces, ring }); + else + result = loopMethod.Invoke(null, new object[] { pbMesh, seedFaces }); + + if (result is System.Collections.IEnumerable resultFaces) + { + foreach (var face in resultFaces) + { + int idx = IndexOfFace(facesList, face); + if (idx >= 0 && !selectedIndices.Contains(idx)) + selectedIndices.Add(idx); + } + } + } + } + + selectedIndices.Sort(); + + return new SuccessResponse($"Selected {selectedIndices.Count} face(s)", new + { + faceIndices = selectedIndices, + count = selectedIndices.Count, + totalFaces = facesList.Count, + }); + } + + private static int IndexOfFace(System.Collections.IList facesList, object face) + { + for (int i = 0; i < facesList.Count; i++) + { + if (ReferenceEquals(facesList[i], face)) + return i; + } + return -1; + } + // ===================================================================== // UV & Materials // ===================================================================== @@ -1341,7 +2049,6 @@ private static object SetFaceMaterial(JObject @params) Undo.RegisterCompleteObjectUndo(pbMesh, "Set Face Material"); - // ProBuilderMesh.SetMaterial(IEnumerable, Material) var setMaterialMethod = _proBuilderMeshType.GetMethod("SetMaterial", BindingFlags.Instance | BindingFlags.Public); @@ -1360,15 +2067,12 @@ private static object SetFaceMaterial(JObject @params) var submeshIndexProp = _faceType.GetProperty("submeshIndex"); var currentMats = meshRenderer.sharedMaterials; - // Collect unique submesh indices actually used by faces var usedIndices = new SortedSet(); foreach (var f in allFacesList) usedIndices.Add((int)submeshIndexProp.GetValue(f)); - // Only compact if there are unused material slots if (usedIndices.Count < currentMats.Length) { - // Build compacted materials array and remap face indices var remap = new Dictionary(); var newMats = new Material[usedIndices.Count]; int newIdx = 0; @@ -1413,7 +2117,6 @@ private static object SetFaceColor(JObject @params) Undo.RegisterCompleteObjectUndo(pbMesh, "Set Face Color"); - // ProBuilderMesh.SetFaceColor(Face, Color) var setColorMethod = _proBuilderMeshType.GetMethod("SetFaceColor", BindingFlags.Instance | BindingFlags.Public); @@ -1425,7 +2128,6 @@ private static object SetFaceColor(JObject @params) RefreshMesh(pbMesh); - // Auto-swap to vertex-color shader if current material is Standard bool skipSwap = props["skipMaterialSwap"]?.Value() ?? props["skip_material_swap"]?.Value() ?? false; if (!skipSwap) { @@ -1460,7 +2162,6 @@ private static object SetFaceUVs(JObject @params) Undo.RegisterCompleteObjectUndo(pbMesh, "Set Face UVs"); - // AutoUnwrapSettings is a struct on each Face var uvProperty = _faceType.GetProperty("uv"); if (uvProperty == null) return new ErrorResponse("Face.uv property not found."); @@ -1471,7 +2172,6 @@ private static object SetFaceUVs(JObject @params) { var uvSettings = uvProperty.GetValue(face); - // Apply scale var scaleToken = props["scale"]; if (scaleToken != null) { @@ -1483,7 +2183,6 @@ private static object SetFaceUVs(JObject @params) } } - // Apply offset var offsetToken = props["offset"]; if (offsetToken != null) { @@ -1495,7 +2194,6 @@ private static object SetFaceUVs(JObject @params) } } - // Apply rotation var rotationToken = props["rotation"]; if (rotationToken != null) { @@ -1504,7 +2202,6 @@ private static object SetFaceUVs(JObject @params) rotField.SetValue(uvSettings, rotationToken.Value()); } - // Apply flipU var flipUToken = props["flipU"] ?? props["flip_u"]; if (flipUToken != null) { @@ -1513,7 +2210,6 @@ private static object SetFaceUVs(JObject @params) flipUField.SetValue(uvSettings, flipUToken.Value()); } - // Apply flipV var flipVToken = props["flipV"] ?? props["flip_v"]; if (flipVToken != null) { @@ -1525,7 +2221,6 @@ private static object SetFaceUVs(JObject @params) uvProperty.SetValue(face, uvSettings); } - // RefreshUV var refreshUVMethod = _proBuilderMeshType.GetMethod("RefreshUV", BindingFlags.Instance | BindingFlags.Public); if (refreshUVMethod != null) @@ -1555,11 +2250,9 @@ private static object GetMeshInfo(JObject @params) var allFaces = GetFacesArray(pbMesh); var facesList = (System.Collections.IList)allFaces; - // Get bounds var renderer = pbMesh.gameObject.GetComponent(); Bounds bounds = renderer != null ? renderer.bounds : new Bounds(); - // Get materials var materials = new List(); if (renderer != null) { @@ -1567,7 +2260,6 @@ private static object GetMeshInfo(JObject @params) materials.Add(mat != null ? mat.name : "(none)"); } - // Always include summary data var data = new Dictionary { ["gameObjectName"] = pbMesh.gameObject.name, @@ -1582,7 +2274,6 @@ private static object GetMeshInfo(JObject @params) ["materials"] = materials, }; - // Include face details when requested if (include == "faces" || include == "all") { var faceDetails = new List(); @@ -1609,27 +2300,48 @@ private static object GetMeshInfo(JObject @params) data["truncated"] = facesList.Count > 100; } - // Include edge data when requested if (include == "edges" || include == "all") { - var allEdges = CollectAllEdges(pbMesh); - var edgeDetails = new List(); - var aField = _edgeType.GetField("a"); - var bField = _edgeType.GetField("b"); - var aProp = aField == null ? _edgeType.GetProperty("a") : null; - var bProp = bField == null ? _edgeType.GetProperty("b") : null; + var uniqueEdges = CollectUniqueEdges(pbMesh); + + // Get vertex positions for enriched edge data + var positionsProp = _proBuilderMeshType.GetProperty("positions"); + var positions = positionsProp?.GetValue(pbMesh) as IList; - for (int i = 0; i < allEdges.Count && i < 200; i++) + var edgeDetails = new List(); + for (int i = 0; i < uniqueEdges.Count && i < 200; i++) { - var edge = allEdges[i]; - int vertA = aField != null ? (int)aField.GetValue(edge) : - aProp != null ? (int)aProp.GetValue(edge) : -1; - int vertB = bField != null ? (int)bField.GetValue(edge) : - bProp != null ? (int)bProp.GetValue(edge) : -1; - edgeDetails.Add(new { index = i, vertexA = vertA, vertexB = vertB }); + var edge = uniqueEdges[i]; + int vertA = GetEdgeVertexA(edge); + int vertB = GetEdgeVertexB(edge); + + var edgeInfo = new Dictionary + { + ["index"] = i, + ["vertexA"] = vertA, + ["vertexB"] = vertB, + }; + + // Include world-space positions for each endpoint + if (positions != null) + { + if (vertA >= 0 && vertA < positions.Count) + { + var posA = pbMesh.transform.TransformPoint(positions[vertA]); + edgeInfo["positionA"] = new[] { Round(posA.x), Round(posA.y), Round(posA.z) }; + } + if (vertB >= 0 && vertB < positions.Count) + { + var posB = pbMesh.transform.TransformPoint(positions[vertB]); + edgeInfo["positionB"] = new[] { Round(posB.x), Round(posB.y), Round(posB.z) }; + } + } + + edgeDetails.Add(edgeInfo); } data["edges"] = edgeDetails; - data["edgesTruncated"] = allEdges.Count > 200; + data["edgeCount"] = uniqueEdges.Count; + data["edgesTruncated"] = uniqueEdges.Count > 200; } return new SuccessResponse("ProBuilder mesh info", data); @@ -1712,38 +2424,41 @@ private static object ConvertToProBuilder(JObject @params) Undo.RegisterCompleteObjectUndo(go, "Convert to ProBuilder"); - // Add ProBuilderMesh component var pbMesh = go.AddComponent(_proBuilderMeshType); - // Create MeshImporter and import - var importerCtor = _meshImporterType.GetConstructor(new[] { _proBuilderMeshType }); + // Use MeshImporter(Mesh, Material[], ProBuilderMesh) constructor + var renderer = go.GetComponent(); + var materials = renderer != null ? renderer.sharedMaterials : new Material[0]; + var importerCtor = _meshImporterType.GetConstructor( + new[] { typeof(Mesh), typeof(Material[]), _proBuilderMeshType }); + if (importerCtor == null) { - // Try alternative constructor - var importMethod = _meshImporterType.GetMethod("Import", - BindingFlags.Instance | BindingFlags.Public); - - if (importMethod == null) - return new ErrorResponse("MeshImporter could not be initialized."); + // Fall back to MeshImporter(ProBuilderMesh) + importerCtor = _meshImporterType.GetConstructor(new[] { _proBuilderMeshType }); + if (importerCtor == null) + return new ErrorResponse("MeshImporter constructor not found."); } - var importer = importerCtor.Invoke(new object[] { pbMesh }); - var importM = _meshImporterType.GetMethod("Import", - BindingFlags.Instance | BindingFlags.Public, - null, - new[] { typeof(Mesh) }, - null); + object importer; + if (importerCtor.GetParameters().Length == 3) + importer = importerCtor.Invoke(new object[] { meshFilter.sharedMesh, materials, pbMesh }); + else + importer = importerCtor.Invoke(new object[] { pbMesh }); - if (importM == null) - { - // Try with MeshImportSettings - importM = _meshImporterType.GetMethod("Import", - BindingFlags.Instance | BindingFlags.Public); - } + // Find Import() overload with fewest parameters (takes optional MeshImportSettings) + var importM = _meshImporterType.GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Where(m => m.Name == "Import") + .OrderBy(m => m.GetParameters().Length) + .FirstOrDefault(); if (importM != null) { - importM.Invoke(importer, new object[] { meshFilter.sharedMesh }); + var importParams = importM.GetParameters(); + if (importParams.Length == 0) + importM.Invoke(importer, null); + else + importM.Invoke(importer, new object[] { null }); } RefreshMesh(pbMesh); @@ -1757,28 +2472,12 @@ private static object ConvertToProBuilder(JObject @params) } // ===================================================================== - // Edge Collection Helper + // Legacy compatibility: CollectAllEdges (now returns unique edges) // ===================================================================== internal static List CollectAllEdges(Component pbMesh) { - var allFaces = (System.Collections.IList)GetFacesArray(pbMesh); - var allEdges = new List(); - var edgesProp = _faceType.GetProperty("edges"); - - if (allFaces != null && edgesProp != null) - { - foreach (var face in allFaces) - { - var faceEdges = edgesProp.GetValue(face) as System.Collections.IList; - if (faceEdges != null) - { - foreach (var edge in faceEdges) - allEdges.Add(edge); - } - } - } - return allEdges; + return CollectUniqueEdges(pbMesh); } } } diff --git a/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs b/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs index 2841a1e05..6e4d7d337 100644 --- a/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs +++ b/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs @@ -40,22 +40,9 @@ internal static object CenterPivot(JObject @params) for (int i = 0; i < positions.Count; i++) newPositions[i] = (Vector3)positions[i] - localCenter; - // Set positions via reflection - var setPositionsMethod = ManageProBuilder._proBuilderMeshType.GetMethod("SetPositions", - BindingFlags.Instance | BindingFlags.Public); - if (setPositionsMethod == null) - { - // Try property setter via positions - var positionsField = ManageProBuilder._proBuilderMeshType.GetProperty("positions"); - if (positionsField != null && positionsField.CanWrite) - positionsField.SetValue(pbMesh, new List(newPositions)); - else - return new ErrorResponse("Cannot set vertex positions on ProBuilderMesh."); - } - else - { - setPositionsMethod.Invoke(pbMesh, new object[] { newPositions }); - } + // Set positions via property setter + SetVertexPositions(pbMesh, newPositions); + // Move transform to compensate var worldOffset = pbMesh.transform.TransformVector(localCenter); @@ -98,20 +85,7 @@ internal static object FreezeTransform(JObject @params) pbMesh.transform.localScale = Vector3.one; // Set new positions (now in world space = new local space since identity) - var setPositionsMethod = ManageProBuilder._proBuilderMeshType.GetMethod("SetPositions", - BindingFlags.Instance | BindingFlags.Public); - if (setPositionsMethod == null) - { - var positionsProperty = ManageProBuilder._proBuilderMeshType.GetProperty("positions"); - if (positionsProperty != null && positionsProperty.CanWrite) - positionsProperty.SetValue(pbMesh, new List(worldPositions)); - else - return new ErrorResponse("Cannot set vertex positions on ProBuilderMesh."); - } - else - { - setPositionsMethod.Invoke(pbMesh, new object[] { worldPositions }); - } + SetVertexPositions(pbMesh, worldPositions); ManageProBuilder.RefreshMesh(pbMesh); @@ -189,6 +163,56 @@ internal static object ValidateMesh(JObject @params) }); } + internal static object SetPivot(JObject @params) + { + var pbMesh = ManageProBuilder.RequireProBuilderMesh(@params); + var props = ManageProBuilder.ExtractProperties(@params); + + var posToken = props["position"] ?? props["worldPosition"] ?? props["world_position"]; + if (posToken == null) + return new ErrorResponse("position parameter is required ([x,y,z] in world space)."); + + var worldPosition = VectorParsing.ParseVector3OrDefault(posToken); + + Undo.RecordObject(pbMesh, "Set Pivot"); + Undo.RecordObject(pbMesh.transform, "Set Pivot"); + + // SetPivot moves the transform without moving the geometry visually. + // We need to offset vertex positions by the inverse of the transform change. + var positionsProp = ManageProBuilder._proBuilderMeshType.GetProperty("positions"); + var positions = positionsProp?.GetValue(pbMesh) as System.Collections.IList; + if (positions == null || positions.Count == 0) + return new ErrorResponse("Could not read vertex positions."); + + // Calculate delta in local space + var worldDelta = worldPosition - pbMesh.transform.position; + var localDelta = pbMesh.transform.InverseTransformVector(worldDelta); + + // Offset all vertices by -localDelta to keep them in place visually + var newPositions = new Vector3[positions.Count]; + for (int i = 0; i < positions.Count; i++) + newPositions[i] = (Vector3)positions[i] - localDelta; + + SetVertexPositions(pbMesh, newPositions); + + // Move transform to new pivot position + pbMesh.transform.position = worldPosition; + + ManageProBuilder.RefreshMesh(pbMesh); + + return new SuccessResponse("Pivot set to world position", new + { + position = new[] { Round(worldPosition.x), Round(worldPosition.y), Round(worldPosition.z) }, + }); + } + + private static void SetVertexPositions(Component pbMesh, Vector3[] positions) + { + var positionsProp = ManageProBuilder._proBuilderMeshType.GetProperty("positions"); + if (positionsProp != null && positionsProp.CanWrite) + positionsProp.SetValue(pbMesh, new List(positions)); + } + internal static object RepairMesh(JObject @params) { var pbMesh = ManageProBuilder.RequireProBuilderMesh(@params); diff --git a/Server/src/cli/commands/probuilder.py b/Server/src/cli/commands/probuilder.py index b251ce377..e30b124f3 100644 --- a/Server/src/cli/commands/probuilder.py +++ b/Server/src/cli/commands/probuilder.py @@ -49,17 +49,32 @@ def probuilder(): @click.option("--params", "-p", default="{}", help="Shape-specific parameters as JSON.") @handle_unity_errors def create_shape(shape_type: str, name: Optional[str], position, rotation, params: str): - """Create a ProBuilder shape. + """Create a ProBuilder shape with real dimensions. \\b Shape types: Cube, Cylinder, Sphere, Plane, Cone, Torus, Pipe, Arch, Stair, CurvedStair, Door, Prism + Each shape accepts type-specific dimension parameters: + Cube/Prism: width, height, depth (or size for uniform) + Cylinder: radius, height, segments/axisDivisions, heightCuts + Cone: radius, height, segments/subdivAxis + Sphere: radius, subdivisions + Torus: innerRadius, outerRadius, rows, columns + Pipe: radius, height, thickness, segments + Plane: width, height, widthCuts, heightCuts + Stair: width, height, depth, steps, buildSides + CurvedStair: width, height, innerRadius, circumference, steps + Arch: radius, width, depth, angle, radialCuts + Door: width, height, depth, ledgeHeight, legWidth + \\b Examples: unity-mcp probuilder create-shape Cube - unity-mcp probuilder create-shape Torus --name "MyTorus" --params '{"rows": 16, "columns": 16}' - unity-mcp probuilder create-shape Stair --position 0 0 5 --params '{"steps": 10}' + unity-mcp probuilder create-shape Cube --params '{"width": 2, "height": 3, "depth": 1}' + unity-mcp probuilder create-shape Cylinder --params '{"radius": 0.5, "height": 3, "segments": 16}' + unity-mcp probuilder create-shape Torus --name "MyTorus" --params '{"innerRadius": 0.2, "outerRadius": 1}' + unity-mcp probuilder create-shape Stair --position 0 0 5 --params '{"steps": 10, "width": 2}' """ config = get_config() extra = parse_json_dict_or_exit(params, "params") @@ -114,6 +129,334 @@ def create_poly(points: str, height: float, name: Optional[str], flip_normals: b print_success("Created ProBuilder poly shape") +# ============================================================================= +# Mesh Editing +# ============================================================================= + +@probuilder.command("extrude-faces") +@click.argument("target") +@click.option("--faces", required=True, help="Face indices as JSON array, e.g. '[0,1,2]'.") +@click.option("--distance", "-d", type=float, default=0.5, help="Extrusion distance.") +@click.option("--method", type=click.Choice(["FaceNormal", "VertexNormal", "IndividualFaces"]), + default="FaceNormal", help="Extrusion method.") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def extrude_faces(target: str, faces: str, distance: float, method: str, + search_method: Optional[str]): + """Extrude faces of a ProBuilder mesh. + + \\b + Examples: + unity-mcp probuilder extrude-faces "MyCube" --faces '[0]' --distance 1.0 + unity-mcp probuilder extrude-faces "MyCube" --faces '[0,1,2]' --method IndividualFaces + """ + config = get_config() + face_indices = parse_json_list_or_exit(faces, "faces") + + request: dict[str, Any] = { + "action": "extrude_faces", + "target": target, + "faceIndices": face_indices, + "distance": distance, + "method": method, + } + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Extruded faces by {distance}") + + +@probuilder.command("extrude-edges") +@click.argument("target") +@click.option("--edges", required=True, + help='Edge indices as JSON array [0,1] or vertex pairs [{"a":0,"b":1}].') +@click.option("--distance", "-d", type=float, default=0.5, help="Extrusion distance.") +@click.option("--as-group/--no-group", default=True, help="Extrude as group.") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def extrude_edges(target: str, edges: str, distance: float, as_group: bool, + search_method: Optional[str]): + """Extrude edges of a ProBuilder mesh. + + \\b + Edges can be specified as flat indices into the unique edge list, + or as vertex pairs [{a: 0, b: 1}, ...]. + + \\b + Examples: + unity-mcp probuilder extrude-edges "MyCube" --edges '[0,1]' --distance 0.5 + unity-mcp probuilder extrude-edges "MyCube" --edges '[{"a":0,"b":1}]' --distance 1 + """ + config = get_config() + import json + try: + parsed = json.loads(edges) + except json.JSONDecodeError: + print_error("Invalid JSON for edges parameter") + raise SystemExit(1) + + request: dict[str, Any] = { + "action": "extrude_edges", + "target": target, + "distance": distance, + "asGroup": as_group, + } + + if parsed and isinstance(parsed[0], dict): + request["edges"] = parsed + else: + request["edgeIndices"] = parsed + + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Extruded edges by {distance}") + + +@probuilder.command("bevel-edges") +@click.argument("target") +@click.option("--edges", required=True, + help='Edge indices as JSON array [0,1] or vertex pairs [{"a":0,"b":1}].') +@click.option("--amount", "-a", type=float, default=0.1, help="Bevel amount (0-1).") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def bevel_edges(target: str, edges: str, amount: float, search_method: Optional[str]): + """Bevel edges of a ProBuilder mesh. + + \\b + Examples: + unity-mcp probuilder bevel-edges "MyCube" --edges '[0,1,2]' --amount 0.2 + unity-mcp probuilder bevel-edges "MyCube" --edges '[{"a":0,"b":1}]' --amount 0.15 + """ + config = get_config() + import json + try: + parsed = json.loads(edges) + except json.JSONDecodeError: + print_error("Invalid JSON for edges parameter") + raise SystemExit(1) + + request: dict[str, Any] = { + "action": "bevel_edges", + "target": target, + "amount": amount, + } + + if parsed and isinstance(parsed[0], dict): + request["edges"] = parsed + else: + request["edgeIndices"] = parsed + + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Beveled edges with amount {amount}") + + +@probuilder.command("delete-faces") +@click.argument("target") +@click.option("--faces", required=True, help="Face indices as JSON array, e.g. '[0,1,2]'.") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def delete_faces(target: str, faces: str, search_method: Optional[str]): + """Delete faces from a ProBuilder mesh. + + \\b + Examples: + unity-mcp probuilder delete-faces "MyCube" --faces '[0,1]' + """ + config = get_config() + face_indices = parse_json_list_or_exit(faces, "faces") + + request: dict[str, Any] = { + "action": "delete_faces", + "target": target, + "faceIndices": face_indices, + } + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Deleted faces") + + +@probuilder.command("subdivide") +@click.argument("target") +@click.option("--faces", default=None, help="Face indices as JSON array (optional, subdivides all if omitted).") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def subdivide(target: str, faces: Optional[str], search_method: Optional[str]): + """Subdivide faces of a ProBuilder mesh. + + \\b + Examples: + unity-mcp probuilder subdivide "MyCube" + unity-mcp probuilder subdivide "MyCube" --faces '[0,1]' + """ + config = get_config() + + request: dict[str, Any] = { + "action": "subdivide", + "target": target, + } + if faces: + request["faceIndices"] = parse_json_list_or_exit(faces, "faces") + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Subdivided mesh") + + +@probuilder.command("select-faces") +@click.argument("target") +@click.option("--direction", type=click.Choice(["up", "down", "forward", "back", "left", "right"]), + default=None, help="Select faces by normal direction.") +@click.option("--tolerance", type=float, default=0.7, help="Dot product tolerance for direction (0-1).") +@click.option("--grow-from", default=None, help="Face indices to grow selection from (JSON array).") +@click.option("--grow-angle", type=float, default=-1, help="Max angle for grow selection (-1=any).") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def select_faces(target: str, direction: Optional[str], tolerance: float, + grow_from: Optional[str], grow_angle: float, + search_method: Optional[str]): + """Select faces by criteria (direction, grow, flood, loop). + + \\b + Examples: + unity-mcp probuilder select-faces "MyCube" --direction up + unity-mcp probuilder select-faces "MyCube" --direction forward --tolerance 0.9 + unity-mcp probuilder select-faces "MyCube" --grow-from '[0]' --grow-angle 45 + """ + config = get_config() + + request: dict[str, Any] = { + "action": "select_faces", + "target": target, + } + if direction: + request["direction"] = direction + if tolerance != 0.7: + request["tolerance"] = tolerance + if grow_from: + request["growFrom"] = parse_json_list_or_exit(grow_from, "grow-from") + if grow_angle != -1: + request["growAngle"] = grow_angle + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + + +@probuilder.command("move-vertices") +@click.argument("target") +@click.option("--vertices", required=True, help="Vertex indices as JSON array, e.g. '[0,1,2]'.") +@click.option("--offset", nargs=3, type=float, required=True, help="Offset X Y Z.") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def move_vertices(target: str, vertices: str, offset, search_method: Optional[str]): + """Move vertices by an offset. + + \\b + Examples: + unity-mcp probuilder move-vertices "MyCube" --vertices '[0,1,2,3]' --offset 0 1 0 + """ + config = get_config() + vertex_indices = parse_json_list_or_exit(vertices, "vertices") + + request: dict[str, Any] = { + "action": "move_vertices", + "target": target, + "vertexIndices": vertex_indices, + "offset": list(offset), + } + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Moved vertices") + + +@probuilder.command("weld-vertices") +@click.argument("target") +@click.option("--vertices", required=True, help="Vertex indices as JSON array.") +@click.option("--radius", "-r", type=float, default=0.01, help="Neighbor radius for welding.") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def weld_vertices(target: str, vertices: str, radius: float, + search_method: Optional[str]): + """Weld vertices within a proximity radius. + + \\b + Examples: + unity-mcp probuilder weld-vertices "MyCube" --vertices '[0,1,2,3]' --radius 0.1 + """ + config = get_config() + vertex_indices = parse_json_list_or_exit(vertices, "vertices") + + request: dict[str, Any] = { + "action": "weld_vertices", + "target": target, + "vertexIndices": vertex_indices, + "radius": radius, + } + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Welded vertices") + + +@probuilder.command("set-material") +@click.argument("target") +@click.option("--faces", required=True, help="Face indices as JSON array.") +@click.option("--material", "-m", required=True, help="Material asset path.") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def set_material(target: str, faces: str, material: str, + search_method: Optional[str]): + """Assign a material to specific faces. + + \\b + Examples: + unity-mcp probuilder set-material "MyCube" --faces '[0,1]' --material "Assets/Materials/Red.mat" + """ + config = get_config() + face_indices = parse_json_list_or_exit(faces, "faces") + + request: dict[str, Any] = { + "action": "set_face_material", + "target": target, + "faceIndices": face_indices, + "materialPath": material, + } + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Set material on faces") + + # ============================================================================= # Mesh Info # ============================================================================= @@ -127,6 +470,9 @@ def create_poly(points: str, height: float, name: Optional[str], flip_normals: b def mesh_info(target: str, include: str, search_method: Optional[str]): """Get ProBuilder mesh info. + \\b + Edge data now includes world-space vertex positions and uses deduplicated edges. + \\b Examples: unity-mcp probuilder info "MyCube" @@ -232,6 +578,34 @@ def center_pivot(target: str, search_method: Optional[str]): print_success("Pivot centered") +@probuilder.command("set-pivot") +@click.argument("target") +@click.option("--position", nargs=3, type=float, required=True, help="World position X Y Z.") +@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) +@handle_unity_errors +def set_pivot(target: str, position, search_method: Optional[str]): + """Set pivot to an arbitrary world position. + + \\b + Examples: + unity-mcp probuilder set-pivot "MyCube" --position 0 0 0 + unity-mcp probuilder set-pivot "MyCube" --position 1.5 0 2.3 + """ + config = get_config() + request: dict[str, Any] = { + "action": "set_pivot", + "target": target, + "position": list(position), + } + if search_method: + request["searchMethod"] = search_method + + result = run_command("manage_probuilder", _normalize_pb_params(request), config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Pivot set") + + @probuilder.command("freeze-transform") @click.argument("target") @click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None) @@ -314,18 +688,25 @@ def pb_raw(action: str, target: Optional[str], params: str, search_method: Optio create_shape, create_poly_shape, extrude_faces, extrude_edges, bevel_edges, subdivide, delete_faces, bridge_edges, connect_elements, detach_faces, - flip_normals, merge_faces, combine_meshes, - merge_vertices, split_vertices, move_vertices, + flip_normals, merge_faces, combine_meshes, merge_objects, + duplicate_and_flip, create_polygon, + merge_vertices, weld_vertices, split_vertices, move_vertices, + insert_vertex, append_vertices_to_edge, + select_faces, set_face_material, set_face_color, set_face_uvs, get_mesh_info, convert_to_probuilder, set_smoothing, auto_smooth, - center_pivot, freeze_transform, validate_mesh, repair_mesh + center_pivot, set_pivot, freeze_transform, validate_mesh, repair_mesh \\b Examples: unity-mcp probuilder raw extrude_faces "MyCube" --params '{"faceIndices": [0], "distance": 1.0}' - unity-mcp probuilder raw bevel_edges "MyCube" --params '{"edgeIndices": [0,1], "amount": 0.2}' - unity-mcp probuilder raw set_face_material "MyCube" --params '{"faceIndices": [0], "materialPath": "Assets/Materials/Red.mat"}' + unity-mcp probuilder raw bevel_edges "MyCube" --params '{"edges": [{"a":0,"b":1}], "amount": 0.2}' + unity-mcp probuilder raw detach_faces "MyCube" --params '{"faceIndices": [0], "deleteSourceFaces": true}' + unity-mcp probuilder raw weld_vertices "MyCube" --params '{"vertexIndices": [0,1,2], "radius": 0.1}' + unity-mcp probuilder raw select_faces "MyCube" --params '{"direction": "up", "tolerance": 0.9}' + unity-mcp probuilder raw insert_vertex "MyCube" --params '{"edge": {"a":0,"b":1}, "point": [0.5,0,0]}' + unity-mcp probuilder raw set_pivot "MyCube" --params '{"position": [0, 0, 0]}' """ config = get_config() extra = parse_json_dict_or_exit(params, "params") diff --git a/Server/src/services/tools/manage_probuilder.py b/Server/src/services/tools/manage_probuilder.py index 54457a1d4..575309de5 100644 --- a/Server/src/services/tools/manage_probuilder.py +++ b/Server/src/services/tools/manage_probuilder.py @@ -17,10 +17,16 @@ "extrude_faces", "extrude_edges", "bevel_edges", "subdivide", "delete_faces", "bridge_edges", "connect_elements", "detach_faces", "flip_normals", "merge_faces", "combine_meshes", "merge_objects", + "duplicate_and_flip", "create_polygon", ] VERTEX_ACTIONS = [ - "merge_vertices", "split_vertices", "move_vertices", + "merge_vertices", "weld_vertices", "split_vertices", "move_vertices", + "insert_vertex", "append_vertices_to_edge", +] + +SELECTION_ACTIONS = [ + "select_faces", ] UV_MATERIAL_ACTIONS = [ @@ -33,10 +39,10 @@ SMOOTHING_ACTIONS = ["set_smoothing", "auto_smooth"] -UTILITY_ACTIONS = ["center_pivot", "freeze_transform", "validate_mesh", "repair_mesh"] +UTILITY_ACTIONS = ["center_pivot", "freeze_transform", "set_pivot", "validate_mesh", "repair_mesh"] ALL_ACTIONS = ( - ["ping"] + SHAPE_ACTIONS + MESH_ACTIONS + VERTEX_ACTIONS + ["ping"] + SHAPE_ACTIONS + MESH_ACTIONS + VERTEX_ACTIONS + SELECTION_ACTIONS + UV_MATERIAL_ACTIONS + QUERY_ACTIONS + SMOOTHING_ACTIONS + UTILITY_ACTIONS ) @@ -73,21 +79,30 @@ def _normalize_probuilder_params(params: dict[str, Any]) -> dict[str, Any]: "extrudeHeight, flipNormals).\n\n" "MESH EDITING:\n" "- extrude_faces: Extrude faces outward (faceIndices, distance, method: FaceNormal/VertexNormal/IndividualFaces).\n" - "- extrude_edges: Extrude edges (edgeIndices, distance, asGroup).\n" - "- bevel_edges: Bevel edges (edgeIndices, amount 0-1).\n" + "- extrude_edges: Extrude edges (edgeIndices or edges [{a,b},...], distance, asGroup).\n" + "- bevel_edges: Bevel edges (edgeIndices or edges [{a,b},...], amount 0-1).\n" "- subdivide: Subdivide faces (faceIndices optional, all if omitted).\n" "- delete_faces: Delete faces (faceIndices).\n" - "- bridge_edges: Bridge two open edges (edgeA, edgeB as {a,b} vertex index pairs).\n" - "- connect_elements: Connect edges or faces (edgeIndices or faceIndices).\n" - "- detach_faces: Detach faces to new object (faceIndices, deleteSource).\n" + "- bridge_edges: Bridge two open edges (edgeA, edgeB as {a,b} pairs, allowNonManifold).\n" + "- connect_elements: Connect edges or faces (edgeIndices/edges or faceIndices).\n" + "- detach_faces: Detach faces (faceIndices, deleteSourceFaces: bool).\n" "- flip_normals: Flip face normals (faceIndices).\n" "- merge_faces: Merge faces into one (faceIndices).\n" "- combine_meshes: Combine multiple ProBuilder objects (targets: list of GameObjects).\n" - "- merge_objects: Merge multiple objects into one ProBuilder mesh (targets list, auto-converts non-ProBuilder objects).\n\n" + "- merge_objects: Merge objects into one ProBuilder mesh (targets list, auto-converts).\n" + "- duplicate_and_flip: Create double-sided geometry (faceIndices).\n" + "- create_polygon: Connect existing vertices into a new face (vertexIndices, unordered).\n\n" "VERTEX OPERATIONS:\n" - "- merge_vertices: Merge/weld vertices (vertexIndices).\n" + "- merge_vertices: Collapse vertices to single point (vertexIndices, collapseToFirst).\n" + "- weld_vertices: Weld vertices within proximity radius (vertexIndices, radius).\n" "- split_vertices: Split shared vertices (vertexIndices).\n" - "- move_vertices: Translate vertices (vertexIndices, offset [x,y,z]).\n\n" + "- move_vertices: Translate vertices (vertexIndices, offset [x,y,z]).\n" + "- insert_vertex: Insert vertex on edge ({a,b}) or face (faceIndex) at point [x,y,z].\n" + "- append_vertices_to_edge: Insert evenly-spaced points on edges (edgeIndices/edges, count).\n\n" + "SELECTION:\n" + "- select_faces: Select faces by criteria (direction: up/down/forward/back/left/right, " + "tolerance, growFrom, growAngle, floodFrom, floodAngle, loopFrom, ring). " + "Returns faceIndices array for use with other actions.\n\n" "UV & MATERIALS:\n" "- set_face_material: Assign material to faces (faceIndices optional — all faces when omitted, materialPath).\n" "- set_face_color: Set vertex color on faces (faceIndices optional — all faces when omitted, color [r,g,b,a]).\n" @@ -103,6 +118,7 @@ def _normalize_probuilder_params(params: dict[str, Any]) -> dict[str, Any]: "- auto_smooth: Auto-assign smoothing groups by angle (angleThreshold: default 30).\n\n" "MESH UTILITIES:\n" "- center_pivot: Move pivot point to mesh bounds center.\n" + "- set_pivot: Set pivot to arbitrary world position (position [x,y,z]).\n" "- freeze_transform: Bake position/rotation/scale into vertex data, reset transform.\n" "- validate_mesh: Check mesh health (degenerate triangles, unused vertices). Read-only.\n" "- repair_mesh: Auto-fix degenerate triangles and unused vertices.\n\n" @@ -138,6 +154,7 @@ async def manage_probuilder( "Shape creation": SHAPE_ACTIONS, "Mesh editing": MESH_ACTIONS, "Vertex operations": VERTEX_ACTIONS, + "Selection": SELECTION_ACTIONS, "UV & materials": UV_MATERIAL_ACTIONS, "Query": QUERY_ACTIONS, "Smoothing": SMOOTHING_ACTIONS, diff --git a/Server/tests/test_manage_probuilder.py b/Server/tests/test_manage_probuilder.py index e6496c7de..32f4051f3 100644 --- a/Server/tests/test_manage_probuilder.py +++ b/Server/tests/test_manage_probuilder.py @@ -12,6 +12,7 @@ SHAPE_ACTIONS, MESH_ACTIONS, VERTEX_ACTIONS, + SELECTION_ACTIONS, UV_MATERIAL_ACTIONS, QUERY_ACTIONS, SMOOTHING_ACTIONS, @@ -51,7 +52,7 @@ async def fake_send(send_fn, unity_instance, tool_name, params): def test_all_actions_is_union_of_sub_lists(): expected = set( - ["ping"] + SHAPE_ACTIONS + MESH_ACTIONS + VERTEX_ACTIONS + ["ping"] + SHAPE_ACTIONS + MESH_ACTIONS + VERTEX_ACTIONS + SELECTION_ACTIONS + UV_MATERIAL_ACTIONS + QUERY_ACTIONS + SMOOTHING_ACTIONS + UTILITY_ACTIONS ) assert set(ALL_ACTIONS) == expected @@ -463,3 +464,209 @@ def test_get_mesh_info_include_param_passthrough(mock_unity): assert result["success"] is True assert mock_unity["params"]["action"] == "get_mesh_info" assert mock_unity["params"]["properties"]["include"] == "faces" + + +# --------------------------------------------------------------------------- +# New actions: mesh editing additions +# --------------------------------------------------------------------------- + +def test_duplicate_and_flip_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="duplicate_and_flip", + target="MyCube", + properties={"faceIndices": [0, 1]}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "duplicate_and_flip" + assert mock_unity["params"]["properties"]["faceIndices"] == [0, 1] + + +def test_create_polygon_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="create_polygon", + target="MyCube", + properties={"vertexIndices": [0, 1, 2, 3], "unordered": True}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "create_polygon" + assert mock_unity["params"]["properties"]["vertexIndices"] == [0, 1, 2, 3] + + +# --------------------------------------------------------------------------- +# New actions: vertex operations +# --------------------------------------------------------------------------- + +def test_weld_vertices_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="weld_vertices", + target="MyCube", + properties={"vertexIndices": [0, 1, 2], "radius": 0.05}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "weld_vertices" + assert mock_unity["params"]["properties"]["radius"] == 0.05 + + +def test_insert_vertex_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="insert_vertex", + target="MyCube", + properties={"edge": {"a": 0, "b": 1}, "point": [0.5, 0, 0]}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "insert_vertex" + assert mock_unity["params"]["properties"]["edge"] == {"a": 0, "b": 1} + + +def test_append_vertices_to_edge_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="append_vertices_to_edge", + target="MyCube", + properties={"edgeIndices": [0, 1], "count": 3}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "append_vertices_to_edge" + assert mock_unity["params"]["properties"]["count"] == 3 + + +# --------------------------------------------------------------------------- +# New actions: selection +# --------------------------------------------------------------------------- + +def test_select_faces_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="select_faces", + target="MyCube", + properties={"direction": "up", "tolerance": 0.9}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "select_faces" + assert mock_unity["params"]["properties"]["direction"] == "up" + assert mock_unity["params"]["properties"]["tolerance"] == 0.9 + + +def test_selection_actions_in_all(): + for action in SELECTION_ACTIONS: + assert action in ALL_ACTIONS, f"{action} should be in ALL_ACTIONS" + + +# --------------------------------------------------------------------------- +# New actions: utility +# --------------------------------------------------------------------------- + +def test_set_pivot_sends_correct_params(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="set_pivot", + target="MyCube", + properties={"position": [1.5, 0, 2.3]}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "set_pivot" + assert mock_unity["params"]["properties"]["position"] == [1.5, 0, 2.3] + + +# --------------------------------------------------------------------------- +# Edge specification by vertex pairs +# --------------------------------------------------------------------------- + +def test_bevel_edges_with_vertex_pairs(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="bevel_edges", + target="MyCube", + properties={"edges": [{"a": 0, "b": 1}, {"a": 2, "b": 3}], "amount": 0.15}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "bevel_edges" + assert mock_unity["params"]["properties"]["edges"] == [{"a": 0, "b": 1}, {"a": 2, "b": 3}] + + +def test_extrude_edges_with_vertex_pairs(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="extrude_edges", + target="MyCube", + properties={"edges": [{"a": 0, "b": 1}], "distance": 0.5}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["properties"]["edges"] == [{"a": 0, "b": 1}] + + +# --------------------------------------------------------------------------- +# Detach faces with deleteSourceFaces +# --------------------------------------------------------------------------- + +def test_detach_faces_with_delete_source(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="detach_faces", + target="MyCube", + properties={"faceIndices": [0], "deleteSourceFaces": True}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["properties"]["deleteSourceFaces"] is True + + +# --------------------------------------------------------------------------- +# Bridge edges with allowNonManifold +# --------------------------------------------------------------------------- + +def test_bridge_edges_with_allow_non_manifold(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="bridge_edges", + target="MyCube", + properties={ + "edgeA": {"a": 0, "b": 1}, + "edgeB": {"a": 2, "b": 3}, + "allowNonManifold": True, + }, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["properties"]["allowNonManifold"] is True + + +# --------------------------------------------------------------------------- +# Merge vertices with collapseToFirst +# --------------------------------------------------------------------------- + +def test_merge_vertices_with_collapse_to_first(mock_unity): + result = asyncio.run( + manage_probuilder( + SimpleNamespace(), + action="merge_vertices", + target="MyCube", + properties={"vertexIndices": [0, 1], "collapseToFirst": True}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["properties"]["collapseToFirst"] is True diff --git a/unity-mcp-skill/SKILL.md b/unity-mcp-skill/SKILL.md index ffc779189..04942a0f8 100644 --- a/unity-mcp-skill/SKILL.md +++ b/unity-mcp-skill/SKILL.md @@ -15,20 +15,6 @@ Before applying a template: - Validate targets/components first via resources and `find_gameobjects`. - Treat names, enum values, and property payloads as placeholders to adapt. -## Resource URIs: Do NOT Guess - -Resource URIs use a specific scheme — do NOT guess or fabricate them. If you are unsure of a URI, call `ListMcpResourcesTool(server="UnityMCP")` first to get the exact list. Common URIs: - -| Resource | URI | -|----------|-----| -| Editor state | `mcpforunity://editor/state` | -| Project info | `mcpforunity://project/info` | -| Scene GameObject API | `mcpforunity://scene/gameobject-api` | -| Tags | `mcpforunity://project/tags` | -| Layers | `mcpforunity://project/layers` | -| Instances | `mcpforunity://instances` | -| Custom tools | `mcpforunity://custom-tools` | - ## Quick Start: Resource-First Workflow **Always read relevant resources before using tools.** This prevents errors and provides the necessary context. @@ -71,69 +57,32 @@ batch_execute( ### 3. Use Screenshots to Verify Visual Results -#### Screenshot Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `camera` | string | Camera name/path/ID. Defaults to `Camera.main` | -| `include_image` | bool | Return base64 PNG inline (for AI vision) | -| `max_resolution` | int | Max longest-edge pixels (default 640). Lower = smaller payload | -| `supersize` | int | Resolution multiplier 1–4 for file-saved screenshots | -| `batch` | string | `"surround"` (6 fixed angles) or `"orbit"` (configurable grid) | -| `look_at` | string | Target: GameObject name/path/ID, or `"x,y,z"` world position | -| `view_position` | list | Camera position `[x,y,z]` for positioned screenshot | -| `view_rotation` | list | Camera euler rotation `[x,y,z]` for positioned screenshot | -| `orbit_angles` | int | Number of azimuth samples around the target (default 8) | -| `orbit_elevations` | list | Vertical angles in degrees, e.g. `[0, 30, -15]` (default `[0, 30, -15]`) | -| `orbit_distance` | float | Camera distance from target in world units (auto-calculated if omitted) | -| `orbit_fov` | float | Camera field of view in degrees (default 60) | - -#### Single Screenshots - ```python -# Basic screenshot (saves to Assets/Screenshots/, returns file path) +# Basic screenshot (saves to Assets/, returns file path only) manage_scene(action="screenshot") # Inline screenshot (returns base64 PNG directly to the AI) manage_scene(action="screenshot", include_image=True) -# Specific camera + capped resolution for smaller payloads +# Use a specific camera and cap resolution for smaller payloads manage_scene(action="screenshot", camera="MainCamera", include_image=True, max_resolution=512) -# Positioned screenshot: place a temp camera at a specific viewpoint -manage_scene(action="screenshot", look_at="Player", view_position=[0, 10, -10], max_resolution=512) -``` - -#### Batch Screenshots (Contact Sheet) - -Batch modes return a **single composite contact sheet** image — a grid of labeled thumbnails — instead of separate files. This is ideal for AI scene understanding in one image. - -```python -# Surround: 6 fixed angles (front/back/left/right/top/bird_eye) +# Batch surround: captures front/back/left/right/top/bird_eye around the scene manage_scene(action="screenshot", batch="surround", max_resolution=256) -# Surround centered on a specific object +# Batch surround centered on a specific object manage_scene(action="screenshot", batch="surround", look_at="Player", max_resolution=256) -# Orbit: 8 angles at eye level around an object -manage_scene(action="screenshot", batch="orbit", look_at="Player", orbit_angles=8) - -# Orbit: 10 angles, 3 elevation rings, custom distance -manage_scene(action="screenshot", batch="orbit", look_at="Player", - orbit_angles=10, orbit_elevations=[0, 30, -15], orbit_distance=8) - -# Orbit: tight close-up with narrow FOV -manage_scene(action="screenshot", batch="orbit", look_at="Treasure", - orbit_distance=3, orbit_fov=40, orbit_angles=6) +# Positioned screenshot: place a temp camera and capture in one call +manage_scene(action="screenshot", look_at="Player", view_position=[0, 10, -10], max_resolution=512) ``` **Best practices for AI scene understanding:** - Use `include_image=True` when you need to *see* the scene, not just save a file. -- Use `batch="surround"` for a quick 6-angle overview of the whole scene. -- Use `batch="orbit"` for detailed inspection of a specific object from many angles. +- Use `batch="surround"` for a comprehensive overview (6 angles, one command). +- Use `look_at`/`view_position` to capture from a specific viewpoint without needing a scene camera. - Keep `max_resolution` at 256–512 to balance quality vs. token cost. -- Use `orbit_elevations` to get views from above/below, not just around. -- Omit `orbit_distance` to let Unity auto-fit the object in frame. +- Combine with `look_at` on `manage_gameobject` to orient a game camera before capturing. ```python # Agentic camera loop: point, shoot, analyze @@ -209,6 +158,7 @@ uri="file:///full/path/to/file.cs" | **Editor** | `manage_editor`, `execute_menu_item`, `read_console` | Editor control | | **Testing** | `run_tests`, `get_test_job` | Unity Test Framework | | **Batch** | `batch_execute` | Parallel/bulk operations | +| **ProBuilder** | `manage_probuilder` | 3D modeling, mesh editing, complex geometry. **When `com.unity.probuilder` is installed, prefer ProBuilder shapes over primitive GameObjects** for editable geometry, multi-material faces, or complex shapes. Supports 13 shape types, face/edge/vertex editing, smoothing, and per-face materials. See [ProBuilder Guide](references/probuilder-guide.md). | | **UI** | `manage_ui`, `batch_execute` with `manage_gameobject` + `manage_components` | **UI Toolkit**: Use `manage_ui` to create UXML/USS files, attach UIDocument, inspect visual trees. **uGUI (Canvas)**: Use `batch_execute` for Canvas, Panel, Button, Text, Slider, Toggle, Input Field. **Read `mcpforunity://project/info` first** to detect uGUI/TMP/Input System/UI Toolkit availability. (see [UI workflows](references/workflows.md#ui-creation-workflows)) | ## Common Workflows diff --git a/unity-mcp-skill/references/probuilder-guide.md b/unity-mcp-skill/references/probuilder-guide.md new file mode 100644 index 000000000..722d6d446 --- /dev/null +++ b/unity-mcp-skill/references/probuilder-guide.md @@ -0,0 +1,444 @@ +# ProBuilder Workflow Guide + +Patterns and best practices for AI-driven ProBuilder mesh editing through MCP for Unity. + +## Availability + +ProBuilder is an **optional** Unity package (`com.unity.probuilder`). Check `mcpforunity://project/info` or call `manage_probuilder(action="ping")` to verify it's installed before using any ProBuilder tools. If available, **prefer ProBuilder over primitive GameObjects** for any geometry that needs editing, multi-material faces, or non-trivial shapes. + +## Core Workflow: Always Get Info First + +Before any mesh edit, call `get_mesh_info` with `include='faces'` to understand the geometry: + +```python +# Step 1: Get face info with directions +result = manage_probuilder(action="get_mesh_info", target="MyCube", + properties={"include": "faces"}) + +# Response includes per-face: +# index: 0, normal: [0, 1, 0], center: [0, 0.5, 0], direction: "top" +# index: 1, normal: [0, -1, 0], center: [0, -0.5, 0], direction: "bottom" +# index: 2, normal: [0, 0, 1], center: [0, 0, 0.5], direction: "front" +# ... + +# Step 2: Use the direction labels to pick faces +# Want to extrude the top? Find the face with direction="top" +manage_probuilder(action="extrude_faces", target="MyCube", + properties={"faceIndices": [0], "distance": 1.5}) +``` + +### Include Parameter + +| Value | Returns | Use When | +|-------|---------|----------| +| `"summary"` | Counts, bounds, materials | Quick check / validation | +| `"faces"` | + normals, centers, directions | Selecting faces for editing | +| `"edges"` | + edge vertex pairs with world positions (max 200) | Edge-based operations | +| `"all"` | Everything | Full mesh analysis | + +## Shape Creation + +### All 13 Shape Types + +```python +# Basic shapes +manage_probuilder(action="create_shape", properties={"shape_type": "Cube", "name": "MyCube"}) +manage_probuilder(action="create_shape", properties={"shape_type": "Sphere", "name": "MySphere"}) +manage_probuilder(action="create_shape", properties={"shape_type": "Cylinder", "name": "MyCyl"}) +manage_probuilder(action="create_shape", properties={"shape_type": "Plane", "name": "MyPlane"}) +manage_probuilder(action="create_shape", properties={"shape_type": "Cone", "name": "MyCone"}) +manage_probuilder(action="create_shape", properties={"shape_type": "Prism", "name": "MyPrism"}) + +# Parametric shapes +manage_probuilder(action="create_shape", properties={ + "shape_type": "Torus", "name": "MyTorus", + "rows": 16, "columns": 24, "innerRadius": 0.5, "outerRadius": 1.0 +}) +manage_probuilder(action="create_shape", properties={ + "shape_type": "Pipe", "name": "MyPipe", + "radius": 1.0, "height": 2.0, "thickness": 0.2 +}) +manage_probuilder(action="create_shape", properties={ + "shape_type": "Arch", "name": "MyArch", + "radius": 2.0, "angle": 180, "segments": 12 +}) + +# Architectural shapes +manage_probuilder(action="create_shape", properties={ + "shape_type": "Stair", "name": "MyStairs", "steps": 10 +}) +manage_probuilder(action="create_shape", properties={ + "shape_type": "CurvedStair", "name": "Spiral", "steps": 12 +}) +manage_probuilder(action="create_shape", properties={ + "shape_type": "Door", "name": "MyDoor" +}) + +# Custom polygon +manage_probuilder(action="create_poly_shape", properties={ + "points": [[0,0,0], [5,0,0], [5,0,5], [2.5,0,7], [0,0,5]], + "extrudeHeight": 3.0, "name": "Pentagon" +}) +``` + +## Common Editing Operations + +### Extrude a Roof + +```python +# 1. Create a building base +manage_probuilder(action="create_shape", properties={ + "shape_type": "Cube", "name": "Building", "size": [4, 3, 6] +}) + +# 2. Find the top face +info = manage_probuilder(action="get_mesh_info", target="Building", + properties={"include": "faces"}) +# Find face with direction="top" -> e.g. index 2 + +# 3. Extrude upward for a flat roof extension +manage_probuilder(action="extrude_faces", target="Building", + properties={"faceIndices": [2], "distance": 0.5}) +``` + +### Cut a Hole (Delete Faces) + +```python +# 1. Get face info +info = manage_probuilder(action="get_mesh_info", target="Wall", + properties={"include": "faces"}) +# Find the face with direction="front" -> e.g. index 4 + +# 2. Subdivide to create more faces +manage_probuilder(action="subdivide", target="Wall", + properties={"faceIndices": [4]}) + +# 3. Get updated face info (indices changed after subdivide!) +info = manage_probuilder(action="get_mesh_info", target="Wall", + properties={"include": "faces"}) + +# 4. Delete the center face(s) for the hole +manage_probuilder(action="delete_faces", target="Wall", + properties={"faceIndices": [6]}) +``` + +### Bevel Edges + +```python +# Get edge info +info = manage_probuilder(action="get_mesh_info", target="MyCube", + properties={"include": "edges"}) + +# Bevel specific edges +manage_probuilder(action="bevel_edges", target="MyCube", + properties={"edgeIndices": [0, 1, 2, 3], "amount": 0.1}) +``` + +### Detach Faces to New Object + +```python +# Detach and keep original (default) +manage_probuilder(action="detach_faces", target="MyCube", + properties={"faceIndices": [0, 1], "deleteSourceFaces": false}) + +# Detach and remove from source +manage_probuilder(action="detach_faces", target="MyCube", + properties={"faceIndices": [0, 1], "deleteSourceFaces": true}) +``` + +### Select Faces by Direction + +```python +# Select all upward-facing faces +manage_probuilder(action="select_faces", target="MyMesh", + properties={"direction": "up", "tolerance": 0.7}) + +# Grow selection from a seed face +manage_probuilder(action="select_faces", target="MyMesh", + properties={"growFrom": [0], "growAngle": 45}) +``` + +### Double-Sided Geometry + +```python +# Create inside faces for a room (duplicate and flip normals) +manage_probuilder(action="duplicate_and_flip", target="Room", + properties={"faceIndices": [0, 1, 2, 3, 4, 5]}) +``` + +### Create Polygon from Existing Vertices + +```python +# Connect existing vertices into a new face (auto-finds winding order) +manage_probuilder(action="create_polygon", target="MyMesh", + properties={"vertexIndices": [0, 3, 7, 4]}) +``` + +## Vertex Operations + +```python +# Move vertices by offset +manage_probuilder(action="move_vertices", target="MyCube", + properties={"vertexIndices": [0, 1, 2, 3], "offset": [0, 1, 0]}) + +# Weld nearby vertices (proximity-based merge) +manage_probuilder(action="weld_vertices", target="MyCube", + properties={"vertexIndices": [0, 1, 2, 3], "radius": 0.1}) + +# Insert vertex on an edge +manage_probuilder(action="insert_vertex", target="MyCube", + properties={"edge": {"a": 0, "b": 1}, "point": [0.5, 0, 0]}) + +# Add evenly-spaced points along edges +manage_probuilder(action="append_vertices_to_edge", target="MyCube", + properties={"edgeIndices": [0, 1], "count": 3}) +``` + +## Smoothing Workflow + +### Auto-Smooth (Recommended Default) + +```python +# Apply auto-smoothing with default 30 degree threshold +manage_probuilder(action="auto_smooth", target="MyMesh", + properties={"angleThreshold": 30}) +``` + +- **Low angle (15-25)**: More hard edges, faceted look +- **Medium angle (30-45)**: Good default, smooth curves + sharp corners +- **High angle (60-80)**: Very smooth, only sharpest edges remain hard + +### Manual Smoothing Groups + +```python +# Set specific faces to smooth group 1 +manage_probuilder(action="set_smoothing", target="MyMesh", + properties={"faceIndices": [0, 1, 2], "smoothingGroup": 1}) + +# Set other faces to hard edges (group 0) +manage_probuilder(action="set_smoothing", target="MyMesh", + properties={"faceIndices": [3, 4, 5], "smoothingGroup": 0}) +``` + +## Mesh Cleanup Pattern + +After editing, always clean up: + +```python +# 1. Center the pivot (important after extrusions that shift geometry) +manage_probuilder(action="center_pivot", target="MyMesh") + +# 2. Optionally freeze transform if you moved/rotated the object +manage_probuilder(action="freeze_transform", target="MyMesh") + +# 3. Validate mesh health +result = manage_probuilder(action="validate_mesh", target="MyMesh") +# Check result.data.healthy -- if false, repair + +# 4. Auto-repair if needed +manage_probuilder(action="repair_mesh", target="MyMesh") +``` + +## Building Complex Objects with ProBuilder + +When ProBuilder is available, prefer it over primitive GameObjects for complex geometry. ProBuilder lets you create, edit, and combine shapes into detailed objects without external 3D tools. + +### Example: Simple House + +```python +# 1. Create base building +manage_probuilder(action="create_shape", properties={ + "shape_type": "Cube", "name": "House", "width": 6, "height": 3, "depth": 8 +}) + +# 2. Get face info to find the top face +info = manage_probuilder(action="get_mesh_info", target="House", + properties={"include": "faces"}) +# Find direction="top" -> e.g. index 2 + +# 3. Extrude the top face to create a flat raised section +manage_probuilder(action="extrude_faces", target="House", + properties={"faceIndices": [2], "distance": 0.3}) + +# 4. Re-query faces, then move top vertices inward to form a ridge +info = manage_probuilder(action="get_mesh_info", target="House", + properties={"include": "faces"}) +# Find the new top face after extrude, get its vertex indices +# Move them to form a peaked roof shape +manage_probuilder(action="move_vertices", target="House", + properties={"vertexIndices": [0, 1, 2, 3], "offset": [0, 2, 0]}) + +# 5. Cut a doorway: subdivide front face, delete center sub-face +info = manage_probuilder(action="get_mesh_info", target="House", + properties={"include": "faces"}) +# Find direction="front", subdivide it +manage_probuilder(action="subdivide", target="House", + properties={"faceIndices": [4]}) + +# Re-query, find bottom-center face, delete it +info = manage_probuilder(action="get_mesh_info", target="House", + properties={"include": "faces"}) +manage_probuilder(action="delete_faces", target="House", + properties={"faceIndices": [12]}) + +# 6. Add a door frame with arch +manage_probuilder(action="create_shape", properties={ + "shape_type": "Door", "name": "Doorway", + "position": [0, 0, 4], "width": 1.5, "height": 2.5 +}) + +# 7. Add stairs to the door +manage_probuilder(action="create_shape", properties={ + "shape_type": "Stair", "name": "FrontSteps", + "position": [0, 0, 5], "steps": 3, "width": 2 +}) + +# 8. Smooth organic parts, keep architectural edges sharp +manage_probuilder(action="auto_smooth", target="House", + properties={"angleThreshold": 30}) + +# 9. Assign materials per face +manage_probuilder(action="set_face_material", target="House", + properties={"faceIndices": [0, 1, 2, 3], "materialPath": "Assets/Materials/Brick.mat"}) +manage_probuilder(action="set_face_material", target="House", + properties={"faceIndices": [4, 5], "materialPath": "Assets/Materials/Roof.mat"}) + +# 10. Cleanup +manage_probuilder(action="center_pivot", target="House") +manage_probuilder(action="validate_mesh", target="House") +``` + +### Example: Pillared Corridor (Batch) + +```python +# Create multiple columns efficiently +batch_execute(commands=[ + {"tool": "manage_probuilder", "params": { + "action": "create_shape", + "properties": {"shape_type": "Cylinder", "name": f"Pillar_{i}", + "radius": 0.3, "height": 4, "segments": 12, + "position": [i * 3, 0, 0]} + }} for i in range(6) +] + [ + # Floor + {"tool": "manage_probuilder", "params": { + "action": "create_shape", + "properties": {"shape_type": "Plane", "name": "Floor", + "width": 18, "height": 6, "position": [7.5, 0, 0]} + }}, + # Ceiling + {"tool": "manage_probuilder", "params": { + "action": "create_shape", + "properties": {"shape_type": "Plane", "name": "Ceiling", + "width": 18, "height": 6, "position": [7.5, 4, 0]} + }}, +]) + +# Bevel all pillar tops for decoration +for i in range(6): + info = manage_probuilder(action="get_mesh_info", target=f"Pillar_{i}", + properties={"include": "edges"}) + # Find top ring edges, bevel them + manage_probuilder(action="bevel_edges", target=f"Pillar_{i}", + properties={"edgeIndices": [0, 1, 2, 3], "amount": 0.05}) + +# Smooth the pillars +for i in range(6): + manage_probuilder(action="auto_smooth", target=f"Pillar_{i}", + properties={"angleThreshold": 45}) +``` + +### Example: Custom L-Shaped Room + +```python +# Use polygon shape for non-rectangular footprint +manage_probuilder(action="create_poly_shape", properties={ + "points": [ + [0, 0, 0], [10, 0, 0], [10, 0, 6], + [4, 0, 6], [4, 0, 10], [0, 0, 10] + ], + "extrudeHeight": 3.0, + "name": "LRoom" +}) + +# Create inside faces for the room interior +info = manage_probuilder(action="get_mesh_info", target="LRoom", + properties={"include": "faces"}) +# Duplicate and flip all faces to make interior visible +all_faces = list(range(info["data"]["faceCount"])) +manage_probuilder(action="duplicate_and_flip", target="LRoom", + properties={"faceIndices": all_faces}) + +# Cut a window: subdivide a wall face, delete center +# (follow the get_mesh_info -> subdivide -> get_mesh_info -> delete pattern) +``` + +### Example: Torus Knot / Decorative Ring + +```python +# Create a torus +manage_probuilder(action="create_shape", properties={ + "shape_type": "Torus", "name": "Ring", + "innerRadius": 0.3, "outerRadius": 2.0, + "rows": 24, "columns": 32 +}) + +# Smooth it for organic look +manage_probuilder(action="auto_smooth", target="Ring", + properties={"angleThreshold": 60}) + +# Assign metallic material +manage_probuilder(action="set_face_material", target="Ring", + properties={"faceIndices": [], "materialPath": "Assets/Materials/Gold.mat"}) +# Note: empty faceIndices = all faces +``` + +## Batch Patterns + +Use `batch_execute` for multi-step workflows to reduce round-trips: + +```python +batch_execute(commands=[ + {"tool": "manage_probuilder", "params": { + "action": "create_shape", + "properties": {"shape_type": "Cube", "name": "Column1", "position": [0, 0, 0]} + }}, + {"tool": "manage_probuilder", "params": { + "action": "create_shape", + "properties": {"shape_type": "Cube", "name": "Column2", "position": [5, 0, 0]} + }}, + {"tool": "manage_probuilder", "params": { + "action": "create_shape", + "properties": {"shape_type": "Cube", "name": "Column3", "position": [10, 0, 0]} + }}, +]) +``` + +## Known Limitations + +### Not Yet Working + +These actions exist in the API but have known bugs that prevent them from working correctly: + +| Action | Issue | Workaround | +|--------|-------|------------| +| `set_pivot` | Vertex positions don't persist through `ToMesh()`/`RefreshMesh()`. The `positions` property setter is overwritten when ProBuilder rebuilds the mesh. Needs `SetVertices(IList)` or direct `m_Positions` field access. | Use `center_pivot` instead, or position objects via Transform. | +| `convert_to_probuilder` | `MeshImporter` constructor throws internally. May need ProBuilder's editor-only `ProBuilderize` API instead of runtime `MeshImporter`. | Create shapes natively with `create_shape` or `create_poly_shape` instead of converting existing meshes. | + +### General Limitations + +- Face indices are **not stable** across edits -- always re-query `get_mesh_info` after any modification +- Edge data is capped at **200 edges** in `get_mesh_info` results +- Face data is capped at **100 faces** in `get_mesh_info` results +- `subdivide` uses `ConnectElements.Connect` internally (ProBuilder has no public `Subdivide` API), which connects face midpoints rather than traditional quad subdivision + +## Key Rules + +1. **Always get_mesh_info before editing** -- face indices are not stable across edits +2. **Re-query after modifications** -- subdivide, extrude, delete all change face indices +3. **Use direction labels** -- don't guess face indices, use the direction field +4. **Cleanup after editing** -- center_pivot + validate is good practice +5. **Auto-smooth for organic shapes** -- 30 degrees is a good default +6. **Prefer ProBuilder over primitives** -- when the package is available and you need editable geometry +7. **Use batch_execute** -- for creating multiple shapes or repetitive operations +8. **Screenshot to verify** -- use `manage_scene(action="screenshot", include_image=True)` to check visual results after complex edits diff --git a/unity-mcp-skill/references/tools-reference.md b/unity-mcp-skill/references/tools-reference.md index 60b967cd4..22ce1f71f 100644 --- a/unity-mcp-skill/references/tools-reference.md +++ b/unity-mcp-skill/references/tools-reference.md @@ -15,6 +15,7 @@ Complete reference for all MCP tools. Each tool includes parameters, types, and - [UI Tools](#ui-tools) - [Editor Control Tools](#editor-control-tools) - [Testing Tools](#testing-tools) +- [ProBuilder Tools](#probuilder-tools) --- @@ -789,3 +790,118 @@ execute_custom_tool( ``` Discover available custom tools via `mcpforunity://custom-tools` resource. + +--- + +## ProBuilder Tools + +### manage_probuilder + +Unified tool for ProBuilder mesh operations. Requires `com.unity.probuilder` package. When available, **prefer ProBuilder over primitive GameObjects** for editable geometry, multi-material faces, or complex shapes. + +**Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `action` | string | Yes | Action to perform (see categories below) | +| `target` | string | Sometimes | Target GameObject name/path/id | +| `search_method` | string | No | How to find target: `by_id`, `by_name`, `by_path`, `by_tag`, `by_layer` | +| `properties` | dict | No | Action-specific parameters | + +**Actions by category:** + +**Shape Creation:** +- `create_shape` — Create ProBuilder primitive (shape_type, size, position, rotation, name). 13 types: Cube, Cylinder, Sphere, Plane, Cone, Torus, Pipe, Arch, Stair, CurvedStair, Door, Prism +- `create_poly_shape` — Create from 2D polygon footprint (points, extrudeHeight, flipNormals) + +**Mesh Editing:** +- `extrude_faces` — Extrude faces (faceIndices, distance, method: FaceNormal/VertexNormal/IndividualFaces) +- `extrude_edges` — Extrude edges (edgeIndices or edges [{a,b},...], distance, asGroup) +- `bevel_edges` — Bevel edges (edgeIndices or edges [{a,b},...], amount 0-1) +- `subdivide` — Subdivide faces via ConnectElements (faceIndices optional) +- `delete_faces` — Delete faces (faceIndices) +- `bridge_edges` — Bridge two open edges (edgeA, edgeB as {a,b} pairs, allowNonManifold) +- `connect_elements` — Connect edges/faces (edgeIndices or faceIndices) +- `detach_faces` — Detach faces to new object (faceIndices, deleteSourceFaces) +- `flip_normals` — Flip face normals (faceIndices) +- `merge_faces` — Merge faces into one (faceIndices) +- `combine_meshes` — Combine ProBuilder objects (targets list) +- `merge_objects` — Merge objects with auto-convert (targets, name) +- `duplicate_and_flip` — Create double-sided geometry (faceIndices) +- `create_polygon` — Connect existing vertices into a new face (vertexIndices, unordered) + +**Vertex Operations:** +- `merge_vertices` — Collapse vertices to single point (vertexIndices, collapseToFirst) +- `weld_vertices` — Weld vertices within proximity radius (vertexIndices, radius) +- `split_vertices` — Split shared vertices (vertexIndices) +- `move_vertices` — Translate vertices (vertexIndices, offset [x,y,z]) +- `insert_vertex` — Insert vertex on edge or face (edge {a,b} or faceIndex + point [x,y,z]) +- `append_vertices_to_edge` — Insert evenly-spaced points on edges (edgeIndices or edges, count) + +**Selection:** +- `select_faces` — Select faces by criteria (direction + tolerance, growFrom + growAngle) + +**UV & Materials:** +- `set_face_material` — Assign material to faces (faceIndices, materialPath) +- `set_face_color` — Set vertex color on faces (faceIndices, color [r,g,b,a]) +- `set_face_uvs` — Set UV params (faceIndices, scale, offset, rotation, flipU, flipV) + +**Query:** +- `get_mesh_info` — Get mesh details with `include` parameter: + - `"summary"` (default): counts, bounds, materials + - `"faces"`: + face normals, centers, and direction labels (capped at 100) + - `"edges"`: + edge vertex pairs with world positions (capped at 200, deduplicated) + - `"all"`: everything +- `ping` — Check if ProBuilder is available + +**Smoothing:** +- `set_smoothing` — Set smoothing group on faces (faceIndices, smoothingGroup: 0=hard, 1+=smooth) +- `auto_smooth` — Auto-assign smoothing groups by angle (angleThreshold: default 30) + +**Mesh Utilities:** +- `center_pivot` — Move pivot to mesh bounds center +- `freeze_transform` — Bake transform into vertices, reset transform +- `validate_mesh` — Check mesh health (read-only diagnostics) +- `repair_mesh` — Auto-fix degenerate triangles + +**Not Yet Working (known bugs):** +- `set_pivot` — Vertex positions don't persist through mesh rebuild. Use `center_pivot` or Transform positioning instead. +- `convert_to_probuilder` — MeshImporter throws internally. Create shapes natively instead. + +**Examples:** + +```python +# Check availability +manage_probuilder(action="ping") + +# Create a cube +manage_probuilder(action="create_shape", properties={"shape_type": "Cube", "name": "MyCube"}) + +# Get face info with directions +manage_probuilder(action="get_mesh_info", target="MyCube", properties={"include": "faces"}) + +# Extrude the top face (find it via direction="top" in get_mesh_info results) +manage_probuilder(action="extrude_faces", target="MyCube", + properties={"faceIndices": [2], "distance": 1.5}) + +# Select all upward-facing faces +manage_probuilder(action="select_faces", target="MyCube", + properties={"direction": "up", "tolerance": 0.7}) + +# Create double-sided geometry (for room interiors) +manage_probuilder(action="duplicate_and_flip", target="Room", + properties={"faceIndices": [0, 1, 2, 3, 4, 5]}) + +# Weld nearby vertices +manage_probuilder(action="weld_vertices", target="MyCube", + properties={"vertexIndices": [0, 1, 2, 3], "radius": 0.1}) + +# Auto-smooth +manage_probuilder(action="auto_smooth", target="MyCube", properties={"angleThreshold": 30}) + +# Cleanup workflow +manage_probuilder(action="center_pivot", target="MyCube") +manage_probuilder(action="validate_mesh", target="MyCube") +``` + +See also: [ProBuilder Workflow Guide](probuilder-guide.md) for detailed patterns and complex object examples. diff --git a/unity-mcp-skill/references/workflows.md b/unity-mcp-skill/references/workflows.md index 577f21ef7..40b3e9e91 100644 --- a/unity-mcp-skill/references/workflows.md +++ b/unity-mcp-skill/references/workflows.md @@ -11,6 +11,7 @@ Common workflows and patterns for effective Unity-MCP usage. - [Testing Workflows](#testing-workflows) - [Debugging Workflows](#debugging-workflows) - [UI Creation Workflows](#ui-creation-workflows) +- [ProBuilder Workflows](#probuilder-workflows) - [Batch Operations](#batch-operations) --- @@ -1425,6 +1426,88 @@ Both systems are active simultaneously. For UI, prefer `InputSystemUIInputModule --- +## ProBuilder Workflows + +When `com.unity.probuilder` is installed, prefer ProBuilder shapes over primitive GameObjects for any geometry that needs editing, multi-material faces, or non-trivial shapes. Check availability first with `manage_probuilder(action="ping")`. + +See [ProBuilder Workflow Guide](probuilder-guide.md) for full reference with complex object examples. + +### ProBuilder vs Primitives Decision + +| Need | Use Primitives | Use ProBuilder | +|------|---------------|----------------| +| Simple placeholder cube | `manage_gameobject(action="create", primitive_type="Cube")` | - | +| Editable geometry | - | `manage_probuilder(action="create_shape", ...)` | +| Per-face materials | - | `set_face_material` | +| Custom shapes (L-rooms, arches) | - | `create_poly_shape` or `create_shape` | +| Mesh editing (extrude, bevel) | - | Face/edge/vertex operations | +| Batch environment building | Either | ProBuilder + `batch_execute` | + +### Basic ProBuilder Scene Build + +```python +# 1. Check ProBuilder availability +manage_probuilder(action="ping") + +# 2. Create shapes (use batch for multiple) +batch_execute(commands=[ + {"tool": "manage_probuilder", "params": { + "action": "create_shape", + "properties": {"shape_type": "Cube", "name": "Floor", "width": 20, "height": 0.2, "depth": 20} + }}, + {"tool": "manage_probuilder", "params": { + "action": "create_shape", + "properties": {"shape_type": "Cube", "name": "Wall1", "width": 20, "height": 3, "depth": 0.3, + "position": [0, 1.5, 10]} + }}, + {"tool": "manage_probuilder", "params": { + "action": "create_shape", + "properties": {"shape_type": "Cylinder", "name": "Pillar1", "radius": 0.4, "height": 3, + "position": [5, 1.5, 5]} + }}, +]) + +# 3. Edit geometry (always get_mesh_info first!) +info = manage_probuilder(action="get_mesh_info", target="Wall1", + properties={"include": "faces"}) +# Find direction="front" face, subdivide it, delete center for a window + +# 4. Apply materials per face +manage_probuilder(action="set_face_material", target="Floor", + properties={"faceIndices": [0], "materialPath": "Assets/Materials/Stone.mat"}) + +# 5. Smooth organic shapes +manage_probuilder(action="auto_smooth", target="Pillar1", + properties={"angleThreshold": 45}) + +# 6. Screenshot to verify +manage_scene(action="screenshot", include_image=True, max_resolution=512) +``` + +### Edit-Verify Loop Pattern + +Face indices change after every edit. Always re-query: + +```python +# WRONG: Assume face indices are stable +manage_probuilder(action="subdivide", target="Obj", properties={"faceIndices": [2]}) +manage_probuilder(action="delete_faces", target="Obj", properties={"faceIndices": [5]}) # Index may be wrong! + +# RIGHT: Re-query after each edit +manage_probuilder(action="subdivide", target="Obj", properties={"faceIndices": [2]}) +info = manage_probuilder(action="get_mesh_info", target="Obj", properties={"include": "faces"}) +# Find the correct face by direction/center, then delete +manage_probuilder(action="delete_faces", target="Obj", properties={"faceIndices": [correct_index]}) +``` + +### Known Limitations + +- **`set_pivot`**: Broken -- vertex positions don't persist through mesh rebuild. Use `center_pivot` or Transform positioning. +- **`convert_to_probuilder`**: Broken -- MeshImporter throws. Create shapes natively with `create_shape`/`create_poly_shape`. +- **`subdivide`**: Uses `ConnectElements.Connect` (not traditional quad subdivision). Connects face midpoints. + +--- + ## Batch Operations ### Mass Property Update From 1c1cce2146119e1679935dc82f9749cb28a7cdbd Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:31:36 -0500 Subject: [PATCH 3/8] code redundancy fix --- .../Tools/ProBuilder/ManageProBuilder.cs | 179 +++++++++--------- .../Tools/ProBuilder/ProBuilderMeshUtils.cs | 2 +- Server/src/cli/commands/probuilder.py | 37 ++-- .../src/services/tools/manage_probuilder.py | 21 -- 4 files changed, 101 insertions(+), 138 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs b/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs index 6fc8ed1d6..aa16aaa93 100644 --- a/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs +++ b/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs @@ -369,6 +369,19 @@ private static object CreateEdge(int a, int b) return ctor?.Invoke(new object[] { a, b }); } + /// + /// Create a typed List<Face> from a Face[] array for reflection calls + /// that require IEnumerable<Face>. + /// + private static System.Collections.IList ToTypedFaceList(Array faces) + { + var faceListType = typeof(List<>).MakeGenericType(_faceType); + var faceList = Activator.CreateInstance(faceListType) as System.Collections.IList; + foreach (var f in faces) + faceList.Add(f); + return faceList; + } + /// /// Collect unique (deduplicated) edges from the mesh. /// Edges shared between faces appear only once. @@ -988,15 +1001,12 @@ private static object Subdivide(JObject @params) // Get faces to subdivide (all faces if none specified) var faces = GetFacesByIndices(pbMesh, faceIndicesToken); - var faceListType = typeof(List<>).MakeGenericType(_faceType); - var faceList = Activator.CreateInstance(faceListType) as System.Collections.IList; - foreach (var f in faces) - faceList.Add(f); + var faceList = ToTypedFaceList(faces); // ProBuilder uses ConnectElements.Connect(mesh, faces) for face subdivision var connectMethod = _connectElementsType.GetMethods(BindingFlags.Static | BindingFlags.Public) .FirstOrDefault(m => m.Name == "Connect" && m.GetParameters().Length == 2 - && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceListType)); + && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceList.GetType())); if (connectMethod == null) return new ErrorResponse("ConnectElements.Connect (faces) method not found."); @@ -1159,17 +1169,12 @@ private static object ConnectElements(JObject @params) if (faceIndicesToken != null) { var faces = GetFacesByIndices(pbMesh, faceIndicesToken); - - // Build IEnumerable compatible type (List) - var faceListType = typeof(List<>).MakeGenericType(_faceType); - var faceList = Activator.CreateInstance(faceListType) as System.Collections.IList; - foreach (var f in faces) - faceList.Add(f); + var faceList = ToTypedFaceList(faces); // Try Connect(ProBuilderMesh, IEnumerable) var connectMethod = _connectElementsType.GetMethods(BindingFlags.Static | BindingFlags.Public) .FirstOrDefault(m => m.Name == "Connect" && m.GetParameters().Length == 2 - && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceListType)); + && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceList.GetType())); if (connectMethod == null) return new ErrorResponse("ConnectElements.Connect (faces) method not found."); @@ -1231,16 +1236,12 @@ private static object DetachFaces(JObject @params) Undo.RegisterCompleteObjectUndo(pbMesh, "Detach Faces"); - // Build IEnumerable compatible list for reflection matching - var faceListType = typeof(List<>).MakeGenericType(_faceType); - var faceList = Activator.CreateInstance(faceListType) as System.Collections.IList; - foreach (var f in faces) - faceList.Add(f); + var faceList = ToTypedFaceList(faces); // Try overload: DetachFaces(ProBuilderMesh, IEnumerable, bool) var detachMethod = _extrudeElementsType.GetMethods(BindingFlags.Static | BindingFlags.Public) .FirstOrDefault(m => m.Name == "DetachFaces" && m.GetParameters().Length == 3 - && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceListType) + && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceList.GetType()) && m.GetParameters()[2].ParameterType == typeof(bool)); if (detachMethod != null) @@ -1252,7 +1253,7 @@ private static object DetachFaces(JObject @params) // Fallback: DetachFaces(ProBuilderMesh, IEnumerable) detachMethod = _extrudeElementsType.GetMethods(BindingFlags.Static | BindingFlags.Public) .FirstOrDefault(m => m.Name == "DetachFaces" && m.GetParameters().Length == 2 - && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceListType)); + && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceList.GetType())); if (detachMethod == null) return new ErrorResponse("ExtrudeElements.DetachFaces method not found."); @@ -1304,15 +1305,11 @@ private static object MergeFaces(JObject @params) Undo.RegisterCompleteObjectUndo(pbMesh, "Merge Faces"); - // Build IEnumerable compatible type - var faceListType = typeof(List<>).MakeGenericType(_faceType); - var faceList = Activator.CreateInstance(faceListType) as System.Collections.IList; - foreach (var f in faces) - faceList.Add(f); + var faceList = ToTypedFaceList(faces); var mergeMethod = _mergeElementsType.GetMethods(BindingFlags.Static | BindingFlags.Public) .FirstOrDefault(m => m.Name == "Merge" && m.GetParameters().Length == 2 - && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceListType)); + && m.GetParameters()[1].ParameterType.IsAssignableFrom(faceList.GetType())); if (mergeMethod == null) return new ErrorResponse("MergeElements.Merge method not found."); @@ -1892,6 +1889,7 @@ private static object SelectFaces(JObject @params) var allFaces = GetFacesArray(pbMesh); var facesList = (System.Collections.IList)allFaces; + var selectedSet = new HashSet(); var selectedIndices = new List(); // Selection by direction @@ -1916,7 +1914,10 @@ private static object SelectFaces(JObject @params) { var normal = ComputeFaceNormal(pbMesh, facesList[i]); if (Vector3.Dot(normal, targetDir) > tolerance) + { + selectedSet.Add(i); selectedIndices.Add(i); + } } } @@ -1926,10 +1927,7 @@ private static object SelectFaces(JObject @params) if (growFromToken != null && _elementSelectionType != null) { var seedFaces = GetFacesByIndices(pbMesh, growFromToken); - var faceListType = typeof(List<>).MakeGenericType(_faceType); - var seedList = Activator.CreateInstance(faceListType) as System.Collections.IList; - foreach (var f in seedFaces) - seedList.Add(f); + var seedList = ToTypedFaceList(seedFaces); var growMethod = _elementSelectionType.GetMethods(BindingFlags.Static | BindingFlags.Public) .FirstOrDefault(m => m.Name == "GrowSelection" && m.GetParameters().Length == 3); @@ -1942,7 +1940,7 @@ private static object SelectFaces(JObject @params) foreach (var face in resultFaces) { int idx = IndexOfFace(facesList, face); - if (idx >= 0 && !selectedIndices.Contains(idx)) + if (idx >= 0 && selectedSet.Add(idx)) selectedIndices.Add(idx); } } @@ -1955,10 +1953,7 @@ private static object SelectFaces(JObject @params) if (floodFromToken != null && _elementSelectionType != null) { var seedFaces = GetFacesByIndices(pbMesh, floodFromToken); - var faceListType = typeof(List<>).MakeGenericType(_faceType); - var seedList = Activator.CreateInstance(faceListType) as System.Collections.IList; - foreach (var f in seedFaces) - seedList.Add(f); + var seedList = ToTypedFaceList(seedFaces); var floodMethod = _elementSelectionType.GetMethods(BindingFlags.Static | BindingFlags.Public) .FirstOrDefault(m => m.Name == "FloodSelection" && m.GetParameters().Length == 3); @@ -1971,7 +1966,7 @@ private static object SelectFaces(JObject @params) foreach (var face in resultFaces) { int idx = IndexOfFace(facesList, face); - if (idx >= 0 && !selectedIndices.Contains(idx)) + if (idx >= 0 && selectedSet.Add(idx)) selectedIndices.Add(idx); } } @@ -2002,7 +1997,7 @@ private static object SelectFaces(JObject @params) foreach (var face in resultFaces) { int idx = IndexOfFace(facesList, face); - if (idx >= 0 && !selectedIndices.Contains(idx)) + if (idx >= 0 && selectedSet.Add(idx)) selectedIndices.Add(idx); } } @@ -2168,55 +2163,43 @@ private static object SetFaceUVs(JObject @params) var autoUnwrapType = uvProperty.PropertyType; + // Resolve reflection members once outside the loop + var scaleField = autoUnwrapType.GetField("scale") ?? (MemberInfo)autoUnwrapType.GetProperty("scale"); + var offsetField = autoUnwrapType.GetField("offset"); + var rotField = autoUnwrapType.GetField("rotation"); + var flipUField = autoUnwrapType.GetField("flipU"); + var flipVField = autoUnwrapType.GetField("flipV"); + + var scaleToken = props["scale"]; + var offsetToken = props["offset"]; + var rotationToken = props["rotation"]; + var flipUToken = props["flipU"] ?? props["flip_u"]; + var flipVToken = props["flipV"] ?? props["flip_v"]; + foreach (var face in faces) { var uvSettings = uvProperty.GetValue(face); - var scaleToken = props["scale"]; - if (scaleToken != null) + if (scaleToken != null && scaleField is FieldInfo scaleFi) { - var scaleProp = autoUnwrapType.GetField("scale") ?? (MemberInfo)autoUnwrapType.GetProperty("scale"); - if (scaleProp is FieldInfo fi) - { - var scaleArr = scaleToken.ToObject(); - fi.SetValue(uvSettings, new Vector2(scaleArr[0], scaleArr.Length > 1 ? scaleArr[1] : scaleArr[0])); - } + var scaleArr = scaleToken.ToObject(); + scaleFi.SetValue(uvSettings, new Vector2(scaleArr[0], scaleArr.Length > 1 ? scaleArr[1] : scaleArr[0])); } - var offsetToken = props["offset"]; - if (offsetToken != null) + if (offsetToken != null && offsetField != null) { - var offsetField = autoUnwrapType.GetField("offset"); - if (offsetField != null) - { - var offsetArr = offsetToken.ToObject(); - offsetField.SetValue(uvSettings, new Vector2(offsetArr[0], offsetArr.Length > 1 ? offsetArr[1] : 0f)); - } + var offsetArr = offsetToken.ToObject(); + offsetField.SetValue(uvSettings, new Vector2(offsetArr[0], offsetArr.Length > 1 ? offsetArr[1] : 0f)); } - var rotationToken = props["rotation"]; - if (rotationToken != null) - { - var rotField = autoUnwrapType.GetField("rotation"); - if (rotField != null) - rotField.SetValue(uvSettings, rotationToken.Value()); - } + if (rotationToken != null && rotField != null) + rotField.SetValue(uvSettings, rotationToken.Value()); - var flipUToken = props["flipU"] ?? props["flip_u"]; - if (flipUToken != null) - { - var flipUField = autoUnwrapType.GetField("flipU"); - if (flipUField != null) - flipUField.SetValue(uvSettings, flipUToken.Value()); - } + if (flipUToken != null && flipUField != null) + flipUField.SetValue(uvSettings, flipUToken.Value()); - var flipVToken = props["flipV"] ?? props["flip_v"]; - if (flipVToken != null) - { - var flipVField = autoUnwrapType.GetField("flipV"); - if (flipVField != null) - flipVField.SetValue(uvSettings, flipVToken.Value()); - } + if (flipVToken != null && flipVField != null) + flipVField.SetValue(uvSettings, flipVToken.Value()); uvProperty.SetValue(face, uvSettings); } @@ -2276,14 +2259,20 @@ private static object GetMeshInfo(JObject @params) if (include == "faces" || include == "all") { + var positionsPropFaces = _proBuilderMeshType.GetProperty("positions"); + var positionsListFaces = positionsPropFaces?.GetValue(pbMesh) as System.Collections.IList; + var indexesPropFaces = _faceType.GetProperty("indexes"); + var smGroupProp = _faceType.GetProperty("smoothingGroup"); + var manualUVProp = _faceType.GetProperty("manualUV"); + var faceDetails = new List(); for (int i = 0; i < facesList.Count && i < 100; i++) { var face = facesList[i]; - var smGroup = _faceType.GetProperty("smoothingGroup")?.GetValue(face); - var manualUV = _faceType.GetProperty("manualUV")?.GetValue(face); - var normal = ComputeFaceNormal(pbMesh, face); - var center = ComputeFaceCenter(pbMesh, face); + var smGroup = smGroupProp?.GetValue(face); + var manualUV = manualUVProp?.GetValue(face); + var normal = ComputeFaceNormal(pbMesh, face, positionsListFaces, indexesPropFaces); + var center = ComputeFaceCenter(pbMesh, face, positionsListFaces, indexesPropFaces); var direction = ClassifyDirection(normal); faceDetails.Add(new @@ -2347,11 +2336,16 @@ private static object GetMeshInfo(JObject @params) return new SuccessResponse("ProBuilder mesh info", data); } - private static Vector3 ComputeFaceNormal(Component pbMesh, object face) + private static Vector3 ComputeFaceNormal(Component pbMesh, object face, + System.Collections.IList positions = null, PropertyInfo indexesProp = null) { - var positionsProp = _proBuilderMeshType.GetProperty("positions"); - var positions = positionsProp?.GetValue(pbMesh) as System.Collections.IList; - var indexesProp = _faceType.GetProperty("indexes"); + if (positions == null) + { + var positionsProp = _proBuilderMeshType.GetProperty("positions"); + positions = positionsProp?.GetValue(pbMesh) as System.Collections.IList; + } + if (indexesProp == null) + indexesProp = _faceType.GetProperty("indexes"); var indexes = indexesProp?.GetValue(face) as System.Collections.IList; if (positions == null || indexes == null || indexes.Count < 3) @@ -2365,11 +2359,16 @@ private static Vector3 ComputeFaceNormal(Component pbMesh, object face) return pbMesh.transform.rotation * localNormal; } - private static Vector3 ComputeFaceCenter(Component pbMesh, object face) + private static Vector3 ComputeFaceCenter(Component pbMesh, object face, + System.Collections.IList positions = null, PropertyInfo indexesProp = null) { - var positionsProp = _proBuilderMeshType.GetProperty("positions"); - var positions = positionsProp?.GetValue(pbMesh) as System.Collections.IList; - var indexesProp = _faceType.GetProperty("indexes"); + if (positions == null) + { + var positionsProp = _proBuilderMeshType.GetProperty("positions"); + positions = positionsProp?.GetValue(pbMesh) as System.Collections.IList; + } + if (indexesProp == null) + indexesProp = _faceType.GetProperty("indexes"); var indexes = indexesProp?.GetValue(face) as System.Collections.IList; if (positions == null || indexes == null || indexes.Count == 0) @@ -2403,7 +2402,7 @@ private static string ClassifyDirection(Vector3 normal) return null; } - private static float Round(float v) => (float)Math.Round(v, 4); + internal static float Round(float v) => (float)Math.Round(v, 4); private static object ConvertToProBuilder(JObject @params) { @@ -2471,13 +2470,5 @@ private static object ConvertToProBuilder(JObject @params) }); } - // ===================================================================== - // Legacy compatibility: CollectAllEdges (now returns unique edges) - // ===================================================================== - - internal static List CollectAllEdges(Component pbMesh) - { - return CollectUniqueEdges(pbMesh); - } } } diff --git a/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs b/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs index 6e4d7d337..be1a901c9 100644 --- a/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs +++ b/MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs @@ -273,6 +273,6 @@ internal static object RepairMesh(JObject @params) }); } - private static float Round(float v) => (float)Math.Round(v, 4); + private static float Round(float v) => ManageProBuilder.Round(v); } } diff --git a/Server/src/cli/commands/probuilder.py b/Server/src/cli/commands/probuilder.py index e30b124f3..0a61cbd2e 100644 --- a/Server/src/cli/commands/probuilder.py +++ b/Server/src/cli/commands/probuilder.py @@ -13,6 +13,19 @@ _PB_TOP_LEVEL_KEYS = {"action", "target", "searchMethod", "properties"} +def _parse_edges_param(edges: str) -> dict[str, Any]: + """Parse edge JSON into either 'edges' (vertex pairs) or 'edgeIndices' (flat indices).""" + import json + try: + parsed = json.loads(edges) + except json.JSONDecodeError: + print_error("Invalid JSON for edges parameter") + raise SystemExit(1) + if parsed and isinstance(parsed[0], dict): + return {"edges": parsed} + return {"edgeIndices": parsed} + + def _normalize_pb_params(params: dict[str, Any]) -> dict[str, Any]: params = dict(params) properties: dict[str, Any] = {} @@ -191,25 +204,15 @@ def extrude_edges(target: str, edges: str, distance: float, as_group: bool, unity-mcp probuilder extrude-edges "MyCube" --edges '[{"a":0,"b":1}]' --distance 1 """ config = get_config() - import json - try: - parsed = json.loads(edges) - except json.JSONDecodeError: - print_error("Invalid JSON for edges parameter") - raise SystemExit(1) request: dict[str, Any] = { "action": "extrude_edges", "target": target, "distance": distance, "asGroup": as_group, + **_parse_edges_param(edges), } - if parsed and isinstance(parsed[0], dict): - request["edges"] = parsed - else: - request["edgeIndices"] = parsed - if search_method: request["searchMethod"] = search_method @@ -235,24 +238,14 @@ def bevel_edges(target: str, edges: str, amount: float, search_method: Optional[ unity-mcp probuilder bevel-edges "MyCube" --edges '[{"a":0,"b":1}]' --amount 0.15 """ config = get_config() - import json - try: - parsed = json.loads(edges) - except json.JSONDecodeError: - print_error("Invalid JSON for edges parameter") - raise SystemExit(1) request: dict[str, Any] = { "action": "bevel_edges", "target": target, "amount": amount, + **_parse_edges_param(edges), } - if parsed and isinstance(parsed[0], dict): - request["edges"] = parsed - else: - request["edgeIndices"] = parsed - if search_method: request["searchMethod"] = search_method diff --git a/Server/src/services/tools/manage_probuilder.py b/Server/src/services/tools/manage_probuilder.py index 575309de5..4092ee69d 100644 --- a/Server/src/services/tools/manage_probuilder.py +++ b/Server/src/services/tools/manage_probuilder.py @@ -46,27 +46,6 @@ + UV_MATERIAL_ACTIONS + QUERY_ACTIONS + SMOOTHING_ACTIONS + UTILITY_ACTIONS ) -_PROBUILDER_TOP_LEVEL_KEYS = {"action", "target", "searchMethod", "properties"} - - -def _normalize_probuilder_params(params: dict[str, Any]) -> dict[str, Any]: - params = dict(params) - properties: dict[str, Any] = {} - for key in list(params.keys()): - if key in _PROBUILDER_TOP_LEVEL_KEYS: - continue - properties[key] = params.pop(key) - - if properties: - existing = params.get("properties") - if isinstance(existing, dict): - params["properties"] = {**properties, **existing} - else: - params["properties"] = properties - - return {k: v for k, v in params.items() if v is not None} - - @mcp_for_unity_tool( group="probuilder", description=( From 437528b6e51fb39a140b710b80e7fedeed7a41be Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:00:50 -0500 Subject: [PATCH 4/8] Include temporary bug fix and experimental notice --- .../Tools/ProBuilder/ManageProBuilder.cs | 48 +++++++++++++++++++ .../Components/Tools/McpToolsSection.cs | 15 ++++++ 2 files changed, 63 insertions(+) diff --git a/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs b/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs index aa16aaa93..618ded368 100644 --- a/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs +++ b/MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs @@ -125,9 +125,57 @@ private static bool EnsureProBuilder() _meshValidationType = Type.GetType("UnityEngine.ProBuilder.MeshOperations.MeshValidation, Unity.ProBuilder"); _proBuilderAvailable = true; + PatchProBuilderDefaultMaterial(); return true; } + /// + /// Patches ProBuilderDefault.mat in memory to suppress unintended emission in URP projects. + /// + /// + /// Root cause: The ProBuilder default material was authored in an HDRP context and ships + /// with _EmissionColor = {1,1,1,1} (full white) and + /// m_LightmapFlags = RealtimeEmissive | BakedEmissive. + /// In a URP project Unity's GI system reads these material properties directly, + /// bypassing the shader's own Emission block (which is correctly wired to black). + /// The result is that every fresh ProBuilder mesh is treated as a full-white emitter, + /// and any URP Bloom volume in the scene amplifies this into a visible glow artefact. + /// + /// Fix: Zero all emission colour properties and set + /// globalIlluminationFlags = EmissiveIsBlack on the loaded + /// object. The change is in-memory only — package assets are read-only on disk — but + /// the GI system and Bloom post-process both re-query the material each frame, so the + /// patch is effective for the entire session. It is re-applied automatically on every + /// domain reload because is called on the first MCP + /// ProBuilder command of each session. + /// + private static void PatchProBuilderDefaultMaterial() + { + const string defaultMatPath = + "Packages/com.unity.probuilder/Content/Resources/Materials/ProBuilderDefault.mat"; + var mat = AssetDatabase.LoadAssetAtPath(defaultMatPath); + if (mat == null) return; + + bool changed = false; + foreach (var prop in new[] { "_EmissionColor", "_EmissionColorUI", "_EmissionColorWithMapUI" }) + { + if (mat.HasProperty(prop) && mat.GetColor(prop) != Color.black) + { + mat.SetColor(prop, Color.black); + changed = true; + } + } + + if (mat.globalIlluminationFlags != MaterialGlobalIlluminationFlags.EmissiveIsBlack) + { + mat.globalIlluminationFlags = MaterialGlobalIlluminationFlags.EmissiveIsBlack; + changed = true; + } + + if (changed) + Debug.Log("[MCP] Patched ProBuilderDefault material: zeroed emission and set GI flags to EmissiveIsBlack."); + } + public static object HandleCommand(JObject @params) { if (!EnsureProBuilder()) diff --git a/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs b/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs index 9195108da..a6f57365c 100644 --- a/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs @@ -44,6 +44,7 @@ public class McpToolsSection { "ui", "UI Toolkit" }, { "scripting_ext", "Scripting Extensions" }, { "testing", "Testing" }, + { "probuilder", "ProBuilder — Experimental" }, }; public VisualElement Root { get; } @@ -203,6 +204,8 @@ private void BuildCategory(string title, string prefsSuffix, IEnumerable MCPServiceLocator.ToolDiscovery.IsToolEnabled(t.Name)); // Default foldout state: core is open, others collapsed @@ -242,6 +245,18 @@ private void BuildCategory(string title, string prefsSuffix, IEnumerable Date: Fri, 6 Mar 2026 01:38:05 -0500 Subject: [PATCH 5/8] Readme update --- README.md | 2 +- docs/i18n/README-zh.md | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 734da9d42..4367696a3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@
Recent Updates -* **v9.4.8 (beta)** — New editor UI, real-time tool toggling via `manage_tools`, skill sync window, multi-view screenshot, one-click Roslyn installer, Qwen Code & Gemini CLI clients. +* **v9.4.8 (beta)** — New editor UI, real-time tool toggling via `manage_tools`, skill sync window, multi-view screenshot, one-click Roslyn installer, Qwen Code & Gemini CLI clients, ProBuilder mesh editing via `manage_probuilder`. * **v9.4.7** — Per-call Unity instance routing, macOS pyenv PATH fix, domain reload resilience for script tools. * **v9.4.6** — New `manage_animation` tool, Cline client support, stale connection detection, tool state persistence across reloads. * **v9.4.4** — Configurable `batch_execute` limits, tool filtering by session state, IPv6/IPv4 loopback fixes. diff --git a/docs/i18n/README-zh.md b/docs/i18n/README-zh.md index c7b731c8a..9b1e58abf 100644 --- a/docs/i18n/README-zh.md +++ b/docs/i18n/README-zh.md @@ -17,6 +17,22 @@ MCP for Unity building a scene +
+最近更新 + +* **v9.4.8 (beta)** — 新编辑器 UI、通过 `manage_tools` 实时切换工具、技能同步窗口、多视图截图、一键 Roslyn 安装器、支持 Qwen Code 与 Gemini CLI 客户端、通过 `manage_probuilder` 进行 ProBuilder 网格编辑。 +* **v9.4.7** — 支持按调用路由 Unity 实例、修复 macOS pyenv PATH 问题、脚本工具的域重载稳定性提升。 +* **v9.4.6** — 新增 `manage_animation` 工具、支持 Cline 客户端、失效连接检测、工具状态跨重载持久化。 +* **v9.4.4** — 可配置 `batch_execute` 限制、按会话状态过滤工具、修复 IPv6/IPv4 回环问题。 + +
+更早的版本 + + + +
+
+ --- ## 快速开始 From 1acfbe43cfd74d08fd40b23e673181d5ab711adf Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Fri, 6 Mar 2026 02:24:37 -0500 Subject: [PATCH 6/8] Bug fixes --- .../references/tools-reference.md | 2 +- MCPForUnity/Editor/Tools/ManageScene.cs | 16 ++++++++-------- Server/src/cli/utils/constants.py | 2 +- docs/guides/CLI_USAGE.md | 2 +- unity-mcp-skill/SKILL.md | 2 +- unity-mcp-skill/references/probuilder-guide.md | 6 +++--- unity-mcp-skill/references/tools-reference.md | 2 +- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.claude/skills/unity-mcp-skill/references/tools-reference.md b/.claude/skills/unity-mcp-skill/references/tools-reference.md index 22ce1f71f..beb7c1459 100644 --- a/.claude/skills/unity-mcp-skill/references/tools-reference.md +++ b/.claude/skills/unity-mcp-skill/references/tools-reference.md @@ -811,7 +811,7 @@ Unified tool for ProBuilder mesh operations. Requires `com.unity.probuilder` pac **Actions by category:** **Shape Creation:** -- `create_shape` — Create ProBuilder primitive (shape_type, size, position, rotation, name). 13 types: Cube, Cylinder, Sphere, Plane, Cone, Torus, Pipe, Arch, Stair, CurvedStair, Door, Prism +- `create_shape` — Create ProBuilder primitive (shape_type, size, position, rotation, name). 12 types: Cube, Cylinder, Sphere, Plane, Cone, Torus, Pipe, Arch, Stair, CurvedStair, Door, Prism - `create_poly_shape` — Create from 2D polygon footprint (points, extrudeHeight, flipNormals) **Mesh Editing:** diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index b2b70a282..f074146ba 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -655,6 +655,10 @@ private static object CaptureSurroundBatch(SceneCommand cmd) tempCam.farClipPlane = radius * 4f; tempCam.clearFlags = CameraClearFlags.Skybox; + // Force material refresh once before capture loop + EditorApplication.QueuePlayerLoopUpdate(); + SceneView.RepaintAll(); + var tiles = new List(); var tileLabels = new List(); var shotMeta = new List(); @@ -665,10 +669,6 @@ private static object CaptureSurroundBatch(SceneCommand cmd) tempCam.transform.position = pos; tempCam.transform.LookAt(center); - // Force material refresh before capture - EditorApplication.QueuePlayerLoopUpdate(); - SceneView.RepaintAll(); - Texture2D tile = ScreenshotUtility.RenderCameraToTexture(tempCam, maxRes); tiles.Add(tile); tileLabels.Add(label); @@ -777,6 +777,10 @@ private static object CaptureOrbitBatch(SceneCommand cmd) tempCam.farClipPlane = radius * 4f; tempCam.clearFlags = CameraClearFlags.Skybox; + // Force material refresh once before capture loop + EditorApplication.QueuePlayerLoopUpdate(); + SceneView.RepaintAll(); + var tiles = new List(); var tileLabels = new List(); var shotMeta = new List(); @@ -808,10 +812,6 @@ private static object CaptureOrbitBatch(SceneCommand cmd) : "level"; string angleLabel = $"{dirLabel}_{elevLabel}"; - // Force material refresh before capture - EditorApplication.QueuePlayerLoopUpdate(); - SceneView.RepaintAll(); - Texture2D tile = ScreenshotUtility.RenderCameraToTexture(tempCam, maxRes); tiles.Add(tile); tileLabels.Add(angleLabel); diff --git a/Server/src/cli/utils/constants.py b/Server/src/cli/utils/constants.py index 41c8c91e2..97f7e24d2 100644 --- a/Server/src/cli/utils/constants.py +++ b/Server/src/cli/utils/constants.py @@ -14,7 +14,7 @@ SEARCH_METHODS_RENDERER = ["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"] # Tagged search methods (used by VFX commands) -SEARCH_METHODS_TAGGED = ["by_name", "by_path", "by_id", "by_tag"] +SEARCH_METHODS_TAGGED = ["by_name", "by_path", "by_id", "by_tag", "by_layer"] # Click choice options for each set SEARCH_METHOD_CHOICE_FULL = click.Choice(SEARCH_METHODS_FULL) diff --git a/docs/guides/CLI_USAGE.md b/docs/guides/CLI_USAGE.md index 7e8a2852a..cf68580c1 100644 --- a/docs/guides/CLI_USAGE.md +++ b/docs/guides/CLI_USAGE.md @@ -282,7 +282,7 @@ unity-mcp probuilder create-poly --points "[[0,0,0],[5,0,0],[5,0,5],[0,0,5]]" -- # Get mesh info unity-mcp probuilder info "MyCube" -# Raw ProBuilder actions (access all 21 actions) +# Raw ProBuilder actions unity-mcp probuilder raw extrude_faces "MyCube" --params '{"faceIndices": [0], "distance": 1.0}' unity-mcp probuilder raw bevel_edges "MyCube" --params '{"edgeIndices": [0,1], "amount": 0.2}' unity-mcp probuilder raw set_face_material "MyCube" --params '{"faceIndices": [0], "materialPath": "Assets/Materials/Red.mat"}' diff --git a/unity-mcp-skill/SKILL.md b/unity-mcp-skill/SKILL.md index 04942a0f8..09dd719d3 100644 --- a/unity-mcp-skill/SKILL.md +++ b/unity-mcp-skill/SKILL.md @@ -158,7 +158,7 @@ uri="file:///full/path/to/file.cs" | **Editor** | `manage_editor`, `execute_menu_item`, `read_console` | Editor control | | **Testing** | `run_tests`, `get_test_job` | Unity Test Framework | | **Batch** | `batch_execute` | Parallel/bulk operations | -| **ProBuilder** | `manage_probuilder` | 3D modeling, mesh editing, complex geometry. **When `com.unity.probuilder` is installed, prefer ProBuilder shapes over primitive GameObjects** for editable geometry, multi-material faces, or complex shapes. Supports 13 shape types, face/edge/vertex editing, smoothing, and per-face materials. See [ProBuilder Guide](references/probuilder-guide.md). | +| **ProBuilder** | `manage_probuilder` | 3D modeling, mesh editing, complex geometry. **When `com.unity.probuilder` is installed, prefer ProBuilder shapes over primitive GameObjects** for editable geometry, multi-material faces, or complex shapes. Supports 12 shape types, face/edge/vertex editing, smoothing, and per-face materials. See [ProBuilder Guide](references/probuilder-guide.md). | | **UI** | `manage_ui`, `batch_execute` with `manage_gameobject` + `manage_components` | **UI Toolkit**: Use `manage_ui` to create UXML/USS files, attach UIDocument, inspect visual trees. **uGUI (Canvas)**: Use `batch_execute` for Canvas, Panel, Button, Text, Slider, Toggle, Input Field. **Read `mcpforunity://project/info` first** to detect uGUI/TMP/Input System/UI Toolkit availability. (see [UI workflows](references/workflows.md#ui-creation-workflows)) | ## Common Workflows diff --git a/unity-mcp-skill/references/probuilder-guide.md b/unity-mcp-skill/references/probuilder-guide.md index 722d6d446..d987b36fc 100644 --- a/unity-mcp-skill/references/probuilder-guide.md +++ b/unity-mcp-skill/references/probuilder-guide.md @@ -38,7 +38,7 @@ manage_probuilder(action="extrude_faces", target="MyCube", ## Shape Creation -### All 13 Shape Types +### All 12 Shape Types ```python # Basic shapes @@ -139,11 +139,11 @@ manage_probuilder(action="bevel_edges", target="MyCube", ```python # Detach and keep original (default) manage_probuilder(action="detach_faces", target="MyCube", - properties={"faceIndices": [0, 1], "deleteSourceFaces": false}) + properties={"faceIndices": [0, 1], "deleteSourceFaces": False}) # Detach and remove from source manage_probuilder(action="detach_faces", target="MyCube", - properties={"faceIndices": [0, 1], "deleteSourceFaces": true}) + properties={"faceIndices": [0, 1], "deleteSourceFaces": True}) ``` ### Select Faces by Direction diff --git a/unity-mcp-skill/references/tools-reference.md b/unity-mcp-skill/references/tools-reference.md index 22ce1f71f..beb7c1459 100644 --- a/unity-mcp-skill/references/tools-reference.md +++ b/unity-mcp-skill/references/tools-reference.md @@ -811,7 +811,7 @@ Unified tool for ProBuilder mesh operations. Requires `com.unity.probuilder` pac **Actions by category:** **Shape Creation:** -- `create_shape` — Create ProBuilder primitive (shape_type, size, position, rotation, name). 13 types: Cube, Cylinder, Sphere, Plane, Cone, Torus, Pipe, Arch, Stair, CurvedStair, Door, Prism +- `create_shape` — Create ProBuilder primitive (shape_type, size, position, rotation, name). 12 types: Cube, Cylinder, Sphere, Plane, Cone, Torus, Pipe, Arch, Stair, CurvedStair, Door, Prism - `create_poly_shape` — Create from 2D polygon footprint (points, extrudeHeight, flipNormals) **Mesh Editing:** From 5bd3a22e5655de6cc673c7d5ae1bdfa37ad20d39 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Fri, 6 Mar 2026 02:45:04 -0500 Subject: [PATCH 7/8] update --- MCPForUnity/Editor/Tools/ManageScene.cs | 1 + unity-mcp-skill/references/tools-reference.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index f074146ba..f33898113 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -941,6 +941,7 @@ private static object CapturePositionedScreenshot(SceneCommand cmd) File.WriteAllBytes(fullPath, pngBytes); string assetsRelativePath = "Assets/Screenshots/" + Path.GetFileName(fullPath); + AssetDatabase.ImportAsset(assetsRelativePath, ImportAssetOptions.ForceSynchronousImport); var data = new Dictionary { diff --git a/unity-mcp-skill/references/tools-reference.md b/unity-mcp-skill/references/tools-reference.md index beb7c1459..c40766425 100644 --- a/unity-mcp-skill/references/tools-reference.md +++ b/unity-mcp-skill/references/tools-reference.md @@ -806,7 +806,7 @@ Unified tool for ProBuilder mesh operations. Requires `com.unity.probuilder` pac | `action` | string | Yes | Action to perform (see categories below) | | `target` | string | Sometimes | Target GameObject name/path/id | | `search_method` | string | No | How to find target: `by_id`, `by_name`, `by_path`, `by_tag`, `by_layer` | -| `properties` | dict | No | Action-specific parameters | +| `properties` | dict \| string | No | Action-specific parameters (dict or JSON string) | **Actions by category:** From 2c59260c16bb8cf336d420091fb018c257edefad Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Fri, 6 Mar 2026 03:00:04 -0500 Subject: [PATCH 8/8] Update ManageScene.cs --- MCPForUnity/Editor/Tools/ManageScene.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index f33898113..5cddb654b 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -923,7 +923,9 @@ private static object CapturePositionedScreenshot(SceneCommand cmd) // Save to disk string screenshotsFolder = Path.Combine(Application.dataPath, "Screenshots"); Directory.CreateDirectory(screenshotsFolder); - string fileName = $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png"; + string fileName = !string.IsNullOrEmpty(cmd.fileName) + ? (cmd.fileName.EndsWith(".png", System.StringComparison.OrdinalIgnoreCase) ? cmd.fileName : cmd.fileName + ".png") + : $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png"; string fullPath = Path.Combine(screenshotsFolder, fileName); // Ensure unique filename if (File.Exists(fullPath)) @@ -950,7 +952,7 @@ private static object CapturePositionedScreenshot(SceneCommand cmd) { "imageHeight", h }, { "viewPosition", new[] { camPos.x, camPos.y, camPos.z } }, { "screenshotsFolder", screenshotsFolder }, - { "filePath", assetsRelativePath }, + { "path", assetsRelativePath }, }; if (targetPos.HasValue) data["lookAt"] = new[] { targetPos.Value.x, targetPos.Value.y, targetPos.Value.z };