({ geometry, bounds, settings, texture })
| 287 | |
| 288 | // Pre-compute per-triangle edges once — reused across all simulator calls. |
| 289 | const triEdges = computeTriEdges(geometry); |
| 290 | |
| 291 | // Solve for the largest (coarsest) edge that keeps simulated tri count ≤ |
| 292 | // budget. Start from the closed-form equilateral-cover estimate, then |
| 293 | // do up to 3 ratio corrections (sim count scales ~1/edge² so each step |
| 294 | // multiplies edge by sqrt(predicted/budget) until it converges). |
| 295 | let budgetEdge = Math.sqrt((TRIS_PER_AREA_GEOM * surfaceArea) / Math.max(triBudget, 1)); |
| 296 | for (let step = 0; step < 3; step++) { |
| 297 | const simCount = simulateFromEdges(triEdges, budgetEdge); |
| 298 | if (simCount <= triBudget) break; |
| 299 | const correction = Math.sqrt(simCount / triBudget); |
| 300 | if (correction < 1.005) break; // converged |
| 301 | budgetEdge = budgetEdge * correction; |
| 302 | } |
| 303 | // Guarantee the budget actually holds. On near-uniform meshes (cube-like |
| 304 | // CAD tessellations) the simulated count is a step function of the edge — |
| 305 | // 12 × 4^k for the default cube — so the sqrt-ratio corrections above can |
| 306 | // stall between split thresholds and finish a few percent over budget. |
| 307 | // Walk coarser in 5% steps until the simulation fits; sim count is |
| 308 | // monotonically non-increasing in edge length, so this always terminates. |
| 309 | for (let step = 0; step < 24; step++) { |
| 310 | if (simulateFromEdges(triEdges, budgetEdge) <= triBudget) break; |
| 311 | budgetEdge *= 1.05; |
| 312 | } |
| 313 | |
| 314 | // 5. Final edge: take the larger (coarser) of detail vs budget so neither |
| 315 | // constraint is violated. |
| 316 | let edge = Math.max(detailEdge, budgetEdge); |
| 317 | const budgetClamped = budgetEdge > detailEdge; |
| 318 | |
| 319 | // Sanity clamp: never below 0.05 mm, never coarser than diag/50 (or the |
| 320 | // 5 mm slider absolute) — matches the legacy default's spirit. |
| 321 | const diag = Math.sqrt(bounds.size.x ** 2 + bounds.size.y ** 2 + bounds.size.z ** 2); |
| 322 | const lo = 0.05; |
| 323 | const hi = Math.min(5.0, diag / 50); |
| 324 | const preClamp = edge; |
| 325 | edge = Math.min(Math.max(edge, lo), Math.max(hi, lo)); |
| 326 | const edgeClamped = edge !== preClamp; |
| 327 | |
| 328 | // Round UP to 2 decimals so the slider value never violates the budget |
| 329 | // floor (rounding down by even 0.005 mm can push estimated triangles past |
| 330 | // the budget cap). |
| 331 | edge = Math.max(lo, Math.ceil(edge * 100) / 100); |
| 332 | |
| 333 | // Estimated triangle count at the chosen edge length, via per-triangle |
| 334 | // simulation of the actual subdivision pattern. |
| 335 | const estTriangles = simulateFromEdges(triEdges, edge); |
| 336 | |
| 337 | // Recommended Max Triangles for "minimum quality loss" decimation. |
| 338 | const recommendedMaxTri = computeRecommendedMaxTri({ |
| 339 | pixelsPerEdge, pixMm, surfaceArea, |
| 340 | amplitude: settings.amplitude, |
| 341 | }); |
| 342 | |
| 343 | return { |
| 344 | edge, |
| 345 | diagnostics: { |
| 346 | pixelsPerEdge, |
no test coverage detected