Skip to content

Commit 338133c

Browse files
committed
rubber: harden invalid guided state
1 parent 94dc79a commit 338133c

6 files changed

Lines changed: 291 additions & 37 deletions

File tree

VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -170,13 +170,15 @@ private static void BindSelectedGuidesToRubber(string undoName)
170170
if (!rubber || !RubberGuideSlotPickerWindow.TryPick(SelectedGuides(), out var bindings)) {
171171
return;
172172
}
173+
Undo.IncrementCurrentGroup();
173174
Undo.RegisterFullObjectHierarchyUndo(rubber.gameObject, undoName);
174175
var convertingSpline = rubber.PathSource == RubberPathSource.Spline;
175176
string error;
176177
var succeeded = convertingSpline
177178
? RubberAutofit.TryConvertToGuides(rubber, bindings, out _, out error)
178-
: TryReplaceGuideBindings(rubber, bindings, out error);
179+
: RubberAutofit.TryReplaceGuideBindings(rubber, bindings, out _, out error);
179180
if (!succeeded) {
181+
Undo.RevertAllInCurrentGroup();
180182
var retainedState = convertingSpline
181183
? "The manual spline remains authoritative."
182184
: "The bindings were preserved and the previous sampled path was retained.";
@@ -189,13 +191,6 @@ private static void BindSelectedGuidesToRubber(string undoName)
189191
RubberGuideDependencyTracker.RebuildSoon();
190192
}
191193

