@mflrevan/ucp 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mflrevan/ucp
2
2
 
3
- Version `0.3.3` of the Unity Control Protocol CLI.
3
+ Version `0.4.0` of the Unity Control Protocol CLI.
4
4
 
5
5
  This package installs the `ucp` command, downloads the matching published binary for your platform during `postinstall`, and ships the matching Unity bridge payload inside the npm package.
6
6
 
@@ -30,7 +30,7 @@ pnpm approve-builds
30
30
  cd /path/to/MyUnityProject
31
31
  ucp doctor
32
32
  ucp connect
33
- ucp snapshot
33
+ ucp scene snapshot
34
34
  ucp object get-fields --id 46894 --component Transform
35
35
  ucp asset search -t Material
36
36
  ucp build targets
@@ -49,7 +49,7 @@ Or add this to `Packages/manifest.json`:
49
49
  ```json
50
50
  {
51
51
  "dependencies": {
52
- "com.ucp.bridge": "https://github.com/mflRevan/unity-control-protocol.git?path=unity-package/com.ucp.bridge#v0.3.3"
52
+ "com.ucp.bridge": "https://github.com/mflRevan/unity-control-protocol.git?path=unity-package/com.ucp.bridge#v0.4.0"
53
53
  }
54
54
  }
55
55
  ```
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-03-15
4
+
5
+ ### Added
6
+
7
+ - Added `scene/focus` so the CLI can align the Unity Scene view to a target object for screenshot-driven iteration.
8
+ - Added smoke coverage for asset refresh after `file/write` and Scene view focus axis handling.
9
+
10
+ ### Changed
11
+
12
+ - `scene/focus` now exposes the stable axis-alignment workflow only, without public distance overrides.
13
+
14
+ ### Fixed
15
+
16
+ - Fixed `file/write` and `file/patch` so changes under `Assets/` and `Packages/` trigger a synchronous `AssetDatabase.Refresh`, making newly created scripts and assets available immediately.
17
+ - Fixed Scene view focus behavior and validation coverage so live automation and package smoke tests agree on the resulting alignment.
18
+
3
19
  ## [0.3.3] - 2026-03-14
4
20
 
5
21
  ### Added
@@ -26,7 +26,7 @@ namespace UCP.Bridge
26
26
  private const int DefaultPort = 21342;
27
27
  private const int MaxPort = 21352;
28
28
  private const int MaxConnections = 4;
29
- private const string ProtocolVersion = "0.3.3";
29
+ private const string ProtocolVersion = "0.4.0";
30
30
 
31
31
  private static TcpListener s_listener;
32
32
  private static CancellationTokenSource s_cts;
@@ -1,6 +1,7 @@
1
1
  using System;
2
2
  using System.Collections.Generic;
3
3
  using System.IO;
4
+ using UnityEditor;
4
5
  using UnityEngine;
5
6
 
6
7
  namespace UCP.Bridge
@@ -67,6 +68,7 @@ namespace UCP.Bridge
67
68
  Directory.CreateDirectory(dir);
68
69
 
69
70
  File.WriteAllText(fullPath, contentObj.ToString());
71
+ RefreshProjectAssets(pathObj.ToString());
70
72
 
71
73
  return new Dictionary<string, object>
72
74
  {
@@ -116,6 +118,7 @@ namespace UCP.Bridge
116
118
 
117
119
  var patched = original.Replace(find, replace);
118
120
  File.WriteAllText(fullPath, patched);
121
+ RefreshProjectAssets(pathObj.ToString());
119
122
 
120
123
  return new Dictionary<string, object>
121
124
  {
@@ -126,5 +129,17 @@ namespace UCP.Bridge
126
129
 
127
130
  throw new ArgumentException("Unsupported patch format. Use {\"find\": \"...\", \"replace\": \"...\"}");
128
131
  }
132
+
133
+ private static void RefreshProjectAssets(string relativePath)
134
+ {
135
+ var normalized = relativePath.Replace('\\', '/');
136
+ if (normalized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)
137
+ || normalized.Equals("Assets", StringComparison.OrdinalIgnoreCase)
138
+ || normalized.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)
139
+ || normalized.Equals("Packages", StringComparison.OrdinalIgnoreCase))
140
+ {
141
+ AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
142
+ }
143
+ }
129
144
  }
130
145
  }
