@mflrevan/ucp 0.6.0 → 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.
@@ -28,7 +28,13 @@ namespace UCP.Bridge
28
28
 
29
29
  private sealed class Parser : IDisposable
30
30
  {
31
+ /// Guards against stack overflow on deeply nested input. A stack overflow is not
32
+ /// catchable in .NET and kills the editor process outright, so the parser trades
33
+ /// pathological depth for a normal, catchable exception.
34
+ private const int MaxParseDepth = 256;
35
+
31
36
  private StringReader _reader;
37
+ private int _depth;
32
38
 
33
39
  private Parser(string jsonString)
34
40
  {
@@ -78,16 +84,24 @@ namespace UCP.Bridge
78
84
  ReadChar(); // {
79
85
  var dict = new Dictionary<string, object>();
80
86
 
81
- while (true)
87
+ EnterContainer();
88
+ try
82
89
  {
83
- EatWhitespace();
84
- if (PeekChar() == '}') { ReadChar(); return dict; }
85
- if (PeekChar() == ',') { ReadChar(); continue; }
86
-
87
- var key = ParseString();
88
- EatWhitespace();
89
- ReadChar(); // :
90
- dict[key] = ParseValue();
90
+ while (true)
91
+ {
92
+ EatWhitespace();
93
+ if (PeekChar() == '}') { ReadChar(); return dict; }
94
+ if (PeekChar() == ',') { ReadChar(); continue; }
95
+
96
+ var key = ParseString();
97
+ EatWhitespace();
98
+ ReadChar(); // :
99
+ dict[key] = ParseValue();
100
+ }
101
+ }
102
+ finally
103
+ {
104
+ _depth--;
91
105
  }
92
106
  }
93
107
 
@@ -96,16 +110,31 @@ namespace UCP.Bridge
96
110
  ReadChar(); // [
97
111
  var list = new List<object>();
98
112
 
99
- while (true)
113
+ EnterContainer();
114
+ try
100
115
  {
101
- EatWhitespace();
102
- if (PeekChar() == ']') { ReadChar(); return list; }
103
- if (PeekChar() == ',') { ReadChar(); continue; }
116
+ while (true)
117
+ {
118
+ EatWhitespace();
119
+ if (PeekChar() == ']') { ReadChar(); return list; }
120
+ if (PeekChar() == ',') { ReadChar(); continue; }
104
121
 
105
- list.Add(ParseValue());
122
+ list.Add(ParseValue());
123
+ }
124
+ }
125
+ finally
126
+ {
127
+ _depth--;
106
128
  }
107
129
  }
108
130
 
131
+ private void EnterContainer()
132
+ {
133
+ if (_depth >= MaxParseDepth)
134
+ throw new FormatException($"JSON nesting exceeds {MaxParseDepth} levels");
135
+ _depth++;
136
+ }
137
+
109
138
  private string ParseString()
110
139
  {
111
140
  ReadChar(); // opening "
@@ -200,16 +229,45 @@ namespace UCP.Bridge
200
229
  return c < 0 ? '\0' : (char)c;
201
230
  }
202
231
 
232
+ /// Truncated input used to yield '\0' forever, which spun ParseString in an infinite
233
+ /// loop and hung the editor's main thread. Fail loudly at end-of-input instead.
203
234
  private char ReadChar()
204
235
  {
205
236
  int c = _reader.Read();
206
- return c < 0 ? '\0' : (char)c;
237
+ if (c < 0) throw new FormatException("Unexpected end of JSON input");
238
+ return (char)c;
207
239
  }
208
240
  }
209
241
 
210
242
  private sealed class Serializer