192-
private static bool TryReplaceGuideBindings(RubberComponent rubber,
193-
RubberGuideBinding[] bindings, out string error)
194-
{
195-
rubber.SetGuideBindings(bindings);
196-
return RubberAutofit.TryBake(rubber, out _, out error);
197-
}
198-
199194
private static RubberComponent SelectedRubber()
200195
{
201196
if (Selection.activeGameObject) {

VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs

Lines changed: 122 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public class RubberInspector : MainInspector<RubberData, RubberComponent>
3030
{
3131
private SerializedProperty _thicknessProperty;
3232
private SerializedProperty _restLengthProperty;
33+
private bool _bindingChangeScheduled;
3334

3435
protected override void OnEnable()
3536
{
@@ -86,13 +87,14 @@ private void DrawPathSection()
8687
EditorGUILayout.LabelField("Path", EditorStyles.boldLabel);
8788
var source = (RubberPathSource)EditorGUILayout.EnumPopup("Source", MainComponent.PathSource);
8889
if (source != MainComponent.PathSource) {
89-
Undo.RegisterFullObjectHierarchyUndo(MainComponent.gameObject, "Change Rubber Path Source");
9090
if (source == RubberPathSource.Guides) {
91-
MainComponent.SetGuideBindings(MainComponent.GuideBindings);
91+
EditorUtility.DisplayDialog("Guide Bindings Required",
92+
"Select the rubber and its guides, then use GameObject > Pinball > Rubber > Bind Selected Guides.",
93+
"OK");
9294
} else {
93-
MainComponent.DetachFromGuides();
95+
ScheduleBindingsChange(MainComponent.GuideBindings.ToArray(),
96+
Array.Empty<RubberGuideBinding>(), "Detach Rubber From Guides");
9497
}
95-
EditorUtility.SetDirty(MainComponent);
9698
}
9799

98100
if (MainComponent.PathSource != RubberPathSource.Guides) {
@@ -101,15 +103,22 @@ private void DrawPathSection()
101103
return;
102104
}
103105

104-
var bindings = MainComponent.GuideBindings.ToArray();
106+
var expectedBindings = MainComponent.GuideBindings.ToArray();
107+
var bindings = expectedBindings.ToArray();
105108
for (var i = 0; i < bindings.Length; i++) {
106109
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
107110
var guide = (RubberGuideComponent)EditorGUILayout.ObjectField($"Guide {i + 1}",
108111
bindings[i].Guide, typeof(RubberGuideComponent), true);
109112
if (guide != bindings[i].Guide) {
110-
bindings[i].Guide = guide;
111-
bindings[i].SlotId = default;
112-
ApplyBindings(bindings, "Change Rubber Guide Binding");
113+
if (!guide) {
114+
ScheduleBindingsChange(expectedBindings,
115+
bindings.Where((_, index) => index != i).ToArray(),
116+
bindings.Length == 1 ? "Detach Rubber From Guides"
117+
: "Remove Rubber Guide Binding");
118+
} else {
119+
ScheduleGuideReplacement(i, expectedBindings, guide);
120+
}
121+
guide = bindings[i].Guide;
113122
}
114123
if (guide && guide.Slots.Length > 0) {
115124
var selectedSlot = Array.FindIndex(guide.Slots, slot => slot.Id == bindings[i].SlotId);
@@ -122,24 +131,24 @@ private void DrawPathSection()
122131
var nextSlot = EditorGUILayout.Popup("Slot", 0, labels);
123132
if (nextSlot > 0) {
124133
bindings[i].SlotId = guide.Slots[nextSlot - 1].Id;
125-
ApplyBindings(bindings, "Repair Rubber Guide Slot");
134+
ScheduleBindingsChange(expectedBindings, bindings, "Repair Rubber Guide Slot");
126135
}
127136
} else {
128137
var nextSlot = EditorGUILayout.Popup("Slot", selectedSlot, slotLabels);
129138
if (nextSlot != selectedSlot && nextSlot >= 0
130139
&& nextSlot < guide.Slots.Length) {
131140
bindings[i].SlotId = guide.Slots[nextSlot].Id;
132-
ApplyBindings(bindings, "Change Rubber Guide Slot");
141+
ScheduleBindingsChange(expectedBindings, bindings, "Change Rubber Guide Slot");
133142
}
134143
}
135144
} else if (guide) {
136145
EditorGUILayout.HelpBox("This guide has no slots.", MessageType.Error);
137146
}
138-
if (GUILayout.Button("Remove Binding")) {
139-
ApplyBindings(bindings.Where((_, index) => index != i).ToArray(),
140-
"Remove Rubber Guide Binding");
141-
EditorGUILayout.EndVertical();
142-
break;
147+
if (GUILayout.Button(bindings.Length == 1 ? "Detach From Guides" : "Remove Binding")) {
148+
ScheduleBindingsChange(expectedBindings,
149+
bindings.Where((_, index) => index != i).ToArray(),
150+
bindings.Length == 1 ? "Detach Rubber From Guides"
151+
: "Remove Rubber Guide Binding");
143152
}
144153
EditorGUILayout.EndVertical();
145154
}
@@ -159,19 +168,109 @@ private void DrawPathSection()
159168
}
160169
}
161170
if (GUILayout.Button("Detach From Guides")) {
162-
Undo.RegisterFullObjectHierarchyUndo(MainComponent.gameObject, "Detach Rubber From Guides");
163-
MainComponent.DetachFromGuides();
164-
MainComponent.RebuildMeshes();
165-
EditorUtility.SetDirty(MainComponent);
171+
ScheduleBindingsChange(expectedBindings, Array.Empty<RubberGuideBinding>(),
172+
"Detach Rubber From Guides");
166173
}
167174
EditorGUILayout.EndHorizontal();
168175
}
169176

170-
private void ApplyBindings(RubberGuideBinding[] bindings, string undoName)
177+
private void ScheduleBindingsChange(RubberGuideBinding[] expectedBindings,
178+
RubberGuideBinding[] bindings, string undoName)
179+
{
180+
if (_bindingChangeScheduled) {
181+
return;
182+
}
183+
expectedBindings = expectedBindings.ToArray();
184+
bindings = bindings.ToArray();
185+
_bindingChangeScheduled = true;
186+
var rubber = MainComponent;
187+
EditorApplication.delayCall += () => {
188+
if (!this) {
189+
return;
190+
}
191+
_bindingChangeScheduled = false;
192+
if (rubber && BindingsMatch(rubber, expectedBindings)) {
193+
ApplyBindings(rubber, bindings, undoName);
194+
}
195+
Repaint();
196+
};
197+
}
198+
199+
private void ScheduleGuideReplacement(int bindingIndex, RubberGuideBinding[] expectedBindings,
200+
RubberGuideComponent guide)
201+
{
202+
if (_bindingChangeScheduled) {
203+
return;
204+
}
205+
expectedBindings = expectedBindings.ToArray();
206+
_bindingChangeScheduled = true;
207+
var rubber = MainComponent;
208+
EditorApplication.delayCall += () => {
209+
if (!this) {
210+
return;
211+
}
212+
_bindingChangeScheduled = false;
213+
if (!rubber || !guide || bindingIndex < 0
214+
|| bindingIndex >= rubber.GuideBindings.Count) {
215+
Repaint();
216+
return;
217+
}
218+
if (!BindingsMatch(rubber, expectedBindings)) {
219+
Repaint();
220+
return;
221+
}
222+
if (RubberGuideSlotPickerWindow.TryPick(new[] { guide }, out var selectedBindings)
223+
&& BindingsMatch(rubber, expectedBindings)) {
224+
var bindings = rubber.GuideBindings.ToArray();
225+
bindings[bindingIndex] = selectedBindings[0];
226+
ApplyBindings(rubber, bindings, "Change Rubber Guide Binding");
227+
}
228+
Repaint();
229+
};
230+
}
231+
232+
private static bool BindingsMatch(RubberComponent rubber, RubberGuideBinding[] expectedBindings)
233+
{
234+
if (rubber.PathSource != RubberPathSource.Guides
235+
|| rubber.GuideBindings.Count != expectedBindings.Length) {
236+
return false;
237+
}
238+
for (var i = 0; i < expectedBindings.Length; i++) {
239+
var current = rubber.GuideBindings[i];
240+
var expected = expectedBindings[i];
241+
if (current.Guide != expected.Guide || current.SlotId != expected.SlotId) {
242+
return false;
243+
}
244+
}
245+
return true;
246+
}
247+
248+
private static void ApplyBindings(RubberComponent rubber, RubberGuideBinding[] bindings, string undoName)
171249
{
172-
Undo.RegisterFullObjectHierarchyUndo(MainComponent.gameObject, undoName);
173-
MainComponent.SetGuideBindings(bindings);
174-
EditorUtility.SetDirty(MainComponent);
250+
Undo.IncrementCurrentGroup();
251+
Undo.RegisterFullObjectHierarchyUndo(rubber.gameObject, undoName);
252+
var detaching = bindings.Length == 0;
253+
if (detaching) {
254+
rubber.DetachFromGuides();
255+
rubber.RebuildMeshes();
256+
} else if (!RubberAutofit.TryReplaceGuideBindings(rubber, bindings,
257+
out _, out var error)) {
258+
Undo.RevertAllInCurrentGroup();
259+
EditorUtility.DisplayDialog("Rubber Binding Change Failed",
260+
$"The previous bindings and sampled path were preserved.\n\n{error}", "OK");
261+
return;
262+
}
263+
EditorUtility.SetDirty(rubber);
264+
PrefabUtility.RecordPrefabInstancePropertyModifications(rubber);
265+
if (detaching) {
266+
var collider = rubber.GetComponent<RubberColliderComponent>();
267+
if (collider) {
268+
EditorUtility.SetDirty(collider);
269+
PrefabUtility.RecordPrefabInstancePropertyModifications(collider);
270+
}
271+
}
272+
RubberGuideDependencyTracker.RebuildSoon();
273+
SceneView.RepaintAll();
175274
}
176275

177276
private void OnSceneGUI()

VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
using Unity.Collections;
1212
using Unity.Mathematics;
1313
using UnityEngine;
14+
using UnityEngine.TestTools;
15+
using VisualPinball.Engine.Math;
1416

1517
namespace VisualPinball.Unity.Test
1618
{
@@ -256,6 +258,38 @@ public void ThinCordShouldUseRadiusRelativeChordTolerance()
256258
.Within(1e-6f));
257259
}
258260