@@ -1,6 +1,7 @@
1
1
  using System.Collections.Generic;
2
2
  using UnityEditor;
3
3
  using UnityEditor.SceneManagement;
4
+ using UnityEngine;
4
5
  using UnityEngine.SceneManagement;
5
6
 
6
7
  namespace UCP.Bridge
@@ -12,6 +13,7 @@ namespace UCP.Bridge
12
13
  router.Register("scene/list", HandleList);
13
14
  router.Register("scene/load", HandleLoad);
14
15
  router.Register("scene/active", HandleActive);
16
+ router.Register("scene/focus", HandleFocus);
15
17
  }
16
18
 
17
19
  private static object HandleList(string paramsJson)
@@ -107,5 +109,161 @@ namespace UCP.Bridge
107
109
  ["rootCount"] = scene.rootCount
108
110
  };
109
111
  }
112
+
113
+ private static object HandleFocus(string paramsJson)
114
+ {
115
+ var parameters = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
116
+ if (parameters == null || !parameters.TryGetValue("instanceId", out var idObj))
117
+ throw new System.ArgumentException("Missing 'instanceId' parameter");
118
+
119
+ var instanceId = System.Convert.ToInt32(idObj);
120
+ var target = FindGameObject(instanceId);
121
+ var bounds = CalculateFocusBounds(target);
122
+ var sceneView = SceneView.lastActiveSceneView ?? EditorWindow.GetWindow<SceneView>();
123
+
124
+ if (sceneView == null)
125
+ throw new System.InvalidOperationException("Unable to open Scene view");
126
+
127
+ sceneView.Show();
128
+ sceneView.Focus();
129
+ Selection.activeGameObject = target;
130
+
131
+ var focusPoint = bounds.center;
132
+ var focusSize = Mathf.Max(bounds.extents.magnitude * 2f, 1f);
133
+ var axis = TryReadAxis(parameters);
134
+
135
+ if (axis.HasValue)
136
+ {
137
+ var normalizedAxis = axis.Value.normalized;
138
+ var rotation = Quaternion.LookRotation(-normalizedAxis, SelectUpVector(normalizedAxis));
139
+ sceneView.LookAtDirect(focusPoint, rotation, focusSize);
140
+ }
141
+ else
142
+ {
143
+ sceneView.LookAtDirect(focusPoint, sceneView.rotation, focusSize);
144
+ }
145
+
146
+ sceneView.Repaint();
147
+ SceneView.RepaintAll();
148
+
149
+ return new Dictionary<string, object>
150
+ {
151
+ ["status"] = "ok",
152
+ ["instanceId"] = instanceId,
153
+ ["name"] = target.name,
154
+ ["pivot"] = VectorToList(sceneView.pivot),
155
+ ["cameraPosition"] = VectorToList(sceneView.camera.transform.position),
156
+ ["cameraRotationEuler"] = VectorToList(sceneView.camera.transform.rotation.eulerAngles),
157
+ ["size"] = sceneView.size,
158
+ ["axis"] = axis.HasValue ? VectorToList(axis.Value.normalized) : null
159
+ };
160
+ }
161
+
162
+ private static Vector3? TryReadAxis(Dictionary<string, object> parameters)
163
+ {
164
+ if (parameters == null || !parameters.TryGetValue("axis", out var axisObj) || axisObj == null)
165
+ return null;
166
+
167
+ if (axisObj is not List<object> values || values.Count != 3)
168
+ throw new System.ArgumentException("axis must be an array of exactly three numeric values");
169
+
170
+ var axis = new Vector3(
171
+ System.Convert.ToSingle(values[0]),
172
+ System.Convert.ToSingle(values[1]),
173
+ System.Convert.ToSingle(values[2]));
174
+
175
+ if (axis.sqrMagnitude < 0.0001f)
176
+ throw new System.ArgumentException("axis must not be the zero vector");
177
+
178
+ return axis;
179
+ }
180
+
181
+ private static Vector3 SelectUpVector(Vector3 axis)
182
+ {
183
+ if (Mathf.Abs(Vector3.Dot(axis, Vector3.up)) > 0.98f)
184
+ return Vector3.forward;
185
+
186
+ return Vector3.up;
187
+ }
188
+
189
+ private static Bounds CalculateFocusBounds(GameObject target)
190
+ {
191
+ var hasBounds = false;
192
+ var bounds = new Bounds(target.transform.position, Vector3.one);
193
+
194
+ foreach (var renderer in target.GetComponentsInChildren<Renderer>())
195
+ {
196
+ if (!hasBounds)
197
+ {
198
+ bounds = renderer.bounds;
199
+ hasBounds = true;
200
+ }
201
+ else
202
+ {
203
+ bounds.Encapsulate(renderer.bounds);
204
+ }
205
+ }
206
+
207
+ foreach (var collider in target.GetComponentsInChildren<Collider>())
208
+ {
209
+ if (!hasBounds)
210
+ {
211
+ bounds = collider.bounds;
212
+ hasBounds = true;
213
+ }
214
+ else
215
+ {
216
+ bounds.Encapsulate(collider.bounds);
217
+ }
218
+ }
219
+
220
+ if (!hasBounds)
221
+ bounds = new Bounds(target.transform.position, Vector3.one);
222
+
223
+ return bounds;
224
+ }
225
+
226
+ private static GameObject FindGameObject(int instanceId)
227
+ {
228
+ var direct = EditorUtility.InstanceIDToObject(instanceId) as GameObject;
229
+ if (direct != null)
230
+ return direct;
231
+
232
+ for (var index = 0; index < SceneManager.sceneCount; index++)
233
+ {
234
+ var scene = SceneManager.GetSceneAt(index);
235
+ if (!scene.isLoaded)
236
+ continue;
237
+
238
+ foreach (var root in scene.GetRootGameObjects())
239
+ {
240
+ var found = FindInHierarchy(root, instanceId);
241
+ if (found != null)
242
+ return found;
243
+ }
244
+ }
245
+
246
+ throw new System.ArgumentException($"GameObject not found: {instanceId}");
247
+ }
248
+
249
+ private static GameObject FindInHierarchy(GameObject gameObject, int instanceId)
250
+ {
251
+ if (gameObject.GetInstanceID() == instanceId)
252
+ return gameObject;
253
+
254
+ foreach (Transform child in gameObject.transform)
255
+ {
256
+ var found = FindInHierarchy(child.gameObject, instanceId);
257
+ if (found != null)
258
+ return found;
259
+ }
260
+
261
+ return null;
262
+ }
263
+
264
+ private static List<object> VectorToList(Vector3 value)
265
+ {
266
+ return new List<object> { value.x, value.y, value.z };
267
+ }
110
268
  }
