(u, v)
| 390 | // unrelated texels. thinness catches it (≈ 1800 in that case) while still |
| 391 | // reporting ≈ 1.15 for an equilateral triangle, so the threshold scale is |
| 392 | // similar to the old edge-ratio metric. |
| 393 | function triAspectSq(t) { |
| 394 | const a = corners[t*3], b = corners[t*3+1], c = corners[t*3+2]; |
| 395 | const ax = vertX[a], ay = vertY[a], az = vertZ[a]; |
| 396 | const abx = vertX[b]-ax, aby = vertY[b]-ay, abz = vertZ[b]-az; |
| 397 | const acx = vertX[c]-ax, acy = vertY[c]-ay, acz = vertZ[c]-az; |
| 398 | const bcx = vertX[c]-vertX[b], bcy = vertY[c]-vertY[b], bcz = vertZ[c]-vertZ[b]; |
| 399 | const lAB2 = abx*abx + aby*aby + abz*abz; |
| 400 | const lAC2 = acx*acx + acy*acy + acz*acz; |
| 401 | const lBC2 = bcx*bcx + bcy*bcy + bcz*bcz; |
| 402 | const lmax2 = Math.max(lAB2, lAC2, lBC2); |
| 403 | const nx = aby*acz - abz*acy; |
| 404 | const ny = abz*acx - abx*acz; |
| 405 | const nz = abx*acy - aby*acx; |
| 406 | const cross2 = nx*nx + ny*ny + nz*nz; |
| 407 | return cross2 > 0 ? lmax2 * lmax2 / cross2 : Infinity; |
| 408 | } |
| 409 | |
| 410 | function countDeleted() { |
| 411 | let n = 0; |
| 412 | for (let i = 0; i < triCount; i++) if (triDeleted[i]) n++; |
| 413 | return n; |
| 414 | } |
| 415 | |
| 416 | // Attempt collapse of edge (u, v). Returns true if applied. |
| 417 | function tryCollapse(u, v) { |
| 418 | if (u === v) return false; |
| 419 | |
| 420 | // Sharp-edge vertices stay put — refuse the collapse outright. |
| 421 | if (frozenVert[u] || frozenVert[v]) { rejectStats.frozen++; return false; } |
| 422 | |
| 423 | // Wing triangles — must be exactly 2 (manifold interior edge). |
| 424 | const wings = trianglesSharingEdge(u, v); |
| 425 | if (wings.length !== 2) { rejectStats.wingCount++; return false; } |
| 426 | |
| 427 | const apexW1 = thirdVertex(wings[0], u, v); |
| 428 | const apexW2 = thirdVertex(wings[1], u, v); |
| 429 | if (apexW1 === apexW2) { rejectStats.foldedApex++; return false; } |
| 430 | |
| 431 | // Two-tier gate selection. Edge-cap loosens if EITHER wing is extreme |
| 432 | // (cheap to relax — easy to recover from with re-subdivide). Normal-cap |
| 433 | // loosens only if BOTH wings are extreme (asymmetric: protects fillets). |
| 434 | const w1Asp2 = triAspectSq(wings[0]); |
| 435 | const w2Asp2 = triAspectSq(wings[1]); |
| 436 | const eitherExtreme = w1Asp2 > extremeAspect2 || w2Asp2 > extremeAspect2; |
| 437 | const bothExtreme = w1Asp2 > extremeAspect2 && w2Asp2 > extremeAspect2; |
| 438 | const effMaxLenSq = eitherExtreme ? aggressiveMaxLenSqAllowed : baseMaxLenSqAllowed; |
| 439 | const effNormalCos = bothExtreme ? aggressiveNormalDeltaCos : maxNormalDeltaCos; |
| 440 | |
| 441 | |
| 442 | // Link condition — vertices that share a triangle with BOTH u and v |
| 443 | // (other than the wing apexes) would become non-manifold after the merge. |
| 444 | // Stamp v's neighbours, then scan u's neighbours against the stamps. |
| 445 | stampGen++; |
| 446 | for (let s = vfHead[v]; s !== -1; s = slotNext[s]) { |
| 447 | const t = (s / 3) | 0; |
| 448 | if (triDeleted[t]) continue; |
| 449 | const a = corners[t*3], b = corners[t*3+1], c = corners[t*3+2]; |
no test coverage detected