261+
[Test]
262+
public void InvalidPhysicalRubberShouldGenerateLegacyFallbackColliders()
263+
{
264+
var go = new GameObject("Rubber");
265+
go.SetActive(false);
266+
var transforms = new NativeParallelHashMap<int, float4x4>(1, Allocator.Temp);
267+
var references = new ColliderReference(ref transforms, Allocator.Temp);
268+
try {
269+
var rubber = go.AddComponent<RubberComponent>();
270+
rubber.DragPoints = new[] {
271+
new DragPointData(-10f, -10f),
272+
new DragPointData(-10f, 10f),
273+
new DragPointData(10f, 10f),
274+
new DragPointData(10f, -10f),
275+
};
276+
var collider = go.AddComponent<RubberColliderComponent>();
277+
collider.Mode = RubberColliderMode.Physical;
278+
var api = new TestRubberApi(go);
279+
LogAssert.Expect(LogType.Warning,
280+
"Physical rubber 'Rubber' has no current valid guided bake; falling back to Legacy collision.");
281+
282+
api.GenerateColliders(ref references);
283+
284+
Assert.That(references.Count, Is.GreaterThan(0));
285+
Assert.That(references.SweptCircleColliders.Length, Is.Zero);
286+
} finally {
287+
references.Dispose();
288+
transforms.Dispose();
289+
UnityEngine.Object.DestroyImmediate(go);
290+
}
291+
}
292+
259293
[Test]
260294
public void PhysicalRubberGeneratorShouldCreateContinuousRoundCordSegments()
261295
{
@@ -344,6 +378,16 @@ public void PhysicalRubberGeneratorShouldApplyOffsetAlongBakeNormal()
344378
}
345379
}
346380