111
269
  }
@@ -17,6 +17,7 @@ namespace UCP.Bridge.Tests
17
17
  private const string TempPrefabPath = "Assets/UcpControllerSmoke.prefab";
18
18
  private const string TempMaterialPath = "Assets/UcpControllerSmoke.mat";
19
19
  private const string TempTextPath = "Assets/UcpControllerSmoke.txt";
20
+ private const string TempScriptPath = "Assets/UcpControllerSmokeComponent.cs";
20
21
 
21
22
  private CommandRouter _router;
22
23
 
@@ -34,12 +35,14 @@ namespace UCP.Bridge.Tests
34
35
  PrefabController.Register(_router);
35
36
  BuildController.Register(_router);
36
37
  EditorSettingsController.Register(_router);
38
+ SceneController.Register(_router);
37
39
  EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
38
40
  DeleteTempAsset();
39
41
  DeleteTempReferenceAsset();
40
42
  DeleteTempPrefab();
41
43
  DeleteTempMaterial();
42
44
  DeleteTempTextFile();
45
+ DeleteTempScriptFile();
43
46
  LogsController.ClearHistoryForTests();
44
47
  }
45
48
 
@@ -51,6 +54,7 @@ namespace UCP.Bridge.Tests
51
54
  DeleteTempPrefab();
52
55
  DeleteTempMaterial();
53
56
  DeleteTempTextFile();
57
+ DeleteTempScriptFile();
54
58
  LogsController.ClearHistoryForTests();
55
59
  EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
56
60
  }
@@ -354,6 +358,72 @@ namespace UCP.Bridge.Tests
354
358
  Assert.That(traversal.error.code, Is.EqualTo(ErrorCodes.FileAccessDenied));
355
359
  }
356
360
 
