Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- [PBLD-240] Fixed a bug where buttons for "Create Cube" and "Create PolyShape" appeared incorrectly on Light theme.
- [PBLD-258] Fixed an bug where clicking a highlighted edge might select a hidden edge instead.
- [PBLD-262] Fixed a bug in the deep cycling of face selection where faces from hidden meshes would get prioritized
- [PBLD-276] Fixed a bug where dragging a single face from a connected texture group in the UV Editor caused visual distortion during the drag operation

## [6.0.7] - 2025-08-28

Expand Down
16 changes: 8 additions & 8 deletions Editor/EditorCore/UVEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,11 @@ static Color DRAG_BOX_COLOR
static readonly Color SELECTED_COLOR_MANUAL = new Color(1f, .68f, 0f, .39f);
static readonly Color SELECTED_COLOR_AUTO = new Color(0f, .785f, 1f, .39f);

#if UNITY_STANDALONE_OSX
public bool ControlKey { get { return Event.current.modifiers == EventModifiers.Command; } }
#else
public bool ControlKey
{
get { return Event.current.modifiers == EventModifiers.Control; }
get { return EditorGUI.actionKey; }
}
#endif
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was changed or else the test would not pass


public bool ShiftKey
{
get { return Event.current.modifiers == EventModifiers.Shift; }
Expand Down Expand Up @@ -547,13 +544,17 @@ internal void OnBeginUVModification()
GUI.FocusControl(string.Empty);
bool update = false;

Vector2 originalHandlePosition = handlePosition;

// Make sure all TextureGroups are auto-selected
for (int i = 0; i < selection.Length; i++)
{
if (selection[i].selectedFaceCount > 0)
{
int fc = selection[i].selectedFaceCount;
selection[i].SetSelectedFaces(SelectTextureGroups(selection[i], selection[i].selectedFacesInternal));
selection[i].SetSelectedFaces(
SelectTextureGroups(selection[i], selection[i].selectedFacesInternal)
);

// kinda lame... this will cause setSelectedUVsWithSceneView to be called again.
if (fc != selection[i].selectedFaceCount)
Expand All @@ -567,9 +568,8 @@ internal void OnBeginUVModification()
if (update)
{
// UpdateSelection clears handlePosition
Vector2 storedHandlePosition = handlePosition;
ProBuilderEditor.Refresh();
SetHandlePosition(storedHandlePosition, true);
SetHandlePosition(originalHandlePosition, true);
}

CopySelectionUVs(out uv_origins);
Expand Down
190 changes: 187 additions & 3 deletions Tests/Editor/Editor/UVEditorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
using UnityEngine.ProBuilder.Shapes;
using EditorUtility = UnityEditor.ProBuilder.EditorUtility;

public class UVEditorWindow
[TestFixture]
public class UVEditorTests
{
ProBuilderMesh m_cube;

Expand All @@ -18,7 +19,7 @@ public void Setup()
{
m_cube = ShapeFactory.Instantiate<Cube>();
EditorUtility.InitObject(m_cube);
// Unsure UV bounds origin is not at (0,0) lower left
// Unsure UV bounds origin is not at (0,0) lower left
foreach (var face in m_cube.facesInternal)
face.uv = new AutoUnwrapSettings(face.uv) { anchor = AutoUnwrapSettings.Anchor.UpperLeft, offset = new Vector2(-0.5f, -0.5f) };
m_cube.RefreshUV(m_cube.faces);
Expand All @@ -31,8 +32,29 @@ public void Setup()
[TearDown]
public void Cleanup()
{
// Close the UV Editor window first
if (UVEditor.instance != null)
{
UVEditor.instance.Close();
}

// Clear ProBuilder selections
MeshSelection.ClearElementSelection();
Selection.activeGameObject = null;

// Reset tool context
ToolManager.SetActiveContext<GameObjectToolContext>();
UObject.DestroyImmediate(m_cube.gameObject);

// Destroy the cube
if (m_cube != null && m_cube.gameObject != null)
{
UObject.DestroyImmediate(m_cube.gameObject);
}

m_cube = null;

// Clear undo to prevent resurrection
Undo.ClearAll();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the cube was not getting deleted at the end of the test. the changes here is to clean the test. only this line was necessary to fix the issue though

}

[Test]
Expand Down Expand Up @@ -103,4 +125,166 @@ public void Manual_PlanarProjection()
minimalUV = UVEditor.instance.UVSelectionMinimalUV();
Assert.That(minimalUV, Is.EqualTo(UVEditor.LowerLeft));
}

/// <summary>
/// Test that moving a single unconnected face doesn't cause distortion
/// </summary>
[Test]
public void MoveSingleFace_PreservesRelativePositions()
{
// Setup: One face in manual UV mode, NOT in a texture group
var face0 = m_cube.facesInternal[0];

face0.manualUV = false;
face0.textureGroup = -1; // No texture group (isolated face)

m_cube.ToMesh();
m_cube.Refresh();

// Select the face
MeshSelection.SetSelection(m_cube.gameObject);
m_cube.SetSelectedFaces(new Face[] { face0 });
MeshSelection.OnObjectSelectionChanged();

// Capture initial UV positions
var face0InitialUVs = UnityEngine.ProBuilder.ArrayUtility.ValuesWithIndexes(
m_cube.texturesInternal, face0.distinctIndexesInternal);

// Calculate initial relative offsets within the face
Vector2[] face0InitialOffsets = new Vector2[face0InitialUVs.Length];
for (int i = 0; i < face0InitialUVs.Length; i++)
face0InitialOffsets[i] = face0InitialUVs[i] - face0InitialUVs[0];

// Simulate a move operation
Vector2 moveDelta = new Vector2(0.1f, 0.2f);
UVEditor.instance.SceneMoveTool(moveDelta);
Comment on lines +158 to +160
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The new tests MoveSingleFace_PreservesRelativePositions and MoveConnectedFaces_PreservesRelativePositions are missing a call to OnBeginUVModification() before SceneMoveTool(). The bug fix in this PR is located within OnBeginUVModification, which handles auto-selecting grouped faces and preserving the handle position. Without this call, the tests do not accurately simulate the user workflow and fail to validate the actual fix. [possible issue, importance: 9]

Suggested change
// Simulate a move operation
Vector2 moveDelta = new Vector2(0.1f, 0.2f);
UVEditor.instance.SceneMoveTool(moveDelta);
// Simulate a move operation
Vector2 moveDelta = new Vector2(0.1f, 0.2f);
UVEditor.instance.OnBeginUVModification();
UVEditor.instance.SceneMoveTool(moveDelta);

🤖 Helpful? Please react with 👍/👎 | Questions❓Please reach out in Slack #ask-u-pr-agent

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SceneModeTool called OnBeginModification already


// Get final UV positions
var face0FinalUVs = UnityEngine.ProBuilder.ArrayUtility.ValuesWithIndexes(
m_cube.texturesInternal, face0.distinctIndexesInternal);

// TEST 1: Verify each vertex moved by the delta
for (int i = 0; i < face0InitialUVs.Length; i++)
{
Assert.That(face0FinalUVs[i].x, Is.EqualTo(face0InitialUVs[i].x + moveDelta.x).Within(0.0001f),
$"Vertex {i} X should move by delta");
Assert.That(face0FinalUVs[i].y, Is.EqualTo(face0InitialUVs[i].y + moveDelta.y).Within(0.0001f),
$"Vertex {i} Y should move by delta");
}

// TEST 2: Verify relative offsets within the face are preserved (no distortion)
for (int i = 0; i < face0FinalUVs.Length; i++)
{
Vector2 finalOffset = face0FinalUVs[i] - face0FinalUVs[0];
Assert.That(finalOffset.x, Is.EqualTo(face0InitialOffsets[i].x).Within(0.0001f),
$"Vertex {i} relative X offset changed - face was distorted!");
Assert.That(finalOffset.y, Is.EqualTo(face0InitialOffsets[i].y).Within(0.0001f),
$"Vertex {i} relative Y offset changed - face was distorted!");
}

// Cleanup
UVEditor.instance.OnFinishUVModification();
}

[Test]
public void MoveConnectedFaces_PreservesRelativePositions()
{
// Setup: Two faces in the same texture group (Auto UV mode)
var face0 = m_cube.facesInternal[0];
var face1 = m_cube.facesInternal[1];

face0.manualUV = false;
face1.manualUV = false;
face0.textureGroup = 1;
face1.textureGroup = 1;

m_cube.ToMesh();
m_cube.Refresh();

// Select ONLY face0
MeshSelection.SetSelection(m_cube.gameObject);
m_cube.SetSelectedFaces(new Face[] { face0 });
MeshSelection.OnObjectSelectionChanged();

// Capture initial UV positions of BOTH faces
var face0InitialUVs = UnityEngine.ProBuilder.ArrayUtility.ValuesWithIndexes(
m_cube.texturesInternal, face0.distinctIndexesInternal);
var face1InitialUVs = UnityEngine.ProBuilder.ArrayUtility.ValuesWithIndexes(
m_cube.texturesInternal, face1.distinctIndexesInternal);

// Calculate initial relative offsets within each face
Vector2[] face0InitialOffsets = new Vector2[face0InitialUVs.Length];
for (int i = 0; i < face0InitialUVs.Length; i++)
face0InitialOffsets[i] = face0InitialUVs[i] - face0InitialUVs[0];

Vector2[] face1InitialOffsets = new Vector2[face1InitialUVs.Length];
for (int i = 0; i < face1InitialUVs.Length; i++)
face1InitialOffsets[i] = face1InitialUVs[i] - face1InitialUVs[0];

// Calculate initial distance between the two faces
Vector2 face0InitialCenter = Bounds2D.Center(face0InitialUVs);
Vector2 face1InitialCenter = Bounds2D.Center(face1InitialUVs);
Vector2 initialCenterDistance = face1InitialCenter - face0InitialCenter;

// Simulate a move operation
Vector2 moveDelta = new Vector2(0.1f, 0.2f);
UVEditor.instance.SceneMoveTool(moveDelta);

// Get final UV positions
var face0FinalUVs = UnityEngine.ProBuilder.ArrayUtility.ValuesWithIndexes(
m_cube.texturesInternal, face0.distinctIndexesInternal);
var face1FinalUVs = UnityEngine.ProBuilder.ArrayUtility.ValuesWithIndexes(
m_cube.texturesInternal, face1.distinctIndexesInternal);

// TEST 1: Verify each vertex in face0 moved by the delta
for (int i = 0; i < face0InitialUVs.Length; i++)
{
Assert.That(face0FinalUVs[i].x, Is.EqualTo(face0InitialUVs[i].x + moveDelta.x).Within(0.0001f),
$"Face0 vertex {i} X should move by delta");
Assert.That(face0FinalUVs[i].y, Is.EqualTo(face0InitialUVs[i].y + moveDelta.y).Within(0.0001f),
$"Face0 vertex {i} Y should move by delta");
}

// TEST 2: Verify each vertex in face1 moved by the delta (auto-selected)
for (int i = 0; i < face1InitialUVs.Length; i++)
{
Assert.That(face1FinalUVs[i].x, Is.EqualTo(face1InitialUVs[i].x + moveDelta.x).Within(0.0001f),
$"Face1 vertex {i} X should move by delta");
Assert.That(face1FinalUVs[i].y, Is.EqualTo(face1InitialUVs[i].y + moveDelta.y).Within(0.0001f),
$"Face1 vertex {i} Y should move by delta");
}

// TEST 3: Verify relative offsets within face0 are preserved (no distortion)
for (int i = 0; i < face0FinalUVs.Length; i++)
{
Vector2 finalOffset = face0FinalUVs[i] - face0FinalUVs[0];
Assert.That(finalOffset.x, Is.EqualTo(face0InitialOffsets[i].x).Within(0.0001f),
$"Face0 vertex {i} relative X offset changed - face was distorted!");
Assert.That(finalOffset.y, Is.EqualTo(face0InitialOffsets[i].y).Within(0.0001f),
$"Face0 vertex {i} relative Y offset changed - face was distorted!");
}

// TEST 4: Verify relative offsets within face1 are preserved (no distortion)
for (int i = 0; i < face1FinalUVs.Length; i++)
{
Vector2 finalOffset = face1FinalUVs[i] - face1FinalUVs[0];
Assert.That(finalOffset.x, Is.EqualTo(face1InitialOffsets[i].x).Within(0.0001f),
$"Face1 vertex {i} relative X offset changed - face was distorted!");
Assert.That(finalOffset.y, Is.EqualTo(face1InitialOffsets[i].y).Within(0.0001f),
$"Face1 vertex {i} relative Y offset changed - face was distorted!");
}

// TEST 5: Verify the distance between face centers is preserved
Vector2 face0FinalCenter = Bounds2D.Center(face0FinalUVs);
Vector2 face1FinalCenter = Bounds2D.Center(face1FinalUVs);
Vector2 finalCenterDistance = face1FinalCenter - face0FinalCenter;

Assert.That(finalCenterDistance.x, Is.EqualTo(initialCenterDistance.x).Within(0.0001f),
"Distance between face centers X changed - faces were recentered!");
Assert.That(finalCenterDistance.y, Is.EqualTo(initialCenterDistance.y).Within(0.0001f),
"Distance between face centers Y changed - faces were recentered!");

// Cleanup
UVEditor.instance.OnFinishUVModification();
}
}