211
243
  {
244
+ /// <summary>
245
+ /// Reflection is the dangerous path: computed properties can hand back fresh instances
246
+ /// of their own type forever (UnityEngine.Vector3.normalized is the canonical example),
247
+ /// so an unbounded walk stack-overflows -- which is not catchable in .NET and takes the
248
+ /// whole editor process down with it. Bound it.
249
+ /// </summary>
250
+ private const int MaxReflectionDepth = 8;
251
+
252
+ /// Backstop for container nesting (dictionaries/lists). Reference cycles are caught
253
+ /// separately, so this only fires on genuinely pathological payloads.
254
+ private const int MaxDepth = 96;
255
+
256
+ /// Total reflected objects per payload, to bound fan-out: a type whose properties each
257
+ /// return new instances of a similar type grows exponentially, not linearly, with depth.
258
+ private const int MaxReflectedObjects = 20000;
259
+
260
+ private const string MaxDepthMarker = "<ucp:max-depth>";
261
+ private const string CycleMarker = "<ucp:cycle>";
262
+ private const string TruncatedMarker = "<ucp:truncated>";
263
+
264
+ private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
265
+
212
266
  private readonly StringBuilder _sb = new();
267
+ private readonly HashSet<object> _visited = new(ReferenceComparer.Instance);
268
+ private int _depth;
269
+ private int _reflectionDepth;
270
+ private int _reflectedObjects;
213
271
 
214
272
  public static string Serialize(object obj)
215
273
  {
@@ -224,43 +282,270 @@ namespace UCP.Bridge
224
282
  {
225
283
  case null:
226
284
  _sb.Append("null");
227
- break;
285
+ return;
228
286
  case string s:
229
287
  WriteString(s);
230
- break;
288
+ return;
231
289
  case bool b:
232
290
  _sb.Append(b ? "true" : "false");
233
- break;
234
- case IDictionary dict:
235
- WriteDict(dict);
236
- break;
237
- case IList list:
238
- WriteArray(list);
239
- break;
291
+ return;
240
292
  case float f:
241
- _sb.Append(f.ToString("R", CultureInfo.InvariantCulture));
242
- break;
293
+ WriteFloat(f);
294
+ return;
243
295
  case double d:
244
- _sb.Append(d.ToString("R", CultureInfo.InvariantCulture));
245
- break;
246
- case int i:
247
- _sb.Append(i);
248
- break;
249
- case long l:
250
- _sb.Append(l);
251
- break;
296
+ WriteDouble(d);
297
+ return;
298
+ case decimal m:
299
+ _sb.Append(m.ToString(Inv));
300
+ return;
252
301
  case Enum e:
253
- _sb.Append(Convert.ToInt32(e));
254
- break;
302
+ WriteEnum(e);
303
+ return;
304
+ case byte v:
305
+ _sb.Append(((int)v).ToString(Inv));
306
+ return;
307
+ case sbyte v:
308
+ _sb.Append(((int)v).ToString(Inv));
309
+ return;
310
+ case short v:
311
+ _sb.Append(((int)v).ToString(Inv));
312
+ return;
313
+ case ushort v:
314
+ _sb.Append(((int)v).ToString(Inv));
315
+ return;
316
+ case int v:
317
+ _sb.Append(v.ToString(Inv));
318
+ return;
319
+ case uint v:
320
+ _sb.Append(v.ToString(Inv));
321
+ return;
322
+ case long v:
323
+ _sb.Append(v.ToString(Inv));
324
+ return;
325
+ case ulong v:
326
+ _sb.Append(v.ToString(Inv));
327
+ return;
328
+ case char c:
329
+ WriteString(c.ToString());
330
+ return;
331
+ case DateTime dt:
332
+ WriteString(dt.ToString("o", Inv));
333
+ return;
334
+ case DateTimeOffset dto:
335
+ WriteString(dto.ToString("o", Inv));
336
+ return;
337
+ case TimeSpan ts:
338
+ WriteString(ts.ToString(null, Inv));
339
+ return;
340
+ case Guid g:
341
+ WriteString(g.ToString());
342
+ return;
343
+ case Type t:
344
+ WriteString(t.FullName);
345
+ return;
346
+ }
347
+
348
+ // Unity's math structs expose self-referential computed properties
349
+ // (Vector3.normalized, Quaternion.normalized, Bounds.extents, ...) plus indexers.
350
+ // Reflecting over them is what crashed the editor, so give them explicit shapes.
351
+ if (TryWriteUnityValue(obj)) return;
352
+
353
+ // UnityEngine.Object graphs are cyclic by construction
354
+ // (GameObject.transform.gameObject), reach the entire scene, and carry no useful
355
+ // JSON projection. Emit an identity instead of walking them.
356
+ if (obj is UnityEngine.Object uo)
357
+ {
358
+ WriteUnityObject(uo);
359
+ return;
360
+ }
361
+
362
+ // Everything below here recurses, so it needs the depth and cycle guards.
363
+ if (_depth >= MaxDepth)
364
+ {
365
+ WriteString(MaxDepthMarker);
366
+ return;
367
+ }
368
+
369
+ var track = !obj.GetType().IsValueType;
370
+ if (track && !_visited.Add(obj))
371
+ {
372
+ WriteString(CycleMarker);
373
+ return;
374
+ }
375
+
376
+ _depth++;
377
+ try
378
+ {
379
+ switch (obj)
380
+ {
381
+ case IDictionary dict:
382
+ WriteDict(dict);
383
+ break;
384
+ case IList list:
385
+ WriteArray(list);
386
+ break;
387
+ case IEnumerable seq:
388
+ WriteEnumerable(seq);
389
+ break;
390
+ default:
391
+ WriteObject(obj);
392
+ break;
393
+ }
394
+ }
395
+ finally
396
+ {
397
+ _depth--;
398
+ if (track) _visited.Remove(obj);
399
+ }
400
+ }
401
+
402
+ private void WriteEnum(Enum e)
403
+ {
404
+ try
405
+ {
406
+ if (Enum.GetUnderlyingType(e.GetType()) == typeof(ulong))
407
+ _sb.Append(Convert.ToUInt64(e).ToString(Inv));
408
+ else
409
+ _sb.Append(Convert.ToInt64(e).ToString(Inv));
410
+ }
411
+ catch
412
+ {
413
+ WriteString(e.ToString());
414
+ }
415
+ }
416
+
417
+ /// NaN and Infinity are not valid JSON; emitting them raw yields a payload the CLI
418
+ /// cannot parse. Unity produces them routinely (degenerate bounds, zero-length
419
+ /// normalize, uninitialized transforms).
420
+ private void WriteFloat(float f)
421
+ {
422
+ if (float.IsNaN(f) || float.IsInfinity(f)) _sb.Append("null");
423
+ else _sb.Append(f.ToString("R", Inv));
424
+ }
425
+
426
+ private void WriteDouble(double d)
427
+ {
428
+ if (double.IsNaN(d) || double.IsInfinity(d)) _sb.Append("null");
429
+ else _sb.Append(d.ToString("R", Inv));
430
+ }
431
+
432
+ private bool TryWriteUnityValue(object obj)
433
+ {
434
+ switch (obj)
435
+ {
436
+ case UnityEngine.Vector2 v:
437
+ WriteFloats(("x", v.x), ("y", v.y));
438
+ return true;
439
+ case UnityEngine.Vector3 v:
440
+ WriteFloats(("x", v.x), ("y", v.y), ("z", v.z));
441
+ return true;
442
+ case UnityEngine.Vector4 v:
443
+ WriteFloats(("x", v.x), ("y", v.y), ("z", v.z), ("w", v.w));
444
+ return true;
445
+ case UnityEngine.Quaternion q:
446
+ WriteFloats(("x", q.x), ("y", q.y), ("z", q.z), ("w", q.w));
447
+ return true;
448
+ case UnityEngine.Color c:
449
+ WriteFloats(("r", c.r), ("g", c.g), ("b", c.b), ("a", c.a));
450
+ return true;
451
+ case UnityEngine.Color32 c:
452
+ WriteFloats(("r", c.r), ("g", c.g), ("b", c.b), ("a", c.a));
453
+ return true;
454
+ case UnityEngine.Vector2Int v:
455
+ WriteFloats(("x", v.x), ("y", v.y));
456
+ return true;
457
+ case UnityEngine.Vector3Int v:
458
+ WriteFloats(("x", v.x), ("y", v.y), ("z", v.z));
459
+ return true;
460
+ case UnityEngine.Rect r:
461
+ WriteFloats(("x", r.x), ("y", r.y), ("width", r.width), ("height", r.height));
462
+ return true;
463
+ case UnityEngine.RectInt r:
464
+ WriteFloats(("x", r.x), ("y", r.y), ("width", r.width), ("height", r.height));
465
+ return true;
466
+ case UnityEngine.Bounds b:
467
+ WriteBounds(b.center, b.size);
468
+ return true;
469
+ case UnityEngine.BoundsInt b:
470
+ WriteBounds(b.center, b.size);
471
+ return true;
472
+ case UnityEngine.Matrix4x4 mtx:
473
+ _sb.Append('[');
474
+ for (int i = 0; i < 16; i++)
475
+ {
476
+ if (i > 0) _sb.Append(',');
477
+ WriteFloat(mtx[i]);
478
+ }
479
+ _sb.Append(']');
480
+ return true;
255
481
  default:
256
- // For anonymous types and other objects, use reflection
257
- WriteObject(obj);
258
- break;
482
+ return false;
483
+ }
484
+ }
485
+
486
+ private void WriteFloats(params (string Name, float Value)[] members)
487
+ {
488
+ _sb.Append('{');
489
+ for (int i = 0; i < members.Length; i++)
490
+ {
491
+ if (i > 0) _sb.Append(',');
492
+ WriteString(members[i].Name);
493
+ _sb.Append(':');
494
+ WriteFloat(members[i].Value);
495
+ }
496
+ _sb.Append('}');
497
+ }
498
+
499
+ private void WriteBounds(UnityEngine.Vector3 center, UnityEngine.Vector3 size)
500
+ {
501
+ _sb.Append("{\"center\":");
502
+ WriteFloats(("x", center.x), ("y", center.y), ("z", center.z));
503
+ _sb.Append(",\"size\":");
504
+ WriteFloats(("x", size.x), ("y", size.y), ("z", size.z));
505
+ _sb.Append('}');
506
+ }
507
+
508
+ private void WriteUnityObject(UnityEngine.Object uo)
509
+ {
510
+ // Unity's overloaded == reports destroyed objects as null even though the managed
511
+ // reference is alive, and touching .name on those throws.
512
+ if (uo == null)
513
+ {
514
+ _sb.Append("null");
515
+ return;
516
+ }
517
+
518
+ string name;
519
+ int id;
520
+ try
521
+ {
522
+ name = uo.name;
523
+ id = uo.GetId();
259
524
  }
525
+ catch
526
+ {
527
+ _sb.Append("null");
528
+ return;
529
+ }
530
+
531
+ _sb.Append('{');
532
+ WriteString("name");
533
+ _sb.Append(':');
534
+ WriteString(name);
535
+ _sb.Append(",\"instanceId\":");
536
+ _sb.Append(id.ToString(Inv));
537
+ _sb.Append(",\"type\":");
538
+ WriteString(uo.GetType().Name);
539
+ _sb.Append('}');
260
540
  }
261
541
 
262
542
  private void WriteString(string s)
263
543
  {
544
+ if (s == null)
545
+ {
546
+ _sb.Append("null");
547
+ return;
548
+ }
264
549
  _sb.Append('"');
265
550
  foreach (var c in s)
266
551
  {
@@ -292,7 +577,8 @@ namespace UCP.Bridge
292
577
  {
293
578
  if (!first) _sb.Append(',');
294
579
  first = false;
295
- WriteString(entry.Key.ToString());
580
+ // A key must always be a quoted string, even if ToString() yields null.
581
+ WriteString(entry.Key?.ToString() ?? string.Empty);
296
582
  _sb.Append(':');
297
583
  WriteValue(entry.Value);
298
584
  }
@@ -310,7 +596,45 @@ namespace UCP.Bridge
310
596
  _sb.Append(']');
311
597
  }
312
598
 
599
+ private void WriteEnumerable(IEnumerable seq)
600
+ {
601
+ _sb.Append('[');
602
+ bool first = true;
603
+ foreach (var item in seq)
604
+ {
605
+ if (!first) _sb.Append(',');
606
+ first = false;
607
+ WriteValue(item);
608
+ }
609
+ _sb.Append(']');
610
+ }
611
+
313
612
  private void WriteObject(object obj)
613
+ {
614
+ if (_reflectionDepth >= MaxReflectionDepth)
615
+ {
616
+ WriteString(MaxDepthMarker);
617
+ return;
618
+ }
619
+ if (_reflectedObjects >= MaxReflectedObjects)
620
+ {
621
+ WriteString(TruncatedMarker);
622
+ return;
623
+ }
624
+
625
+ _reflectedObjects++;
626
+ _reflectionDepth++;
627
+ try
628
+ {
629
+ WriteObjectMembers(obj);
630
+ }
631
+ finally
632
+ {
633
+ _reflectionDepth--;
634
+ }
635
+ }
636
+
637
+ private void WriteObjectMembers(object obj)
314
638
  {
315
639
  var type = obj.GetType();
316
640
  var props = type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
@@ -322,37 +646,59 @@ namespace UCP.Bridge
322
646
  foreach (var prop in props)
323
647
  {
324
648
  if (!prop.CanRead) continue;
325
- try
326
- {
327
- var val = prop.GetValue(obj);
328
- if (!first) _sb.Append(',');
329
- first = false;
330
- // Convert PascalCase to camelCase
331
- var name = char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1);
332
- WriteString(name);
333
- _sb.Append(':');
334
- WriteValue(val);
335
- }
336
- catch { }
649
+ // Indexers (Vector3's this[int], IList's this[int], ...) have no readable value.
650
+ if (prop.GetIndexParameters().Length > 0) continue;
651
+ var captured = prop;
652
+ WriteMember(captured.Name, () => captured.GetValue(obj), ref first);
337
653
  }
338
654
 
339
655
  foreach (var field in fields)
340
656
  {
341
- try
342
- {
343
- var val = field.GetValue(obj);
344
- if (!first) _sb.Append(',');
345
- first = false;
346
- var name = char.ToLowerInvariant(field.Name[0]) + field.Name.Substring(1);
347
- WriteString(name);
348
- _sb.Append(':');
349
- WriteValue(val);
350
- }
351
- catch { }
657
+ var captured = field;
658
+ WriteMember(captured.Name, () => captured.GetValue(obj), ref first);
352
659
  }
353
660
 
354
661
  _sb.Append('}');
355
662
  }
663
+
664
+ /// Writes one member, rolling the buffer back if reading or serializing it throws.
665
+ /// The separator and key used to be appended before the value was evaluated, so a
666
+ /// throwing getter left a dangling `"key":` behind and produced invalid JSON.
667
+ private void WriteMember(string name, Func<object> read, ref bool first)
668
+ {
669
+ var rollback = _sb.Length;
670
+ var wasFirst = first;
671
+ try
672
+ {
673
+ var val = read();
674
+ if (!first) _sb.Append(',');
675
+ first = false;
676
+ // Convert PascalCase to camelCase
677
+ WriteString(string.IsNullOrEmpty(name)
678
+ ? name
679
+ : char.ToLowerInvariant(name[0]) + name.Substring(1));
680
+ _sb.Append(':');
681
+ WriteValue(val);
682
+ }
683
+ catch
684
+ {
685
+ _sb.Length = rollback;
686
+ first = wasFirst;
687
+ }
688
+ }
689
+
690
+ /// Identity comparer for cycle detection: value equality would collapse distinct but
691
+ /// equal nodes, and a payload type's own Equals/GetHashCode can be arbitrarily
692
+ /// expensive or throw.
693
+ private sealed class ReferenceComparer : IEqualityComparer<object>
694
+ {
695
+ public static readonly ReferenceComparer Instance = new();
696
+
697
+ public new bool Equals(object x, object y) => ReferenceEquals(x, y);
698
+
699
+ public int GetHashCode(object obj) =>
700
+ System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
701
+ }
356
702
  }