381+
private sealed class TestRubberApi : RubberApi
382+
{
383+
internal TestRubberApi(GameObject go) : base(go, null, null)
384+
{
385+
}
386+
387+
internal void GenerateColliders(ref ColliderReference references)
388+
=> CreateColliders(ref references, float4x4.identity, 0f);
389+
}
390+
347391
private static SweptCircleCollider CreateCollider()
348392
{
349393
return new SweptCircleCollider(new float3(-10f, 0f, 0f),

VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,99 @@ public void FailedRebindShouldRetainLastBakedGeometry()
215215
}
216216
}
217217

218+
[Test]
219+
public void FailedGuidedReplacementShouldRestoreCurrentBake()
220+
{
221+
var rubberObject = new GameObject("Rubber");
222+
var guideObject = new GameObject("Guide");
223+
try {
224+
var rubber = rubberObject.AddComponent<RubberComponent>();
225+
var guide = AddGuide(guideObject, 0.01f);
226+
var binding = new RubberGuideBinding(guide, guide.Slots[0].Id);
227+
rubber.SetGuideBindings(new[] { binding });
228+
Assert.That(RubberAutofit.TryBake(rubber, out _, out var bakeError),
229+
Is.True, bakeError);
230+
var previousPath = rubber.BakedPath.ToArray();
231+
var previousVersion = rubber.BakeVersion;
232+
var previousHash = rubber.BakeInputHash;
233+
var previousFrame = rubber.BakeFrameToLocal;
234+
235+
var replaced = RubberAutofit.TryReplaceGuideBindings(rubber, new[] {
236+
new RubberGuideBinding(guide, SerializedGuid.New()),
237+
}, out _, out var error);
238+
239+
Assert.That(replaced, Is.False);
240+
Assert.That(error, Does.Contain("missing slot"));
241+
Assert.That(rubber.PathSource, Is.EqualTo(RubberPathSource.Guides));
242+
Assert.That(rubber.GuideBindings.Count, Is.EqualTo(1));
243+
Assert.That(rubber.GuideBindings[0].Guide, Is.SameAs(binding.Guide));
244+
Assert.That(rubber.GuideBindings[0].SlotId, Is.EqualTo(binding.SlotId));
245+
Assert.That(rubber.BakedPath, Is.EqualTo(previousPath));
246+
Assert.That(rubber.BakeVersion, Is.EqualTo(previousVersion));
247+
Assert.That(rubber.BakeInputHash, Is.EqualTo(previousHash));
248+
Assert.That(rubber.BakeFrameToLocal, Is.EqualTo(previousFrame));
249+
Assert.That(RubberAutofit.GetStatus(rubber).IsValid, Is.True);
250+
} finally {
251+
UnityEngine.Object.DestroyImmediate(rubberObject);
252+
UnityEngine.Object.DestroyImmediate(guideObject);
253+
}
254+
}
255+
256+
[Test]
257+
public void GuidedReplacementShouldRejectSplineRubberWithoutMutation()
258+
{
259+
var rubberObject = new GameObject("Rubber");
260+
var guideObject = new GameObject("Guide");
261+
try {
262+
var rubber = rubberObject.AddComponent<RubberComponent>();
263+
var guide = AddGuide(guideObject, 0.01f);
264+
265+
var replaced = RubberAutofit.TryReplaceGuideBindings(rubber, new[] {
266+
new RubberGuideBinding(guide, guide.Slots[0].Id),
267+
}, out _, out var error);
268+
269+
Assert.That(replaced, Is.False);
270+
Assert.That(error, Does.Contain("guided rubber"));
271+
Assert.That(rubber.PathSource, Is.EqualTo(RubberPathSource.Spline));
272+
Assert.That(rubber.GuideBindings, Is.Empty);
273+
Assert.That(rubber.BakedPath, Is.Empty);
274+
} finally {
275+
UnityEngine.Object.DestroyImmediate(rubberObject);
276+
UnityEngine.Object.DestroyImmediate(guideObject);
277+
}
278+
}
279+
280+
[Test]
281+
public void DetachingGuidedRubberShouldKeepSampledSpline()
282+
{
283+
var rubberObject = new GameObject("Rubber");
284+
var guideObject = new GameObject("Guide");
285+
try {
286+
var rubber = rubberObject.AddComponent<RubberComponent>();
287+
var collider = rubberObject.AddComponent<RubberColliderComponent>();
288+
collider.Mode = RubberColliderMode.Physical;
289+
var guide = AddGuide(guideObject, 0.01f);
290+
rubber.SetGuideBindings(new[] {
291+
new RubberGuideBinding(guide, guide.Slots[0].Id),
292+
});
293+
Assert.That(RubberAutofit.TryBake(rubber, out _, out var bakeError),
294+
Is.True, bakeError);
295+
var sampledPoints = rubber.DragPoints.Select(point => point.Center).ToArray();
296+
297+
rubber.DetachFromGuides();
298+
rubber.RebuildMeshes();
299+
300+
Assert.That(rubber.PathSource, Is.EqualTo(RubberPathSource.Spline));
301+
Assert.That(rubber.GuideBindings, Is.Empty);
302+
Assert.That(rubber.BakedPath, Is.Empty);
303+
Assert.That(rubber.DragPoints.Select(point => point.Center), Is.EqualTo(sampledPoints));
304+
Assert.That(collider.Mode, Is.EqualTo(RubberColliderMode.Legacy));
305+
} finally {
306+
UnityEngine.Object.DestroyImmediate(rubberObject);
307+
UnityEngine.Object.DestroyImmediate(guideObject);
308+
}
309+
}
310+
218311
[Test]
219312
public void FailedSplineConversionShouldRestoreManualAuthority()
220313
{

0 commit comments

Comments
 (0)