Skip to content

Commit fb83b29

Browse files
committed
fix unwrap failures on collapsed preseed uvs
1 parent aaf7532 commit fb83b29

9 files changed

Lines changed: 73 additions & 17 deletions

File tree

dev/tests/test_seams.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
uv_fit,
5858
uv_island_groups,
5959
uv_topology,
60+
uvs_collapsed,
6061
vertex_components,
6162
)
6263
from seams.islands import absorb_fragments, split_pieces # noqa: E402
@@ -1820,3 +1821,13 @@ def test_flatten_distortion_reads_a_mirrored_island_like_its_source():
18201821
mirrored = [[(0.0, 0.0), (-1.0, 0.0), (-1.0, 1.0), (0.0, 1.0)]]
18211822
value = flatten_distortion(FLAT_QUAD_VERTS, [(0, 1, 2, 3)], mirrored, [0])
18221823
assert abs(value - 4.0) < 1e-9
1824+
1825+
1826+
def test_collapsed_uvs_are_detected():
1827+
point = (0.1, 0.9987)
1828+
assert uvs_collapsed([[point, point, point]] * 100)
1829+
1830+
1831+
def test_real_uvs_are_not_collapsed():
1832+
quad = [(0.0, 0.0), (0.01, 0.0), (0.01, 0.01), (0.0, 0.01)]
1833+
assert not uvs_collapsed([quad])

engine/optcuts/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.16.0
1+
1.16.1

