@mflrevan/ucp 0.5.2 → 0.6.1

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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/bridge/com.ucp.bridge/Editor/AssemblyInfo.cs +3 -0
  3. package/bridge/com.ucp.bridge/Editor/AssemblyInfo.cs.meta +2 -0
  4. package/bridge/com.ucp.bridge/Editor/Bridge/BridgeServer.cs +45 -5
  5. package/bridge/com.ucp.bridge/Editor/Compatibility/UnityObjectCompat.cs +26 -0
  6. package/bridge/com.ucp.bridge/Editor/Controllers/AssetController.cs +2 -2
  7. package/bridge/com.ucp.bridge/Editor/Controllers/CompilationController.cs +137 -1
  8. package/bridge/com.ucp.bridge/Editor/Controllers/EditorModalGuard.cs +60 -0
  9. package/bridge/com.ucp.bridge/Editor/Controllers/EditorModalGuard.cs.meta +2 -0
  10. package/bridge/com.ucp.bridge/Editor/Controllers/HierarchyController.cs +56 -5
  11. package/bridge/com.ucp.bridge/Editor/Controllers/MaterialController.cs +2 -2
  12. package/bridge/com.ucp.bridge/Editor/Controllers/ObjectLocator.cs +207 -0
  13. package/bridge/com.ucp.bridge/Editor/Controllers/ObjectLocator.cs.meta +2 -0
  14. package/bridge/com.ucp.bridge/Editor/Controllers/ObjectReferenceResolver.cs +1 -1
  15. package/bridge/com.ucp.bridge/Editor/Controllers/PlayModeController.cs +1 -35
  16. package/bridge/com.ucp.bridge/Editor/Controllers/PrefabController.cs +3 -3
  17. package/bridge/com.ucp.bridge/Editor/Controllers/ProfilerController.cs +80 -5
  18. package/bridge/com.ucp.bridge/Editor/Controllers/PropertyController.cs +1 -1
  19. package/bridge/com.ucp.bridge/Editor/Controllers/ReferenceController.cs +1 -1
  20. package/bridge/com.ucp.bridge/Editor/Controllers/SceneChangeTracker.cs +6 -6
  21. package/bridge/com.ucp.bridge/Editor/Controllers/SceneController.cs +26 -36
  22. package/bridge/com.ucp.bridge/Editor/Controllers/ScriptController.cs +82 -23
  23. package/bridge/com.ucp.bridge/Editor/Controllers/SnapshotController.cs +4 -4
  24. package/bridge/com.ucp.bridge/Editor/Controllers/SpatialController.cs +322 -0
  25. package/bridge/com.ucp.bridge/Editor/Controllers/SpatialController.cs.meta +2 -0
  26. package/bridge/com.ucp.bridge/Editor/Controllers/TransformController.cs +249 -0
  27. package/bridge/com.ucp.bridge/Editor/Controllers/TransformController.cs.meta +2 -0
  28. package/bridge/com.ucp.bridge/Editor/Controllers/ViewController.cs +415 -0
  29. package/bridge/com.ucp.bridge/Editor/Controllers/ViewController.cs.meta +2 -0
  30. package/bridge/com.ucp.bridge/Editor/Protocol/MiniJson.cs +409 -63
  31. package/bridge/com.ucp.bridge/Tests/Editor/ControllerSmokeTests.cs +131 -13
  32. package/bridge/com.ucp.bridge/Tests/Editor/MiniJsonSerializerTests.cs +221 -0
  33. package/bridge/com.ucp.bridge/Tests/Editor/MiniJsonSerializerTests.cs.meta +2 -0
  34. package/bridge/com.ucp.bridge/Tests/Editor/SpatialVisualControllerTests.cs +252 -0
  35. package/bridge/com.ucp.bridge/Tests/Editor/SpatialVisualControllerTests.cs.meta +2 -0
  36. package/bridge/com.ucp.bridge/package.json +1 -1
  37. package/package.json +1 -1
@@ -12,29 +12,35 @@ namespace UCP.Bridge
12
12
  router.Register("exec/run", HandleRun);
13
13
  }
14
14
 