361
+ [Test]
362
+ public void FileController_Write_RefreshesAssetDatabaseForNewAssets()
363
+ {
364
+ var write = _router.Dispatch(
365
+ "file/write",
366
+ 1,
367
+ "{\"path\":\"Assets/UcpControllerSmokeComponent.cs\",\"content\":\"using UnityEngine; public class UcpControllerSmokeComponent : MonoBehaviour {}\"}"
368
+ );
369
+
370
+ Assert.That(write.error, Is.Null);
371
+
372
+ var script = AssetDatabase.LoadAssetAtPath<MonoScript>(TempScriptPath);
373
+ Assert.That(script, Is.Not.Null);
374
+ Assert.That(script.name, Is.EqualTo("UcpControllerSmokeComponent"));
375
+ }
376
+
377
+ [Test]
378
+ public void SceneFocus_WithAxis_AlignsSceneCameraTowardTarget()
379
+ {
380
+ var sceneView = EditorWindow.GetWindow<SceneView>();
381
+ var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
382
+ cube.name = "FocusTarget";
383
+ cube.transform.position = new Vector3(2f, 1f, 3f);
384
+ cube.transform.localScale = new Vector3(2f, 2f, 2f);
385
+
386
+ var response = _router.Dispatch(
387
+ "scene/focus",
388
+ 1,
389
+ "{\"instanceId\":" + cube.GetInstanceID() + ",\"axis\":[1,0,1]}"
390
+ );
391
+
392
+ Assert.That(response.error, Is.Null);
393
+
394
+ var result = (Dictionary<string, object>)response.result;
395
+ Assert.That(result["name"].ToString(), Is.EqualTo("FocusTarget"));
396
+ Assert.That(Selection.activeGameObject, Is.EqualTo(cube));
397
+
398
+ var expectedDirection = new Vector3(1f, 0f, 1f).normalized;
399
+ var axisData = (List<object>)result["axis"];
400
+ var returnedAxis = new Vector3(
401
+ System.Convert.ToSingle(axisData[0]),
402
+ System.Convert.ToSingle(axisData[1]),
403
+ System.Convert.ToSingle(axisData[2]));
404
+ var actualForward = sceneView.camera.transform.forward;
405
+ Assert.That(Vector3.Dot(returnedAxis.normalized, expectedDirection), Is.GreaterThan(0.98f));
406
+ Assert.That(Mathf.Abs(Vector3.Dot(actualForward.normalized, expectedDirection)), Is.GreaterThan(0.98f));
407
+ Assert.That(Vector3.Distance(sceneView.pivot, cube.transform.position), Is.LessThan(2f));
408
+ }
409
+
410
+ [Test]
411
+ public void SceneFocus_RejectsZeroAxisVector()
412
+ {
413
+ EditorWindow.GetWindow<SceneView>();
414
+ var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
415
+ cube.name = "ZeroAxisTarget";
416
+
417
+ var response = _router.Dispatch(
418
+ "scene/focus",
419
+ 1,
420
+ "{\"instanceId\":" + cube.GetInstanceID() + ",\"axis\":[0,0,0]}"
421
+ );
422
+
423
+ Assert.That(response.error, Is.Not.Null);
424
+ Assert.That(response.error.code, Is.EqualTo(ErrorCodes.InvalidParams));
425
+ }
426
+
357
427
  [Test]
358
428
  public void MaterialController_SetAndGetFloatProperty_RoundTrips()
359
429
  {
@@ -532,6 +602,15 @@ namespace UCP.Bridge.Tests
532
602
  }
533
603
  }
534
604
 
605
+ private static void DeleteTempScriptFile()
606
+ {
607
+ if (AssetDatabase.LoadMainAssetAtPath(TempScriptPath) != null)
608
+ {
609
+ AssetDatabase.DeleteAsset(TempScriptPath);
610
+ AssetDatabase.SaveAssets();
611
+ }
612
+ }
613
+
535
614
  private static string FindFirstFloatOrRangeProperty(Shader shader)
536
615
  {
537
616
  for (var index = 0; index < shader.GetPropertyCount(); index++)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "com.ucp.bridge",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "displayName": "Unity Control Protocol Bridge",
5
5
  "description": "WebSocket bridge for programmatic Unity Editor control via CLI and AI agents.",
6
6
  "unity": "2021.3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mflrevan/ucp",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Unity Control Protocol — CLI for programmatic Unity Editor control",
5
5
  "license": "MIT",
6
6
  "repository": {