357
703
  }
358
704
  }
@@ -307,10 +307,48 @@ namespace UCP.Bridge.Tests
307
307
  var status = _router.Dispatch("logs/status", 1, "{}");
308
308
  Assert.That(status.error, Is.Null);
309
309
 
310
- var result = (Dictionary<string, object>)status.result;
311
- var byLevel = (Dictionary<string, object>)result["byLevel"];
312
- Assert.That(Convert.ToInt32(byLevel["error"]), Is.EqualTo(0));
313
- Assert.That(Convert.ToInt32(byLevel["exception"]), Is.EqualTo(0));
310
+ Assert.That(status.result, Is.InstanceOf<Dictionary<string, object>>());
311
+
312
+ // Assert on the specific regression this test exists for -- `asset/search` loading
313
+ // `.unity` files through LoadAllAssetsAtPath, which makes Unity emit
314
+ // "Do not use ReadObjectThreaded on scene objects!" -- rather than on "zero errors of
315
+ // any kind". A blanket count also catches unrelated editor noise: on a cold Library the
316
+ // search pulls in lazy imports whose URP shader-fallback errors have nothing to do with
317
+ // this code path, which made the whole release matrix red on every Unity version.
318
+ var problems = BufferedProblems();
319
+ var threadedReadErrors = problems
320
+ .FindAll(entry => entry.Contains("ReadObjectThreaded"));
321
+
322
+ Assert.That(
323
+ threadedReadErrors,
324
+ Is.Empty,
325
+ () => "asset/search emitted threaded scene-read errors:" + NewLineIndent
326
+ + string.Join(NewLineIndent, threadedReadErrors));
327
+ }
328
+
329
+ private const string NewLineIndent = "\n ";
330
+
331
+ /// Buffered error/exception entries, as "[level] message" strings.
332
+ private List<string> BufferedProblems()
333
+ {
334
+ var lines = new List<string>();
335
+ var tail = _router.Dispatch("logs/tail", 1, "{\"count\":200}");
336
+ if (tail.error != null || tail.result is not Dictionary<string, object> payload)
337
+ return lines;
338
+
339
+ if (!payload.TryGetValue("logs", out var logsObj) || logsObj is not List<object> logs)
340
+ return lines;
341
+
342
+ foreach (var item in logs)
343
+ {
344
+ if (item is not Dictionary<string, object> entry) continue;
345
+ var level = entry.TryGetValue("level", out var l) ? l?.ToString() : null;
346
+ if (level != "error" && level != "exception") continue;
347
+ var message = entry.TryGetValue("messagePreview", out var m) ? m?.ToString() : "";
348
+ lines.Add($"[{level}] {message}");
349
+ }
350
+
351
+ return lines;
314
352
  }