15
- private static List<IUCPScript> DiscoverScripts()
15
+ /// <summary>
16
+ /// Implementing types, cached for the lifetime of the app domain. The scan itself is the
17
+ /// expensive part -- `GetTypes()` over every loaded assembly -- and its result cannot go
18
+ /// stale without a domain reload, which resets this static anyway.
19
+ /// </summary>
20
+ private static Type[] s_scriptTypes;
21
+
22
+ private static Type[] DiscoverScriptTypes()
16
23
  {
17
- var scripts = new List<IUCPScript>();
24
+ if (s_scriptTypes != null) return s_scriptTypes;
25
+
18
26
  var interfaceType = typeof(IUCPScript);
27
+ var bridgeAssemblyName = interfaceType.Assembly.GetName().Name;
28
+ var types = new List<Type>();
19
29
 
20
30
  foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
21
31
  {
32
+ // A type can only implement IUCPScript if its assembly references the one that
33
+ // declares it. Checking cheap reference metadata first avoids materialising the
34
+ // full type list of every framework and Unity assembly in the domain.
35
+ if (assembly != interfaceType.Assembly && !ReferencesAssembly(assembly, bridgeAssemblyName))
36
+ continue;
37
+
22
38
  try
23
39
  {
24
40
  foreach (var type in assembly.GetTypes())
25
41
  {
26
42
  if (interfaceType.IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface)
27
- {
28
- try
29
- {
30
- var instance = (IUCPScript)Activator.CreateInstance(type);
31
- scripts.Add(instance);
32
- }
33
- catch (Exception ex)
34
- {
35
- Debug.LogWarning($"[UCP] Failed to instantiate script {type.Name}: {ex.Message}");
36
- }
37
- }
43
+ types.Add(type);
38
44
  }
39
45
  }
40
46
  catch (System.Reflection.ReflectionTypeLoadException)
@@ -43,9 +49,71 @@ namespace UCP.Bridge
43
49
  }
44
50
  }
45
51
 
52
+ s_scriptTypes = types.ToArray();
53
+ return s_scriptTypes;
54
+ }
55
+
56
+ private static bool ReferencesAssembly(System.Reflection.Assembly assembly, string name)
57
+ {
58
+ try
59
+ {
60
+ foreach (var reference in assembly.GetReferencedAssemblies())
61
+ {
62
+ if (string.Equals(reference.Name, name, StringComparison.Ordinal)) return true;
63
+ }
64
+ }
65
+ catch
66
+ {
67
+ // Dynamic assemblies can refuse to report references; treat them as non-matching.
68
+ }
69
+
70
+ return false;
71
+ }
72
+
73
+ private static IUCPScript Instantiate(Type type)
74
+ {
75
+ try
76
+ {
77
+ return (IUCPScript)Activator.CreateInstance(type);
78
+ }
79
+ catch (Exception ex)
80
+ {
81
+ Debug.LogWarning($"[UCP] Failed to instantiate script {type.Name}: {ex.Message}");
82
+ return null;
83
+ }
84
+ }
85
+
86
+ private static List<IUCPScript> DiscoverScripts()
87
+ {
88
+ var scripts = new List<IUCPScript>();
89
+ foreach (var type in DiscoverScriptTypes())
90
+ {
91
+ var instance = Instantiate(type);
92
+ if (instance != null) scripts.Add(instance);
93
+ }
94
+
46
95
  return scripts;
47
96
  }
48
97
 
98
+ /// <summary>
99
+ /// Resolve one script by name, stopping at the first match.
100
+ /// `Name` is an instance member, so candidates must be constructed to be identified --
101
+ /// but running *every* script's constructor to invoke one of them is a side effect nobody
102
+ /// asked for, so stop as soon as the target is found.
103
+ /// </summary>
104
+ private static IUCPScript FindScript(string name)
105
+ {
106
+ foreach (var type in DiscoverScriptTypes())
107
+ {
108
+ var instance = Instantiate(type);
109
+ if (instance == null) continue;
110
+ if (string.Equals(instance.Name, name, StringComparison.OrdinalIgnoreCase))
111
+ return instance;
112
+ }
113
+
114
+ return null;
115
+ }
116
+
49
117
  private static object HandleList(string paramsJson)