engine/optcuts/src/Scaffold.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ Scaffold::Scaffold(const TriMesh &mesh, Eigen::MatrixXd UV_bnds,
179179
// "q" for high quality mesh generation
180180
// "Q" for quiet mode (no output)
181181

182+
// degenerate boundaries can leave no triangles, crashing computeFeatures
183+
if (airMesh.F.rows() == 0)
184+
throw std::runtime_error("air mesh triangulation came back empty");
185+
182186
airMesh.V_rest.resize(airMesh.V.rows(), 3);
183187
airMesh.V_rest << airMesh.V, Eigen::VectorXd::Zero(airMesh.V.rows());
184188
airMesh.areaThres_AM =

engine/optcuts/src/uvgami.cpp

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1209,10 +1209,23 @@ int main(int argc, char *argv[]) {
12091209
for (int c = 0; c < n_components; ++c) {
12101210
inverted[c] = !temp.checkInversion(true, chartTris[c]);
12111211
}
1212-
bool anyInversion = false, allDisks = true;
1212+
1213+
// zero area passes the inversion test and a point boundary evades
1214+
// the crossing test, but a collapsed chart cannot seed the solve
1215+
std::vector<bool> degenerate(n_components, false);
1216+
for (int triI = 0; triI < temp.F.rows(); ++triI) {
1217+
const Eigen::RowVector3i &tri = temp.F.row(triI);
1218+
const Eigen::RowVector2d e1 = temp.V.row(tri[1]) - temp.V.row(tri[0]);
1219+
const Eigen::RowVector2d e2 = temp.V.row(tri[2]) - temp.V.row(tri[0]);
1220+
if (e1[0] * e2[1] - e1[1] * e2[0] <= 0.0)
1221+
degenerate[C[triI]] = true;
1222+
}
1223+
1224+
bool anyInversion = false, allDisks = true, anyDegenerate = false;
12131225
for (int c = 0; c < n_components; ++c) {
12141226
anyInversion = anyInversion || inverted[c];
12151227
allDisks = allDisks && isDisk[c];
1228+
anyDegenerate = anyDegenerate || degenerate[c];
12161229
}
12171230
// a stitch run can keep charts with holes: an interior split the
12181231
// engine never merged back leaves a slit, and the machinery is
@@ -1249,19 +1262,22 @@ int main(int argc, char *argv[]) {
12491262
// whole-map decision first, so a map that was kept before is still
12501263
// kept byte for byte
12511264
keepInputUV = (allDisks || stitchKeepable) && !anyInversion &&
1252-
crossingVerts.empty();
1265+
!anyDegenerate && crossingVerts.empty();
12531266

1254-
int badInverted = 0, badOverlapping = 0, badNonDisk = 0;
1267+
int badInverted = 0, badOverlapping = 0, badNonDisk = 0,
1268+
badDegenerate = 0;
12551269
if (keepInputUV) {
12561270
keepChart.assign(n_components, true);
12571271
keptCharts = n_components;
12581272
} else {
12591273
for (int c = 0; c < n_components; ++c) {
1260-
keepChart[c] =
1261-
isDisk[c] && !overlaps[c] && !inverted[c] && !pinched[c];
1274+
keepChart[c] = isDisk[c] && !overlaps[c] && !inverted[c] &&
1275+
!pinched[c] && !degenerate[c];
12621276
keptCharts += keepChart[c];
12631277
if (inverted[c]) {
12641278
++badInverted;
1279+
} else if (degenerate[c]) {
1280+
++badDegenerate;
12651281
} else if (overlaps[c]) {
12661282
++badOverlapping;
12671283
} else if (!isDisk[c]) {
@@ -1280,8 +1296,8 @@ int main(int argc, char *argv[]) {
12801296
} else if (!keepInputUV) {
12811297
std::cout << "kept " << keptCharts << " of " << n_components
12821298
<< " input UV charts, re-cutting " << badInverted
1283-
<< " inverted, " << badOverlapping
1284-
<< " self-intersecting, " << badNonDisk
1299+
<< " inverted, " << badDegenerate << " degenerate, "
1300+
<< badOverlapping << " self-intersecting, " << badNonDisk
12851301
<< " not disk-topology" << std::endl;
12861302
}
12871303
}

src/engines/optcuts/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
seam_flags,
1010
seam_restrictions,
1111
)
12-
from ...seams import FlattenError
12+
from ...seams import FlattenError, uvs_collapsed
1313
from ...utils.io import print_stdin
14-
from ...utils.mesh import deselect_all, validate_obj
14+
from ...utils.mesh import corner_uvs, deselect_all, validate_obj
1515
from ...utils.ui import only_active
1616
from ..binary_engine import BinaryEngine
1717
from .install import OPTCUTS, UVGAMI_OT_install_optcuts
@@ -251,9 +251,12 @@ def piece_uses_uvs(self, obj, props, has_uvs):
251251
# auto mode routes per loose part: a piece the preseed skipped has no
252252
# seams and goes to the engine bare, to be cut from scratch. With
253253
# import uvs on, organic pieces keep the user's map instead
254-
if not props.optcuts.is_auto or props.import_uvs:
254+
if props.optcuts.is_auto and not props.import_uvs:
255+
has_uvs = has_uvs and bool(seam_flags(obj.data).any())
256+
if not has_uvs or obj.data.uv_layers.active is None:
255257
return has_uvs
256-
return has_uvs and bool(seam_flags(obj.data).any())
258+
# a collapsed flatten goes bare too, the engine can fail re-cutting it
259+
return not uvs_collapsed(corner_uvs(obj.data))
257260

258261
def build_args(self, ctx, input_path, props):
259262
bounds = {"LESS_STRETCH": "4.05", "BALANCED": "4.2", "FEWER_SEAMS": "5.0"}

src/engines/optcuts/install.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from ..binary_engine import EngineRelease, InstallEngineTask
44

55
# must match engine/optcuts/VERSION (check-engine-versions.yml fails on drift)
6-
OPTCUTS_VERSION = "1.16.0"
6+
OPTCUTS_VERSION = "1.16.1"
77
OPTCUTS = EngineRelease("optcuts", "Optcuts", OPTCUTS_VERSION, "2 MB")
88

99

src/manager.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def _reset_session(self):
7272
self.pending_transfers = []
7373
self.transfer_uv_failed = False
7474
self.transfer_uv_fail_detail = ""
75+
self.transfer_uv_missing_pieces = False
7576
self.transfer_uv_split_count = 0
7677
self.error_code = 0
7778
self.error_stderr = ""
@@ -486,6 +487,16 @@ def _import_and_finalize(self, unwrap, path, edge_path, added_edges):
486487
if obj == output:
487488
pack_index = i
488489
break
490+
# a group missing pieces can never cover every input face
491+
group = unwrap.join_job
492+
if group is not None and len(group.finished) < group.expected:
493+
failed = group.expected - len(group.finished)
494+
self.transfer_uv_missing_pieces = True
495+
report = TransferReport(
496+
False, 0, f"{failed} of {group.expected} parts failed to unwrap"
497+
)
498+
self._settle_transfer(job, output, pack_index, report)
499+
return
489500
if isinstance(job, ProxyUVs):
490501
report = job.start(self.input[job], output)
491502
if report is None:
@@ -722,10 +733,10 @@ def _finish_batch(self):
722733

723734
if self.transfer_uv_failed:
724735
detail = self.transfer_uv_fail_detail or "unknown reason"
725-
msg.append(
726-
f"UV transfer failed: {detail}."
727-
" This can happen with cuts or symmetry enabled."
728-
)
736+
line = f"UV transfer failed: {detail}."
737+
if not self.transfer_uv_missing_pieces:
738+
line += " This can happen with cuts or symmetry enabled."
739+
msg.append(line)
729740

730741
if self.transfer_uv_split_count > 0:
731742
count = self.transfer_uv_split_count

src/seams/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
uv_area_fit,
4040
uv_fit,
4141
uv_island_groups,
42+
uvs_collapsed,
4243
vertex_components,
4344
)
4445
from .pipeline import is_hard_surface, seam_edges
@@ -126,5 +127,6 @@
126127
"uv_fit",
127128
"uv_island_groups",
128129
"uv_topology",
130+
"uvs_collapsed",
129131
"vertex_components",
130132
]

src/seams/mesh.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,15 @@ def signed_area(pts):
102102
return total / 2
103103

104104

105+
# between a collapsed map's float noise and any real packed map's area
106+
COLLAPSED_UV_AREA = 1e-8
107+
108+
109+
def uvs_collapsed(polygons):
110+
"""Whether a uv map is crushed to points, a failed flatten's signature."""
111+
return sum(abs(signed_area(pts)) for pts in polygons) < COLLAPSED_UV_AREA
112+
113+
105114
def island_groups(faces, seams, edges):
106115
"""Faces grouped into uv islands: joined by interior edges not on a seam."""
107116
parent = list(range(len(faces)))

0 commit comments

Comments
 (0)