315
353
 
316
354
  [Test]
@@ -1142,9 +1180,33 @@ namespace UCP.Bridge.Tests
1142
1180
  System.Convert.ToSingle(axisData[0]),
1143
1181
  System.Convert.ToSingle(axisData[1]),
1144
1182
  System.Convert.ToSingle(axisData[2]));
1145
- var actualForward = sceneView.camera.transform.forward;
1146
1183
  Assert.That(Vector3.Dot(returnedAxis.normalized, expectedDirection), Is.GreaterThan(0.98f));
1147
- Assert.That(Mathf.Abs(Vector3.Dot(actualForward.normalized, expectedDirection)), Is.GreaterThan(0.98f));
1184
+
1185
+ // Assert against the Scene view's own state and the reported pose, not
1186
+ // `sceneView.camera.transform`: that transform is only synced when the view repaints,
1187
+ // so in batch mode it still holds the pre-focus pose and this check used to fail.
1188
+ var viewForward = sceneView.rotation * Vector3.forward;
1189
+ Assert.That(Mathf.Abs(Vector3.Dot(viewForward.normalized, expectedDirection)), Is.GreaterThan(0.98f));
1190
+
1191
+ var eulerData = (List<object>)result["cameraRotationEuler"];
1192
+ var reportedForward = Quaternion.Euler(
1193
+ System.Convert.ToSingle(eulerData[0]),
1194
+ System.Convert.ToSingle(eulerData[1]),
1195
+ System.Convert.ToSingle(eulerData[2])) * Vector3.forward;
1196
+ Assert.That(
1197
+ Mathf.Abs(Vector3.Dot(reportedForward.normalized, expectedDirection)),
1198
+ Is.GreaterThan(0.98f),
1199
+ "scene/focus must report the pose it just applied, not the last rendered one");
1200
+
1201
+ // The reported camera must sit behind the pivot along its own forward axis.
1202
+ var positionData = (List<object>)result["cameraPosition"];
1203
+ var reportedPosition = new Vector3(
1204
+ System.Convert.ToSingle(positionData[0]),
1205
+ System.Convert.ToSingle(positionData[1]),
1206
+ System.Convert.ToSingle(positionData[2]));
1207
+ Assert.That(Vector3.Dot((sceneView.pivot - reportedPosition).normalized, reportedForward.normalized),
1208
+ Is.GreaterThan(0.98f));
1209
+
1148
1210
  Assert.That(Vector3.Distance(sceneView.pivot, cube.transform.position), Is.LessThan(2f));
1149
1211
  }
1150
1212