50
118
  {
51
119
  var scripts = DiscoverScripts();
@@ -78,16 +146,7 @@ namespace UCP.Bridge
78
146
  if (p.TryGetValue("params", out var paramsObj) && paramsObj != null)
79
147
  scriptParams = MiniJson.Serialize(paramsObj);
80
148
 
81
- var scripts = DiscoverScripts();
82
- IUCPScript target = null;
83
- foreach (var s in scripts)
84
- {
85
- if (string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase))
86
- {
87
- target = s;
88
- break;
89
- }
90
- }
149
+ var target = FindScript(name);
91
150
 
92
151
  if (target == null)
93
152
  throw new ArgumentException($"Script not found: {name}. Use exec/list to see available scripts.");
@@ -86,7 +86,7 @@ namespace UCP.Bridge
86
86
 
87
87
  var entry = new Dictionary<string, object>
88
88
  {
89
- ["instanceId"] = go.GetInstanceID(),
89
+ ["instanceId"] = go.GetId(),
90
90
  ["name"] = go.name,
91
91
  ["active"] = go.activeSelf,
92
92
  ["tag"] = go.tag,
@@ -230,7 +230,7 @@ namespace UCP.Bridge
230
230
  private static Dictionary<string, object> ProjectGameObject(GameObject go, HashSet<string> fields, int depth)
231
231
  {
232
232
  var entry = new Dictionary<string, object>();
233
- AddField(entry, fields, "instanceId", go.GetInstanceID());
233
+ AddField(entry, fields, "instanceId", go.GetId());
234
234
  AddField(entry, fields, "name", go.name);
235
235
  AddField(entry, fields, "active", go.activeSelf);
236
236
  AddField(entry, fields, "activeInHierarchy", go.activeInHierarchy);
@@ -426,7 +426,7 @@ namespace UCP.Bridge
426
426
 
427
427
  private static GameObject FindInHierarchy(GameObject go, int instanceId)
428
428
  {
429
- if (go.GetInstanceID() == instanceId)
429
+ if (go.GetId() == instanceId)
430
430
  return go;
431
431
 
432
432
  for (int i = 0; i < go.transform.childCount; i++)
@@ -479,7 +479,7 @@ namespace UCP.Bridge
479
479
  {
480
480
  return new Dictionary<string, object>
481
481
  {
482
- ["instanceId"] = go.GetInstanceID(),
482
+ ["instanceId"] = go.GetId(),
483
483
  ["name"] = go.name,
484
484
  ["active"] = go.activeSelf,
485
485
  ["tag"] = go.tag,
@@ -0,0 +1,322 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using UnityEditor;
4
+ using UnityEditor.SceneManagement;
5
+ using UnityEngine;
6
+ using UnityEngine.SceneManagement;
7
+
8
+ namespace UCP.Bridge
9
+ {
10
+ /// <summary>
11
+ /// Spatial reasoning primitives so an agent can answer geometric questions about a scene
12
+ /// instead of inferring them from raw transforms: raycast, overlap, world bounds, drop-to-
13
+ /// surface (ground), and nearest-object search.
14
+ ///
15
+ /// Physics queries hit colliders only — objects without a Collider are invisible to
16
+ /// raycast/overlap/ground. 'bounds' and 'nearest' fall back to renderer bounds and so also
17
+ /// see render-only objects.
18
+ /// </summary>
19
+ public static class SpatialController
20
+ {
21
+ public static void Register(CommandRouter router)
22
+ {
23
+ router.Register("physics/raycast", HandleRaycast);
24
+ router.Register("physics/overlap", HandleOverlap);
25
+ router.Register("object/bounds", HandleBounds);
26
+ router.Register("spatial/ground", HandleGround);
27
+ router.Register("spatial/nearest", HandleNearest);
28
+ }
29
+
30
+ private static object HandleRaycast(string paramsJson)
31
+ {
32
+ // Collider positions in the edit-mode physics scene can lag transform edits made via
33
+ // earlier RPCs; sync so queries see the current state.
34
+ Physics.SyncTransforms();
35
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
36
+ var origin = RequireVec3(p, "origin");
37
+ var direction = RequireVec3(p, "direction");
38
+ if (direction.sqrMagnitude < 1e-8f)
39
+ throw new ArgumentException("'direction' must not be the zero vector");
40
+
41
+ var maxDistance = ReadFloat(p, "maxDistance", Mathf.Infinity);
42
+ var layerMask = ReadLayerMask(p);
43
+ var queryTriggers = ReadBool(p, "queryTriggers", false)
44
+ ? QueryTriggerInteraction.Collide
45
+ : QueryTriggerInteraction.Ignore;
46
+
47
+ if (Physics.Raycast(new Ray(origin, direction.normalized), out var hit, maxDistance, layerMask, queryTriggers))
48
+ {
49
+ return new Dictionary<string, object>
50
+ {
51
+ ["status"] = "ok",
52
+ ["hit"] = true,
53
+ ["point"] = ObjectLocator.Vec3(hit.point),
54
+ ["normal"] = ObjectLocator.Vec3(hit.normal),
55
+ ["distance"] = hit.distance,
56
+ ["instanceId"] = hit.collider.gameObject.GetId(),
57
+ ["gameObject"] = hit.collider.gameObject.name,
58
+ ["collider"] = hit.collider.GetType().Name
59
+ };
60
+ }
61
+
62
+ return new Dictionary<string, object> { ["status"] = "ok", ["hit"] = false };
63
+ }
64
+
65
+ private static object HandleOverlap(string paramsJson)
66
+ {
67
+ Physics.SyncTransforms();
68
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
69
+ var shape = (ReadString(p, "shape") ?? "sphere").ToLowerInvariant();
70
+ var center = RequireVec3(p, "center");
71
+ var layerMask = ReadLayerMask(p);
72
+ var queryTriggers = ReadBool(p, "queryTriggers", false)
73
+ ? QueryTriggerInteraction.Collide
74
+ : QueryTriggerInteraction.Ignore;
75
+
76
+ Collider[] hits;
77
+ switch (shape)
78
+ {
79
+ case "sphere":
80
+ hits = Physics.OverlapSphere(center, ReadFloat(p, "radius", 1f), layerMask, queryTriggers);
81
+ break;
82
+ case "box":
83
+ var half = ReadVec3Optional(p, "halfExtents") ?? Vector3.one * 0.5f;
84
+ hits = Physics.OverlapBox(center, half, Quaternion.identity, layerMask, queryTriggers);
85
+ break;
86
+ case "capsule":
87
+ var end = ReadVec3Optional(p, "end") ?? center;
88
+ hits = Physics.OverlapCapsule(center, end, ReadFloat(p, "radius", 1f), layerMask, queryTriggers);
89
+ break;
90
+ default:
91
+ throw new ArgumentException("'shape' must be 'sphere', 'box', or 'capsule'");
92
+ }
93
+
94
+ var list = new List<object>();
95
+ foreach (var c in hits)
96
+ {
97
+ if (c == null) continue;
98
+ list.Add(new Dictionary<string, object>
99
+ {
100
+ ["instanceId"] = c.gameObject.GetId(),
101
+ ["gameObject"] = c.gameObject.name,
102
+ ["collider"] = c.GetType().Name,
103
+ ["distance"] = Vector3.Distance(center, c.bounds.center)
104
+ });
105
+ }
106
+
107
+ return new Dictionary<string, object> { ["status"] = "ok", ["count"] = list.Count, ["hits"] = list };
108
+ }
109
+
110
+ private static object HandleBounds(string paramsJson)
111
+ {
112
+ // Collider bounds lag transform edits in edit mode; sync before reading them.
113
+ Physics.SyncTransforms();
114
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
115
+ var go = ObjectLocator.Resolve(p);
116
+ var includeChildren = ReadBool(p, "includeChildren", true);
117
+
118
+ if (!ObjectLocator.TryComputeWorldBounds(go, includeChildren, out var bounds))
119
+ {
120
+ // No renderers/colliders: fall back to a zero-size box at the transform.
121
+ bounds = new Bounds(go.transform.position, Vector3.zero);
122
+ return new Dictionary<string, object>
123
+ {
124
+ ["status"] = "ok",
125
+ ["instanceId"] = go.GetId(),
126
+ ["name"] = go.name,
127
+ ["empty"] = true,
128
+ ["center"] = ObjectLocator.Vec3(bounds.center),
129
+ ["extents"] = ObjectLocator.Vec3(bounds.extents),
130
+ ["size"] = ObjectLocator.Vec3(bounds.size),
131
+ ["min"] = ObjectLocator.Vec3(bounds.min),
132
+ ["max"] = ObjectLocator.Vec3(bounds.max)
133
+ };
134
+ }
135
+
136
+ return new Dictionary<string, object>
137
+ {
138
+ ["status"] = "ok",
139
+ ["instanceId"] = go.GetId(),
140
+ ["name"] = go.name,
141
+ ["empty"] = false,
142
+ ["center"] = ObjectLocator.Vec3(bounds.center),
143
+ ["extents"] = ObjectLocator.Vec3(bounds.extents),
144
+ ["size"] = ObjectLocator.Vec3(bounds.size),
145
+ ["min"] = ObjectLocator.Vec3(bounds.min),
146
+ ["max"] = ObjectLocator.Vec3(bounds.max)
147
+ };
148
+ }
149
+
150
+ private static object HandleGround(string paramsJson)
151
+ {
152
+ Physics.SyncTransforms();
153
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
154
+ var direction = (ReadVec3Optional(p, "direction") ?? Vector3.down).normalized;
155
+ var maxDistance = ReadFloat(p, "maxDistance", 1000f);
156
+ var layerMask = ReadLayerMask(p);
157
+ var apply = ReadBool(p, "apply", true);
158
+
159
+ // Two modes: drop an object onto the surface, or just probe a point.
160
+ GameObject go = null;
161
+ Vector3 origin;
162
+ if (p != null && (p.ContainsKey("instanceId") || p.ContainsKey("id") || p.ContainsKey("path") || p.ContainsKey("name")))
163
+ {
164
+ go = ObjectLocator.Resolve(p);
165
+ origin = go.transform.position;
166
+ }
167
+ else
168
+ {
169
+ origin = RequireVec3(p, "point");
170
+ }
171
+
172
+ // Offset the ray start slightly against the cast direction so an object already
173
+ // resting on / overlapping the surface still registers a hit.
174
+ var start = origin - direction * 0.01f;
175
+ if (!Physics.Raycast(start, direction, out var hit, maxDistance, layerMask, QueryTriggerInteraction.Ignore))
176
+ return new Dictionary<string, object> { ["status"] = "ok", ["hit"] = false };
177
+
178
+ var result = new Dictionary<string, object>
179
+ {
180
+ ["status"] = "ok",
181
+ ["hit"] = true,
182
+ ["point"] = ObjectLocator.Vec3(hit.point),
183
+ ["normal"] = ObjectLocator.Vec3(hit.normal),
184
+ ["distance"] = hit.distance,
185
+ ["surface"] = hit.collider.gameObject.name,
186
+ ["surfaceId"] = hit.collider.gameObject.GetId()
187
+ };
188
+
189
+ if (go != null && apply)
190
+ {
191
+ // Rest the object's pivot on the surface, raised by the half-height of its
192
+ // bounds along the up axis so it sits on rather than through the surface.
193
+ var lift = 0f;
194
+ if (ObjectLocator.TryComputeWorldBounds(go, true, out var bounds))
195
+ {
196
+ var pivotToBottom = go.transform.position.y - bounds.min.y;
197
+ lift = Mathf.Max(pivotToBottom, 0f);
198
+ }
199
+ Undo.RecordObject(go.transform, "UCP Ground");
200
+ go.transform.position = hit.point + Vector3.up * lift;
201
+ EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene());
202
+ SceneChangeTracker.RecordGameObjectChange(go, "Transform");
203
+ result["movedId"] = go.GetId();
204
+ result["restPosition"] = ObjectLocator.Vec3(go.transform.position);
205
+ }
206
+
207
+ return result;
208
+ }
209
+
210
+ private static object HandleNearest(string paramsJson)
211
+ {
212
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
213
+
214
+ Vector3 from;
215
+ GameObject self = null;
216
+ if (p != null && (p.ContainsKey("instanceId") || p.ContainsKey("id") || p.ContainsKey("path") || p.ContainsKey("name")))
217
+ {
218
+ self = ObjectLocator.Resolve(p);
219
+ from = self.transform.position;
220
+ }
221
+ else
222
+ {
223
+ from = RequireVec3(p, "point");
224
+ }
225
+
226
+ var max = (int)ReadFloat(p, "max", 5f);
227
+ var componentFilter = ReadString(p, "component");
228
+ var tagFilter = ReadString(p, "tag");
229
+
230
+ var candidates = new List<(GameObject go, float dist)>();
231
+ for (var i = 0; i < SceneManager.sceneCount; i++)
232
+ {
233
+ var scene = SceneManager.GetSceneAt(i);
234
+ if (!scene.isLoaded) continue;
235
+ foreach (var root in scene.GetRootGameObjects())
236
+ CollectNearest(root, from, self, componentFilter, tagFilter, candidates);
237
+ }
238
+
239
+ candidates.Sort((a, b) => a.dist.CompareTo(b.dist));
240
+ var list = new List<object>();
241
+ for (var i = 0; i < candidates.Count && i < max; i++)
242
+ {
243
+ var (go, dist) = candidates[i];
244
+ list.Add(new Dictionary<string, object>
245
+ {
246
+ ["instanceId"] = go.GetId(),
247
+ ["name"] = go.name,
248
+ ["distance"] = dist,
249
+ ["position"] = ObjectLocator.Vec3(go.transform.position)
250
+ });
251
+ }
252
+
253
+ return new Dictionary<string, object> { ["status"] = "ok", ["count"] = list.Count, ["objects"] = list };
254
+ }
255
+
256
+ private static void CollectNearest(GameObject go, Vector3 from, GameObject self,
257
+ string componentFilter, string tagFilter, List<(GameObject, float)> outList)
258
+ {
259
+ var include = go != self;
260
+ if (include && !string.IsNullOrEmpty(componentFilter) && go.GetComponent(componentFilter) == null)
261
+ include = false;
262
+ if (include && !string.IsNullOrEmpty(tagFilter) && !go.CompareTag(tagFilter))
263
+ include = false;
264
+ if (include)
265
+ outList.Add((go, Vector3.Distance(from, go.transform.position)));
266
+
267
+ foreach (Transform child in go.transform)
268
+ CollectNearest(child.gameObject, from, self, componentFilter, tagFilter, outList);
269
+ }
270
+
271
+ // --- param helpers -------------------------------------------------
272
+
273
+ private static Vector3 RequireVec3(Dictionary<string, object> p, string key)
274
+ {
275
+ var v = ReadVec3Optional(p, key);
276
+ if (!v.HasValue) throw new ArgumentException($"Missing '{key}' ([x,y,z]) parameter");
277
+ return v.Value;
278
+ }
279
+
280
+ private static Vector3? ReadVec3Optional(Dictionary<string, object> p, string key)
281
+ {
282
+ if (p == null || !p.TryGetValue(key, out var v) || v == null) return null;
283
+ if (v is not List<object> list || list.Count < 3)
284
+ throw new ArgumentException($"'{key}' must be an array of three numbers");
285
+ return new Vector3(Convert.ToSingle(list[0]), Convert.ToSingle(list[1]), Convert.ToSingle(list[2]));
286
+ }
287
+
288
+ private static float ReadFloat(Dictionary<string, object> p, string key, float dflt)
289
+ {
290
+ if (p != null && p.TryGetValue(key, out var v) && v != null) return Convert.ToSingle(v);
291
+ return dflt;
292
+ }
293
+
294
+ private static bool ReadBool(Dictionary<string, object> p, string key, bool dflt)
295
+ {
296
+ if (p != null && p.TryGetValue(key, out var v) && v is bool b) return b;
297
+ return dflt;
298
+ }
299
+
300
+ private static string ReadString(Dictionary<string, object> p, string key)
301
+ {
302
+ if (p != null && p.TryGetValue(key, out var v) && v != null) return v.ToString();
303
+ return null;
304
+ }
305
+
306
+ private static int ReadLayerMask(Dictionary<string, object> p)
307
+ {
308
+ // Accept an explicit int mask, or a single layer name, else everything.
309
+ if (p != null && p.TryGetValue("layerMask", out var v) && v != null)
310
+ {
311
+ if (v is string s)
312
+ {
313
+ var layer = LayerMask.NameToLayer(s);
314
+ if (layer < 0) throw new ArgumentException($"Unknown layer name '{s}'");
315
+ return 1 << layer;
316
+ }
317
+ return Convert.ToInt32(v);
318
+ }
319
+ return ~0;
320
+ }
321
+ }
322
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: 2a3b4c5d6e7f8091a2b3c4d5e6f70819