// =====================================================================
// Per-mesh data structures.
//
//   MeshInfo:    { numVertices, numTriangles, numEdges }
//     Stable counts for the source mesh. numVertices is the position
//     count (obj.positions.length); the corner count (post UV-seam
//     dedupe) can be larger but isn't tracked here.
//
//   BuiltData:   { meshInfo, triEdgeIds, edgePos0, edgePos1,
//                  cornerPosIdx, cornerNormalIdx, cornerUvIdx,
//                  triVertOwnership, triEdgeOwnership, triTopoMask,
//                  phongSetups, triUvs }
//     Computed ONCE when the mesh is loaded. Holds source-side data
//     that doesn't change with tess parameters:
//       triEdgeIds [Int32Array, numTriangles*3]
//         Adjacency from tri to edge -- triEdgeIds[3*t + k] is the edge
//         id at edge k of triangle t. Edge order matches
//         CalcPlainBaryPtsForTess: 0 = corners 0->1, 1 = 1->2, 2 = 0->2.
//       edgePos0, edgePos1 [Int32Array, numEdges]
//         The two endpoint position indices for each edge id, used by
//         length-based tess heuristics. Either ordering is fine.
//       cornerPosIdx, cornerNormalIdx, cornerUvIdx [Int32Array, numTriangles*3]
//         Per-source-tri-corner attribute indices into obj.positions /
//         obj.normals / obj.texcoords. Pass B fetches these to look up
//         each source tri's 3 corners.
//       triVertOwnership [Int32Array, numTriangles*3]
//         For each (tri, corner): -1 if this triangle is the dominant
//         owner of the position; otherwise packed ref domTri*3+domSlot
//         pointing at the dominant triangle's corner slot. Vertices are
//         shared by POSITION (not full corner identity) -- a UV seam
//         doesn't split vertex ownership, only edge ownership.
//       triEdgeOwnership [Int32Array, numTriangles*3]
//         For each (tri, edge): -1 if dominant; else packed ref
//         domTri*3+domSlot. Edges share only when the underlying edge
//         id matches (full corner-identity dedup -- UV seams DO split
//         edge ownership).
//       triTopoMask [Uint8Array, numTriangles]
//         Per-tri 6-bit summary mirroring PackTessOwnedMask: bit 0/1/2
//         = own corner 0/1/2, bit 3/4/5 = own edge 0/1/2. Drives Pass A
//         allocate-vs-defer decisions in Phase 2.
//       phongSetups [Array, numTriangles]
//         Cached Phong-tessellation setup per source tri (just the 3
//         corner positions and 3 corner normals as vec3 fields). Pass
//         B projects onto each corner's tangent plane and barycentric-
//         blends.
//       triUvs [Float32Array, numTriangles*6]
//         Pre-fetched per-corner UVs as flat (u0,v0,u1,v1,u2,v2) per
//         tri so Pass B can interpolate without going through indices.
//
//   RenderData:  { tessFactors, vertexOffset/Count, triOffset/Count }
//     Computed every tessellation pass (today: every slider change;
//     tomorrow: every frame on the GPU compute side). Holds the
//     per-edge factors used for this pass plus the bump-allocated
//     slice this mesh occupies in the global pool.
//
//   Mesh:        { objs, builtData, renderData }
//     One mesh = source OBJ groups + its BuiltData + its RenderData.
//
//   GlobalPool:  shared typed-array + bump cursor pool that backs the
//     tessellated vertex / index data for ALL meshes. Each mesh's
//     RenderData records where its slice lives. Resets to empty at the
//     start of every rebuild. Analogue of the future single global GPU
//     buffer.
// =====================================================================

function makeRenderData(numTriangles) {
  return {
    tessFactors:  null,   // Float32Array(numEdges) when in edge mode, else null.
    vertexOffset: 0,
    vertexCount:  0,
    triOffset:    0,      // tri (3-index group) offset
    triCount:     0,
    // Phase 2 scratch tables (per pass). Filled in runTopologyPass.
    //   triCornerPoolSlot[3*t + k]
    //     Mesh-local slot (relative to vertexOffset) of source tri t's
    //     corner k vertex in the pool. When k is OWNED by t, this is
    //     where t allocated. When k is shared, this is the dominant's
    //     slot -- copied during Pass A so the index list translation
    //     can do a single lookup regardless of ownership.
    //   triEdgeFirstPoolSlot[3*t + k]
    //     Mesh-local slot of the FIRST interior vert on source tri t's
    //     edge k. Same own-vs-share semantics as above.
    //   triEdgeVertCount[3*t + k]
    //     Number of interior verts on source tri t's edge k for this
    //     pass. Depends on the edge's tess factor, so per-pass.
    triCornerPoolSlot:    new Int32Array(numTriangles * 3),
    triEdgeFirstPoolSlot: new Int32Array(numTriangles * 3),
    triEdgeVertCount:     new Int32Array(numTriangles * 3),
  };
}

// 16:16-unorm pack/unpack for per-vertex bary. Pack is the standard
// pack2x16unorm contract: clamp to [0,1], round-half-to-even, scale
// by 65535. Unpack is the SYMMETRIC variant: for u in [0, 32767]
// returns u/65535 directly; for u in [32768, 65535] returns
// 1 - (65535-u)/65535. This guarantees decode(u) + decode(65535-u)
// is bit-exactly 1.0 -- so when adjacent triangles share an edge
// vertex and one sees parameter t and the other sees 1-t, they get
// floats that sum to exactly 1.0 instead of 1.0 +/- 1 ulp.
//
// WGSL's built-in unpack2x16unorm uses the simple u/65535 form for
// both halves, which doesn't have this symmetry; we open-code the
// matching unpack on the GPU side too so both stay bit-equivalent.
function pack2x16unorm(x, y) {
  function roundHalfEven(v) {
    const f = Math.floor(v);
    const d = v - f;
    if (d < 0.5) return f;
    if (d > 0.5) return f + 1;
    return (f & 1) ? f + 1 : f;
  }
  const cx = x < 0 ? 0 : x > 1 ? 1 : x;
  const cy = y < 0 ? 0 : y > 1 ? 1 : y;
  const ix = roundHalfEven(cx * 65535);
  const iy = roundHalfEven(cy * 65535);
  return ((ix | (iy << 16)) >>> 0);
}
function unpack16Symmetric(u) {
  const flipped = 65535 - u;
  return (u <= flipped) ? (u / 65535) : (1 - flipped / 65535);
}
function unpack2x16unorm_x(packed) { return unpack16Symmetric(packed & 0xffff); }
function unpack2x16unorm_y(packed) { return unpack16Symmetric(packed >>> 16); }

// Tess-factor snap-to-integer-token-endpoint. Applied at upload time
// when STATE.fractionalTess is off; the result is the bracket-low
// integer endpoint of the bracket the input falls in (with the upper
// endpoint kept when the input lands exactly on it). This is
// numerically equivalent to passing forceFullT=true through
// EvaluateTokenT / HalfExpFrac_GetT: the table lookup's lerp parameter
// becomes 0 (or stays at 1 when already at the bracket top), so
// downstream evaluation snaps to the integer-only tess output.
//
// Brackets follow getTokenFromTessFactor in the WGSL helpers + the
// CPU GetTokenFromTessFactorAnalytic: step 2 up to 15, step 4 up to
// 31, step 8 up to 63, step 16 up to 127, step 32 up to 255.
function snapTessForFullT(tess) {
  if (tess <= 0)   return 0;
  if (tess <= 1)   return (tess === 1) ? 1 : 0;
  if (tess >= 255) return 255;
  if (tess <= 15)  return Math.floor((tess - 1)   /  2) *  2 + 1;
  if (tess <= 31)  return Math.floor((tess - 15)  /  4) *  4 + 15;
  if (tess <= 63)  return Math.floor((tess - 31)  /  8) *  8 + 31;
  if (tess <= 127) return Math.floor((tess - 63)  / 16) * 16 + 63;
  return            Math.floor((tess - 127) / 32) * 32 + 127;
}

function makeGlobalPool(maxVerts, maxTris) {
  return {
    maxVerts, maxTris,
    // Pass A outputs (per tessellated vertex):
    // (x, y) in source-tri parameter space, packed as pack2x16unorm
    // (16-bit unorm per coord). Read sites unpack with the matching
    // unpack2x16unorm helper. Same canonical form on CPU and GPU --
    // gpuOutA3cBary holds u32-per-vert too.
    vertexBary:   new Uint32Array(maxVerts),
    vertexTriId:  new Uint32Array(maxVerts),        // mesh-local source tri index
    // Pass B outputs (per tessellated vertex), all in LOCAL space.
    // B does Phong tessellation eval AND displacement in one pass --
    // positions are already displaced when written.
    positions:    new Float32Array(maxVerts * 3),
    normals:      new Float32Array(maxVerts * 3),
    texcoords:    new Float32Array(maxVerts * 2),
    // Snapshot of post-B (pos+disp), pre-weld positions. runWeldPass
    // mutates `positions` in place, so the GPU B_vertex validation
    // needs an un-welded reference to diff against when weld is on.
    positionsAfterB: new Float32Array(maxVerts * 3),
    // Pre-weld normals snapshot -- runWeldPass also averages +
    // renormalizes normals across welded sets, which leaves a small
    // residual (~1e-5) in the unit direction. CC parity gates need
    // the un-welded normal to diff bit-exact against ccEvalPosNrm.
    normalsAfterB:   new Float32Array(maxVerts * 3),
    // Per-vertex debug color (rgba8). Written by Pass A only when
    // colorMode is "ownership" or "tessOrient"; consumed by the
    // surface shader's mode-2 (debug) branch via vbufColor. Solid +
    // pbr modes don't read this.
    clusterColor: new Uint8Array(maxVerts * 4),
    // Tri index buffer (mesh-local; rendered with baseVertex offset):
    triIndices:   new Uint32Array(maxTris  * 3),
    vertexCursor: 0,
    triCursor:    0,
  };
}

function resetGlobalPool(pool) {
  pool.vertexCursor = 0;
  pool.triCursor    = 0;
}




// =====================================================================
// Per-edge tess factor pass.
//
// factor = clamp(effectiveEdgeLength * lengthScale, 0, maxTess).
//
// The effective length comes from edgeTfPos0/Pos1: it equals the
// edge's own length when the edge is part of a polygon's perimeter,
// or the OWNING POLYGON's longest perimeter length when the edge is
// a diagonal that fan triangulation introduced. That keeps the
// shortest-at-slot-2 rotation driven by polygon-edge metrics and
// guarantees both triangles sharing a quad's diagonal use a matching
// tess factor on that internal seam.
//
// maxTess comes from the Max-tess combo (31 / 63 / 127 / 255);
// TessLookup supports up to 255.
// =====================================================================
function computeEdgeTessFactors(builtData, positions, lengthScale, maxTess) {
  const { meshInfo, edgeTfPos0, edgeTfPos1 } = builtData;
  const numEdges = meshInfo.numEdges;
  const factors = new Float32Array(numEdges);
  for (let i = 0; i < numEdges; i++) {
    const a = positions[edgeTfPos0[i]];
    const b = positions[edgeTfPos1[i]];
    const dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2];
    const len = Math.sqrt(dx*dx + dy*dy + dz*dz);
    let f = len * lengthScale;
    if (f < 0)       f = 0;
    if (f > maxTess) f = maxTess;
    factors[i] = f;
  }
  return factors;
}


// =====================================================================
// Per-edge screen-space tess factor.
//
// factor = clamp(pixelLength / targetPixels - 1, 0, maxTess)
//
// "tess factor 0 = full edge, no subdivision" is the offset baked into
// the -1: an edge that's exactly targetPixels long maps to factor 0
// (one segment), a 2x-target edge to factor 1 (two segments), etc.
// Endpoints behind the camera (clip.w <= 0) push the edge to maxTess
// so silhouettes near the near plane don't suddenly under-tessellate.
//
// edgeTfPos0/Pos1 give the pos indices the edge mode already uses --
// own perimeter when available, owning polygon's longest perimeter
// when the edge is a fan-tri diagonal. Same metric, just projected.
// =====================================================================
// =====================================================================
// Per-edge curvature tess factor (Brainerd-style screen-space midpoint).
//
// Follows Brainerd & Wade's adaptive-tessellation metric from the CoD:
// Ghosts GDC 2014 talk: for each edge, project BOTH midpoints to screen
// pixels and use the pixel distance between them as the curvature error
// estimate. A flat edge (phong_mid == pl_mid in 3D) projects to a single
// screen point -> 0 error -> no subdivision. A curved edge close to the
// camera produces a big screen gap -> high tess factor.
//
//   pl_mid    = 0.5 * (A + B)                    -- linear chord midpoint
//   phong_mid = 0.5 * (pi_A + pi_B)              -- Phong-tessellated mid
//     where pi_X = pl_mid - dot(pl_mid - X, nX) * nX
//
//   factor = sqrt(error_px / max(target_px, 0.001))
//
// SQRT conversion (from Schwarz 2006, PN-triangle bound, slide 60 of
// Brainerd CoD: Ghosts 2014 in spirit): per-subsegment chord error
// scales as ~(1/N^2) for Phong/Bezier as subdivision count N grows.
// So target = initial_err / N^2 -> N = sqrt(initial_err / target).
// At target = err_px, factor = 1 (one extra mid-edge vert per edge);
// at target = err_px/4, factor = 2 (3 segments per edge); etc.
//
// Endpoints behind the near plane (clip.w <= 0) fall back to maxTess.
// Slider floor of 0.001 keeps target=0 ("max tess everywhere") safe.
//
// `normals` is per-position (smoothNormalsInPlace makes normalIdx ==
// posIdx, so per-position indexing works directly).
// =====================================================================
// Global call counter -- diagnostic. Bumps on every entry so the
// snapshot can confirm the function is actually being invoked even
// if the per-builtData diag struct ends up missing for some reason.
if (typeof window !== 'undefined') {
  if (!window.__curvatureCallCount) window.__curvatureCallCount = 0;
}
function computeCurvatureScreenSpaceTessFactors(builtData, positions, normals, modelMatrix, camInfo, targetPx, maxTess) {
  if (typeof window !== 'undefined') window.__curvatureCallCount++;
  const { meshInfo, edgeTfPos0, edgeTfPos1 } = builtData;
  const numEdges = meshInfo.numEdges;
  const factors = new Float32Array(numEdges);
  const m = modelMatrix;
  // Slider floor: 0 in the UI means "max tess everywhere".
  const invTarget = 1 / Math.max(targetPx, 0.001);
  // Camera basis + projection scalars (precomputed by caller).
  const cpx = camInfo.camPos[0], cpy = camInfo.camPos[1], cpz = camInfo.camPos[2];
  const fx = camInfo.forward[0], fy = camInfo.forward[1], fz = camInfo.forward[2];
  const rx = camInfo.right[0],   ry = camInfo.right[1],   rz = camInfo.right[2];
  const ux = camInfo.up[0],      uy = camInfo.up[1],      uz = camInfo.up[2];
  const pxPerRad     = camInfo.pxPerRad;
  // Use ANGLES (radians) instead of NDC for the off-screen falloff.
  // Half-fov values define the in-view boundary; an endpoint with
  // |h_angle| > halfFovX is "off to the side". atan2() handles
  // behind-camera endpoints naturally (their angle wraps past pi/2
  // toward +/-pi), so mixed-state edges (one in front + one behind)
  // don't fall through the same-side check.
  const halfFovX = Math.atan(camInfo.tanHalfFovX);   // radians
  const halfFovY = Math.atan(camInfo.tanHalfFovY);
  // Off-screen falloff width: how many RADIANS past the frustum half-
  // angle before tess goes fully to zero. ~0.5 rad ~ 28 degrees beyond
  // the FOV boundary.
  const falloffW = 0.5;
  // Diagnostic accumulators.
  let maxErrPx = 0, sumErrPx = 0, maxF = 0, behindCount = 0, falloffSuppressedCount = 0;
  const n0Sample = normals && normals[edgeTfPos0[0]];
  const sampleNormalMag = n0Sample
    ? Math.sqrt(n0Sample[0]*n0Sample[0] + n0Sample[1]*n0Sample[1] + n0Sample[2]*n0Sample[2])
    : -1;
  for (let i = 0; i < numEdges; i++) {
    const a = positions[edgeTfPos0[i]];
    const b = positions[edgeTfPos1[i]];
    const nA = normals[edgeTfPos0[i]];
    const nB = normals[edgeTfPos1[i]];
    // Linear chord midpoint + Phong-tessellated midpoint (object space).
    const lx = 0.5 * (a[0] + b[0]);
    const ly = 0.5 * (a[1] + b[1]);
    const lz = 0.5 * (a[2] + b[2]);
    const daX = lx - a[0], daY = ly - a[1], daZ = lz - a[2];
    const tA  = daX*nA[0] + daY*nA[1] + daZ*nA[2];
    const piAx = lx - tA*nA[0], piAy = ly - tA*nA[1], piAz = lz - tA*nA[2];
    const dbX = lx - b[0], dbY = ly - b[1], dbZ = lz - b[2];
    const tB  = dbX*nB[0] + dbY*nB[1] + dbZ*nB[2];
    const piBx = lx - tB*nB[0], piBy = ly - tB*nB[1], piBz = lz - tB*nB[2];
    const ppx = 0.5 * (piAx + piBx);
    const ppy = 0.5 * (piAy + piBy);
    const ppz = 0.5 * (piAz + piBz);
    // Transform midpoints + endpoints to world via the model matrix.
    const lwx = m[0]*lx + m[4]*ly + m[ 8]*lz + m[12];
    const lwy = m[1]*lx + m[5]*ly + m[ 9]*lz + m[13];
    const lwz = m[2]*lx + m[6]*ly + m[10]*lz + m[14];
    const pwx = m[0]*ppx + m[4]*ppy + m[ 8]*ppz + m[12];
    const pwy = m[1]*ppx + m[5]*ppy + m[ 9]*ppz + m[13];
    const pwz = m[2]*ppx + m[6]*ppy + m[10]*ppz + m[14];
    // Angular metric: angle subtended at the camera by the chord-to-
    // Phong-midpoint deviation. sin_theta = |v_L x v_P| / (|v_L|*|v_P|);
    // err_px = sin_theta * pxPerRad. Numerically stable everywhere
    // except |v_L| ~= 0 (camera coincident with the midpoint).
    const vlx = lwx - cpx, vly = lwy - cpy, vlz = lwz - cpz;
    const vpx = pwx - cpx, vpy = pwy - cpy, vpz = pwz - cpz;
    const vlLen2 = vlx*vlx + vly*vly + vlz*vlz;
    const vpLen2 = vpx*vpx + vpy*vpy + vpz*vpz;
    const ccx = vly*vpz - vlz*vpy;
    const ccy = vlz*vpx - vlx*vpz;
    const ccz = vlx*vpy - vly*vpx;
    const crossMag = Math.sqrt(ccx*ccx + ccy*ccy + ccz*ccz);
    let err_px = 0;
    if (vlLen2 > 1e-12 && vpLen2 > 1e-12) {
      err_px = (crossMag / Math.sqrt(vlLen2 * vpLen2)) * pxPerRad;
    }
    if (err_px > maxErrPx) maxErrPx = err_px;
    sumErrPx += err_px;
    // Schwarz convergence: segments scale as sqrt(initial_err / target).
    let f = Math.sqrt(err_px * invTarget);
    if (f > maxTess) f = maxTess;
    // ---- Off-screen falloff. Use per-endpoint horizontal+vertical
    // angles (atan2 from forward) so behind-camera endpoints get a
    // well-defined extreme angle and the same-side check naturally
    // catches mixed-state edges (one in front + one behind).
    const awx = m[0]*a[0] + m[4]*a[1] + m[ 8]*a[2] + m[12];
    const awy = m[1]*a[0] + m[5]*a[1] + m[ 9]*a[2] + m[13];
    const awz = m[2]*a[0] + m[6]*a[1] + m[10]*a[2] + m[14];
    const bwx = m[0]*b[0] + m[4]*b[1] + m[ 8]*b[2] + m[12];
    const bwy = m[1]*b[0] + m[5]*b[1] + m[ 9]*b[2] + m[13];
    const bwz = m[2]*b[0] + m[6]*b[1] + m[10]*b[2] + m[14];
    const dAx = awx - cpx, dAy = awy - cpy, dAz = awz - cpz;
    const fwdA = dAx*fx + dAy*fy + dAz*fz;
    const rgtA = dAx*rx + dAy*ry + dAz*rz;
    const upDA = dAx*ux + dAy*uy + dAz*uz;
    const dBx = bwx - cpx, dBy = bwy - cpy, dBz = bwz - cpz;
    const fwdB = dBx*fx + dBy*fy + dBz*fz;
    const rgtB = dBx*rx + dBy*ry + dBz*rz;
    const upDB = dBx*ux + dBy*uy + dBz*uz;
    // Per-endpoint horizontal + vertical angles. atan2 is (-pi, pi];
    // in-front endpoints land in (-pi/2, pi/2) and represent the
    // angle off the view axis. Behind-camera endpoints get values
    // past +/-pi/2 (toward +/-pi) -- still well-defined, so the
    // same-side check works through the camera plane transition.
    const hA = Math.atan2(rgtA, fwdA);
    const vA = Math.atan2(upDA, fwdA);
    const hB = Math.atan2(rgtB, fwdB);
    const vB = Math.atan2(upDB, fwdB);
    // Same-side excess in RADIANS past the half-FOV. min() picks
    // the LESS-far endpoint -- negative result means at least one
    // endpoint is on the other side or inside the frustum, so the
    // outer max() clamps to 0 (edge spans the frustum -> no falloff).
    const exHRight = Math.min(hA - halfFovX, hB - halfFovX);
    const exHLeft  = Math.min(-halfFovX - hA, -halfFovX - hB);
    const excessH  = Math.max(0, exHRight, exHLeft);
    const exVUp    = Math.min(vA - halfFovY, vB - halfFovY);
    const exVDown  = Math.min(-halfFovY - vA, -halfFovY - vB);
    const excessV  = Math.max(0, exVUp, exVDown);
    // Direct "both behind" check -- handles the atan2 discontinuity
    // when both endpoints are directly behind camera but on opposite
    // sides of the back center (h ~ +pi and h ~ -pi have opposite
    // signs, so the same-side check above would miss them).
    let excessBehind = 0;
    if (fwdA <= 0 && fwdB <= 0) {
      excessBehind = Math.min(-fwdA, -fwdB);
      behindCount++;
    }
    // Linear ramp: excess (radians) / W -> falloff (0..1).
    const falloffH = Math.min(excessH      / falloffW, 1);
    const falloffV = Math.min(excessV      / falloffW, 1);
    const falloffB = Math.min(excessBehind / falloffW, 1);
    const falloff  = Math.max(falloffH, falloffV, falloffB);
    f *= (1 - falloff);
    if (falloff >= 1) falloffSuppressedCount++;
    if (f < 0) f = 0;
    if (f > maxF) maxF = f;
    factors[i] = f;
  }
  builtData._curvatureDiag = {
    maxErrPx, meanErrPx: numEdges > 0 ? sumErrPx / numEdges : 0,
    maxF, behindCount, falloffSuppressedCount, numEdges, target: targetPx,
    sampleNormalMag,
  };
  return factors;
}


// =====================================================================
// Per-edge angular EDGE-LENGTH tess factor + off-screen falloff.
//
// Same camera basis + same-side angular falloff as the curvature mode,
// but the metric is the EDGE LENGTH in pixels (angle subtended at the
// camera by the chord between A and B) rather than the chord-to-Phong
// deviation. Conceptually equivalent to the existing screen-space mode
// but uses the projection-free angular formulation, so it inherits the
// near-plane stability + off-screen falloff that the curvature path
// already has.
//
//   v_A = A_world - cam_pos
//   v_B = B_world - cam_pos
//   sin_theta = |v_A x v_B| / (|v_A| * |v_B|)
//   edge_px  = sin_theta * pxPerRad
//   factor   = edge_px / target_px - 1
//
// `-1` offset mirrors the existing screen-space mode: when the edge is
// exactly `target_px` long on screen, factor=0 (no subdivision).
// =====================================================================
function computeAngularEdgeTessFactors(builtData, positions, modelMatrix, camInfo, targetPx, maxTess) {
  if (typeof window !== 'undefined') {
    if (!window.__angularEdgeCallCount) window.__angularEdgeCallCount = 0;
    window.__angularEdgeCallCount++;
  }
  const { meshInfo, edgeTfPos0, edgeTfPos1 } = builtData;
  const numEdges = meshInfo.numEdges;
  const factors = new Float32Array(numEdges);
  const m = modelMatrix;
  const invTarget = 1 / Math.max(targetPx, 0.001);
  const cpx = camInfo.camPos[0], cpy = camInfo.camPos[1], cpz = camInfo.camPos[2];
  const fx = camInfo.forward[0], fy = camInfo.forward[1], fz = camInfo.forward[2];
  const rx = camInfo.right[0],   ry = camInfo.right[1],   rz = camInfo.right[2];
  const ux = camInfo.up[0],      uy = camInfo.up[1],      uz = camInfo.up[2];
  const pxPerRad = camInfo.pxPerRad;
  const halfFovX = Math.atan(camInfo.tanHalfFovX);
  const halfFovY = Math.atan(camInfo.tanHalfFovY);
  const falloffW = 0.5;
  let maxErrPx = 0, sumErrPx = 0, maxF = 0, behindCount = 0, falloffSuppressedCount = 0;
  for (let i = 0; i < numEdges; i++) {
    const a = positions[edgeTfPos0[i]];
    const b = positions[edgeTfPos1[i]];
    // Endpoints to world.
    const awx = m[0]*a[0] + m[4]*a[1] + m[ 8]*a[2] + m[12];
    const awy = m[1]*a[0] + m[5]*a[1] + m[ 9]*a[2] + m[13];
    const awz = m[2]*a[0] + m[6]*a[1] + m[10]*a[2] + m[14];
    const bwx = m[0]*b[0] + m[4]*b[1] + m[ 8]*b[2] + m[12];
    const bwy = m[1]*b[0] + m[5]*b[1] + m[ 9]*b[2] + m[13];
    const bwz = m[2]*b[0] + m[6]*b[1] + m[10]*b[2] + m[14];
    // Camera-relative vectors -- to the ENDPOINTS (not midpoints).
    const vax = awx - cpx, vay = awy - cpy, vaz = awz - cpz;
    const vbx = bwx - cpx, vby = bwy - cpy, vbz = bwz - cpz;
    const vaLen2 = vax*vax + vay*vay + vaz*vaz;
    const vbLen2 = vbx*vbx + vby*vby + vbz*vbz;
    // Angular size of the edge as seen from the camera.
    const ccx = vay*vbz - vaz*vby;
    const ccy = vaz*vbx - vax*vbz;
    const ccz = vax*vby - vay*vbx;
    const crossMag = Math.sqrt(ccx*ccx + ccy*ccy + ccz*ccz);
    let edge_px = 0;
    if (vaLen2 > 1e-12 && vbLen2 > 1e-12) {
      edge_px = (crossMag / Math.sqrt(vaLen2 * vbLen2)) * pxPerRad;
    }
    if (edge_px > maxErrPx) maxErrPx = edge_px;
    sumErrPx += edge_px;
    // Linear -- matches existing screen-space convention.
    let f = edge_px * invTarget - 1;
    if (f > maxTess) f = maxTess;
    // ---- Off-screen falloff (same as curvature). Reuse fwd/rgt/up
    // components already implicit in v_A / v_B.
    const fwdA = vax*fx + vay*fy + vaz*fz;
    const rgtA = vax*rx + vay*ry + vaz*rz;
    const upDA = vax*ux + vay*uy + vaz*uz;
    const fwdB = vbx*fx + vby*fy + vbz*fz;
    const rgtB = vbx*rx + vby*ry + vbz*rz;
    const upDB = vbx*ux + vby*uy + vbz*uz;
    const hA = Math.atan2(rgtA, fwdA);
    const vA = Math.atan2(upDA, fwdA);
    const hB = Math.atan2(rgtB, fwdB);
    const vB = Math.atan2(upDB, fwdB);
    const exHRight = Math.min(hA - halfFovX, hB - halfFovX);
    const exHLeft  = Math.min(-halfFovX - hA, -halfFovX - hB);
    const excessH  = Math.max(0, exHRight, exHLeft);
    const exVUp    = Math.min(vA - halfFovY, vB - halfFovY);
    const exVDown  = Math.min(-halfFovY - vA, -halfFovY - vB);
    const excessV  = Math.max(0, exVUp, exVDown);
    let excessBehind = 0;
    if (fwdA <= 0 && fwdB <= 0) {
      excessBehind = Math.min(-fwdA, -fwdB);
      behindCount++;
    }
    const falloffH = Math.min(excessH      / falloffW, 1);
    const falloffV = Math.min(excessV      / falloffW, 1);
    const falloffB = Math.min(excessBehind / falloffW, 1);
    const falloff  = Math.max(falloffH, falloffV, falloffB);
    f *= (1 - falloff);
    if (falloff >= 1) falloffSuppressedCount++;
    if (f < 0) f = 0;
    if (f > maxF) maxF = f;
    factors[i] = f;
  }
  // Reuse the curvature diag struct -- the buildDebugSnapshot reads
  // `_curvatureDiag` regardless of which angular metric was active.
  // The fields' meanings here: maxErrPx is the longest screen-space
  // edge length, not chord-deviation.
  builtData._curvatureDiag = {
    maxErrPx, meanErrPx: numEdges > 0 ? sumErrPx / numEdges : 0,
    maxF, behindCount, falloffSuppressedCount, numEdges, target: targetPx,
    sampleNormalMag: -1,
  };
  return factors;
}


function computeScreenSpaceTessFactors(builtData, positions, modelMatrix, viewProj, viewportW, viewportH, targetPixels, maxTess) {
  const { meshInfo, edgeTfPos0, edgeTfPos1 } = builtData;
  const numEdges = meshInfo.numEdges;
  const factors = new Float32Array(numEdges);
  // Flatten model * viewProj into one local-to-clip matrix so each
  // endpoint is a single 4x4 mul.
  const m = modelMatrix, vp = viewProj;
  const mvp = new Float32Array(16);
  for (let r = 0; r < 4; r++) {
    for (let c = 0; c < 4; c++) {
      let s = 0;
      for (let k = 0; k < 4; k++) s += vp[r + k*4] * m[k + c*4];
      mvp[r + c*4] = s;
    }
  }
  const halfW = viewportW * 0.5;
  const halfH = viewportH * 0.5;
  const inv = 1 / Math.max(targetPixels, 1e-6);
  for (let i = 0; i < numEdges; i++) {
    const a = positions[edgeTfPos0[i]];
    const b = positions[edgeTfPos1[i]];
    const ax = mvp[0]*a[0] + mvp[4]*a[1] + mvp[ 8]*a[2] + mvp[12];
    const ay = mvp[1]*a[0] + mvp[5]*a[1] + mvp[ 9]*a[2] + mvp[13];
    const aw = mvp[3]*a[0] + mvp[7]*a[1] + mvp[11]*a[2] + mvp[15];
    const bx = mvp[0]*b[0] + mvp[4]*b[1] + mvp[ 8]*b[2] + mvp[12];
    const by = mvp[1]*b[0] + mvp[5]*b[1] + mvp[ 9]*b[2] + mvp[13];
    const bw = mvp[3]*b[0] + mvp[7]*b[1] + mvp[11]*b[2] + mvp[15];
    let f;
    if (aw <= 0 || bw <= 0) {
      f = maxTess;
    } else {
      const apx = (ax / aw) * halfW;
      const apy = (ay / aw) * halfH;
      const bpx = (bx / bw) * halfW;
      const bpy = (by / bw) * halfH;
      const dx = apx - bpx, dy = apy - bpy;
      const px = Math.sqrt(dx*dx + dy*dy);
      f = px * inv - 1;
      if (f < 0) f = 0;
      if (f > maxTess) f = maxTess;
    }
    factors[i] = f;
  }
  return factors;
}




// =====================================================================
// Pass A -- topology.
//
// For each source triangle in the mesh, run CalcPlainBaryPtsForTess to
// get the tessellated bary points and tri index list, then write into
// the pool:
//   pool.vertexBary[i]           = (x, y) bary in source-tri parameter
//                                  space, packed via pack2x16unorm
//   pool.vertexTriId[i]          = mesh-local source-tri index
//   pool.triIndices[j]           = MESH-LOCAL tessellated tri index
//
// Indices are stored mesh-local (not adding the pool's vertexCursor)
// so render uses baseVertex = renderData.vertexOffset to remap them.
// This mirrors the future GPU compute layout where each mesh's slice
// is a self-contained sub-buffer.
//
// Pass A does NOT touch positions/normals/texcoords -- that's Pass B's
// job. The only outputs here are bary, triId, and the index lists.
// =====================================================================
// Edge endpoint corner-index pairs in TessLookup convention:
//   edge 0 traverses corner 0 -> corner 1
//   edge 1 traverses corner 2 -> corner 1
//   edge 2 traverses corner 0 -> corner 2
const EDGE_START_CORNER = [0, 2, 0];
const EDGE_END_CORNER   = [1, 1, 2];

// skipPoolWrites: when true, skip writes into pool.vertexBary /
// pool.vertexTriId / pool.clusterColor (ownership/tessOrient only) /
// pool.triIndices. The cursor
// advances and renderData side-effects (triCornerPoolSlot /
// triEdgeFirstPoolSlot / triEdgeVertCount / vertexOffset / etc.)
// still happen, so D_weld's per-instance pool uploads and the
// render's drawIndexed bounds remain correct. Used in gpu_only mode
// where the render path reads gpuOut* for everything, so the CPU-side
// pool data is pure dead code -- skipping it saves ~1M+ array writes
// per Pass A on a typical scene.
function runTopologyPass(mesh, pool, fixedTessFactor, colorMode, shareVerts, shareEdges, emitParallelogram, emitGap, skipPoolWrites, skipPerSlotLoop) {
  const { builtData, renderData, clusters, triClusterId } = mesh;
  // Reset per-pass diagnostics. Filled in the per-source-tri loop.
  renderData.numType0          = 0;
  renderData.numType1          = 0;
  renderData.numType2          = 0;
  renderData.numType2Interior  = 0;
  renderData.numType2Gap       = 0;
  const factors      = renderData.tessFactors;
  const triEdgeIds   = builtData.triEdgeIds;
  const numSrcTris   = builtData.meshInfo.numTriangles;
  const ownerVert    = builtData.triVertOwnership;
  const ownerEdge    = builtData.triEdgeOwnership;
  const cornerPosIdx = builtData.cornerPosIdx;
  const triRotation  = builtData.triRotation;
  // Ownership-viz palette (rgb triplets):
  //   owned vertex (corner):    yellow
  //   borrowed vertex (corner): magenta
  //   owned edge interior:      green
  //   borrowed edge interior:   red
  //   face interior:            gray
  // Vertex and edge colors are deliberately distinct hues so a corner
  // shared between two tris reads differently from a shared edge.
  const COL_VERT_OWN = [232, 216, 64];
  const COL_VERT_DEF = [200, 64, 192];
  const COL_EDGE_OWN = [80, 220, 80];
  const COL_EDGE_DEF = [220, 80, 80];
  const COL_INT      = [130, 130, 130];

  // Tess-orientation viz palette. Each tessellated edge gets the color
  // of its ROTATED edge slot (post shortest-at-slot-2 rotation). With
  // the rotation actually APPLIED to the tessellation input below,
  // slot K in the rotated bary layout is rotated edge K directly --
  // so the edge-region tests just key off the rotated slot range.
  const COL_ROT_E0 = [220, 80, 80];   // rotated_e0 (red)
  const COL_ROT_E1 = [80, 220, 80];   // rotated_e1 (green) -- gap stitch
  const COL_ROT_E2 = [80, 130, 235];  // rotated_e2 (blue) -- SHORTEST edge
  const COL_ROT_CORNER = [232, 216, 64];   // corner (yellow)

  const startVtx  = pool.vertexCursor;
  const startTri  = pool.triCursor;

  for (let triIdx = 0; triIdx < numSrcTris; triIdx++) {
    // Apply the per-tri rotation by feeding ROTATED tess factors into
    // CalcPlainBaryPtsForTess. The result lives in the rotated bary
    // frame: rotated corner i = original corner (i + R) % 3, rotated
    // edge K = original edge (K + R) % 3. We translate back to the
    // original frame at every interface (bary writes, ownership lookups,
    // scratch-table writes) so Pass B / welding stay frame-agnostic.
    const rotR = triRotation[triIdx];
    let t0, t1, t2;
    if (factors) {
      t0 = factors[triEdgeIds[triIdx*3 + ((0 + rotR) % 3)]];
      t1 = factors[triEdgeIds[triIdx*3 + ((1 + rotR) % 3)]];
      t2 = factors[triEdgeIds[triIdx*3 + ((2 + rotR) % 3)]];
    } else {
      t0 = t1 = t2 = fixedTessFactor;
    }
    // Fast path in skipPoolWrites mode: only the per-tri TOKENS,
    // triangleType, and numPrims are needed downstream (the per-vertex
    // bary points and the per-prim tessIdx are dead because the pool
    // writes that consume them are gated off). CalcPlainBaryPtsForTess
    // calls BuildBaryVec which walks GetSingleBary nv times per source
    // tri -- skipping it saves the bulk of Pass A's CPU cost in
    // gpu_only mode (the GPU's A3c already computes equivalent bary
    // data in cluster-scoped layout).
    let r, baryPts, tessIdx;
    if (skipPoolWrites) {
      const tk0 = TessLookup.GetTokenFromTessFactorAnalytic(t0);
      const tk1 = TessLookup.GetTokenFromTessFactorAnalytic(t1);
      const tk2 = TessLookup.GetTokenFromTessFactorAnalytic(t2);
      const triangleType = TessLookup.GetTriangleType(tk0, tk1, tk2);
      const numPrims     = TessLookup.GetNumPrimsForType(tk0, tk1, tk2, triangleType);
      r = { token0: tk0, token1: tk1, token2: tk2, triangleType, numPrims };
    } else {
      r = CalcPlainBaryPtsForTess(t0, t1, t2, false);
      baryPts = r.baryPts;
      tessIdx = r.tessIdxList;
    }
    const E0 = r.token0.m_numPts - 1;
    const E1 = r.token1.m_numPts - 1;
    const E2 = r.token2.m_numPts - 1;
    const interiorCount = (r.triangleType === 0) ? 0
                       : (r.triangleType === 1) ? 1
                       : TessLookup.LookupInteriorNumInteriorVtx(r.token0.m_table, r.token2.m_table);
    const nv      = 3 + E0 + E1 + E2 + interiorCount;
    const numTri  = r.numPrims;

    // Conservative pool overflow check (allocCount <= nv).
    if (pool.vertexCursor + nv > pool.maxVerts) {
      throw new Error(`pool vertex overflow: alloc <= ${nv} at ${pool.vertexCursor}, max ${pool.maxVerts}`);
    }
    const triOff = pool.triCursor;
    if (triOff + numTri > pool.maxTris) {
      throw new Error(`pool tri overflow: alloc ${numTri} at ${triOff}, max ${pool.maxTris}`);
    }

    // Bary slot-region boundaries in the ROTATED frame
    // (CalcPlainBaryPtsForTess layout with shareMask 0xf8):
    // [0..2] rotated corners, [3..3+E0) rotated-edge-0 interior,
    // [..) rotated-edge-1 interior, [..) rotated-edge-2 interior,
    // then face interior. E0/E1/E2 were computed above.
    const e0End = 3 + E0;
    const e1End = e0End + E1;
    const e2End = e1End + E2;

    const showOwnership = (colorMode === 'ownership');
    const showTessOrient = (colorMode === 'tessOrient');
    // Ownership flags indexed by ROTATED slot. Rotated corner i ==
    // original corner (i+R)%3; rotated edge K == original edge (K+R)%3.
    const rotOwnC = [
      ownerVert[triIdx*3 + ((0 + rotR) % 3)] === -1,
      ownerVert[triIdx*3 + ((1 + rotR) % 3)] === -1,
      ownerVert[triIdx*3 + ((2 + rotR) % 3)] === -1,
    ];
    const rotOwnE = [
      ownerEdge[triIdx*3 + ((0 + rotR) % 3)] === -1,
      ownerEdge[triIdx*3 + ((1 + rotR) % 3)] === -1,
      ownerEdge[triIdx*3 + ((2 + rotR) % 3)] === -1,
    ];

    // Pre-look-up dominant slots for any non-owned ROTATED slot we'd
    // defer to. Dominants always have a lower triangle index than
    // this tri (first-encountered-wins, iterated in order), and their
    // scratch tables are stored under ORIGINAL keys, so we look them
    // up via the original corner/edge index that this rotated slot
    // corresponds to.
    const rotCornerDomLocal = [-1, -1, -1];
    if (shareVerts) {
      for (let kRot = 0; kRot < 3; kRot++) {
        const origK = (kRot + rotR) % 3;
        const ref = ownerVert[triIdx*3 + origK];
        if (ref === -1) continue;
        const domTri  = (ref / 3) | 0;
        const domSlot = ref % 3;            // ORIGINAL corner index of dom
        rotCornerDomLocal[kRot] = renderData.triCornerPoolSlot[domTri*3 + domSlot];
      }
    }
    const rotEdgeDomFirstLocal = [-1, -1, -1];
    const rotEdgeDomReversed   = [false, false, false];
    if (shareEdges) {
      for (let kRot = 0; kRot < 3; kRot++) {
        const origK = (kRot + rotR) % 3;
        const ref = ownerEdge[triIdx*3 + origK];
        if (ref === -1) continue;
        const domTri  = (ref / 3) | 0;
        const domSlot = ref % 3;            // ORIGINAL edge index of dom
        const domR    = triRotation[domTri];
        // Dom stores its edge interior in its own rotated traversal
        // order; that order is what triEdgeFirstPoolSlot indexes.
        const kRotDom = (domSlot - domR + 3) % 3;
        rotEdgeDomFirstLocal[kRot] = renderData.triEdgeFirstPoolSlot[domTri*3 + domSlot];

        // Compare ROTATED traversal endpoints on both sides.
        const myStart  = cornerPosIdx[triIdx*3 + ((EDGE_START_CORNER[kRot] + rotR) % 3)];
        const myEnd    = cornerPosIdx[triIdx*3 + ((EDGE_END_CORNER  [kRot] + rotR) % 3)];
        const domStart = cornerPosIdx[domTri*3  + ((EDGE_START_CORNER[kRotDom] + domR) % 3)];
        const domEnd   = cornerPosIdx[domTri*3  + ((EDGE_END_CORNER  [kRotDom] + domR) % 3)];
        if (myStart === domStart && myEnd === domEnd) {
          rotEdgeDomReversed[kRot] = false;
        } else if (myStart === domEnd && myEnd === domStart) {
          rotEdgeDomReversed[kRot] = true;
        } else {
          throw new Error(`edge sharing direction mismatch: tri ${triIdx} rotEdge ${kRot} (pos ${myStart}->${myEnd}) vs dom tri ${domTri} edge ${domSlot} rotEdge ${kRotDom} (pos ${domStart}->${domEnd})`);
        }

        const myCount  = (kRot === 0) ? E0 : (kRot === 1) ? E1 : E2;
        const domCount = renderData.triEdgeVertCount[domTri*3 + domSlot];
        if (myCount !== domCount) {
          throw new Error(`edge sharing count mismatch: tri ${triIdx} rotEdge ${kRot} (${myCount}) vs dom tri ${domTri} edge ${domSlot} (${domCount})`);
        }
      }
    }

    // Walk every face-local rotated slot. Either bump-allocate (own)
    // and write bary/triId/color into the new pool slot, or look up
    // the dominant's mesh-local slot. remap[s] gives the mesh-local
    // pool slot that this triangle's (rotated) index list should
    // reference for rotated face-local slot s.
    //
    // Fast path: when skipPerSlotLoop is true (caller guarantees
    // sharing is off + the tri*PoolSlot/tri*Edge* fields are not
    // needed downstream), the only effect of the per-slot loop is
    // to bump pool.vertexCursor nv times. Replace with one += nv
    // and skip the entire loop + the per-srcTri triCornerPoolSlot
    // / triEdgeFirstPoolSlot / triEdgeVertCount writes. This drops
    // the tess-scaling O(numSrcTris * nv) work down to O(numSrcTris)
    // -- huge win in gpu_only mode where pool.* and these fields
    // are validation-only.
    let remap;
    if (skipPerSlotLoop) {
      pool.vertexCursor += nv;
    } else {
    remap = new Int32Array(nv);
    for (let s = 0; s < nv; s++) {
      let isOwn   = true;
      let domLocal = -1;

      if (s < 3) {
        if (shareVerts && !rotOwnC[s]) {
          isOwn = false;
          domLocal = rotCornerDomLocal[s];
        }
      } else if (s < e0End) {
        if (shareEdges && !rotOwnE[0]) {
          isOwn = false;
          const off = s - 3;
          const eff = rotEdgeDomReversed[0] ? (E0 - 1 - off) : off;
          domLocal = rotEdgeDomFirstLocal[0] + eff;
        }
      } else if (s < e1End) {
        if (shareEdges && !rotOwnE[1]) {
          isOwn = false;
          const off = s - e0End;
          const eff = rotEdgeDomReversed[1] ? (E1 - 1 - off) : off;
          domLocal = rotEdgeDomFirstLocal[1] + eff;
        }
      } else if (s < e2End) {
        if (shareEdges && !rotOwnE[2]) {
          isOwn = false;
          const off = s - e1End;
          const eff = rotEdgeDomReversed[2] ? (E2 - 1 - off) : off;
          domLocal = rotEdgeDomFirstLocal[2] + eff;
        }
      }
      // else: face interior -- always owned.

      if (isOwn) {
        const slotPool  = pool.vertexCursor;
        const slotLocal = slotPool - startVtx;
        // skipPerSlotLoop fast-path bypasses this branch entirely
        // (the outer "if (skipPerSlotLoop)" branch took us elsewhere);
        // we still keep skipPoolWrites for the existing CPU-Pass-A
        // skip that runs in compare mode when GPU is doing Pass B.
        if (!skipPoolWrites) {
          // Convert rotated bary (x_rot, y_rot) -> original bary so
          // Pass B can evaluate the Phong tessellation patch unchanged.
          // Rotated weights: w0_rot = 1 - x - y, w1_rot = y, w2_rot = x.
          // Original weight i = rotated weight (i - R + 3) % 3 mapping.
          // Then x_orig = w2_orig, y_orig = w1_orig.
          const xr = baryPts[s][0];
          const yr = baryPts[s][1];
          let xo, yo;
          if      (rotR === 0) { xo = xr;             yo = yr;             }
          else if (rotR === 1) { xo = yr;             yo = 1 - xr - yr;    }
          else                 { xo = 1 - xr - yr;    yo = xr;             }
          pool.vertexBary[slotPool] = pack2x16unorm(xo, yo);
          pool.vertexTriId[slotPool] = triIdx;
          // Per-vertex debug color: ownership / tessOrient encode
          // per-slot ownership / rotation; solid + pbr modes don't
          // read color in the surface shader so we skip the write.
          if (showOwnership) {
            let col;
            if      (s === 0)     col = rotOwnC[0] ? COL_VERT_OWN : COL_VERT_DEF;
            else if (s === 1)     col = rotOwnC[1] ? COL_VERT_OWN : COL_VERT_DEF;
            else if (s === 2)     col = rotOwnC[2] ? COL_VERT_OWN : COL_VERT_DEF;
            else if (s < e0End)   col = rotOwnE[0] ? COL_EDGE_OWN : COL_EDGE_DEF;
            else if (s < e1End)   col = rotOwnE[1] ? COL_EDGE_OWN : COL_EDGE_DEF;
            else if (s < e2End)   col = rotOwnE[2] ? COL_EDGE_OWN : COL_EDGE_DEF;
            else                  col = COL_INT;
            pool.clusterColor[slotPool*4 + 0] = col[0];
            pool.clusterColor[slotPool*4 + 1] = col[1];
            pool.clusterColor[slotPool*4 + 2] = col[2];
            pool.clusterColor[slotPool*4 + 3] = 255;
          } else if (showTessOrient) {
            let col;
            if      (s < 3)       col = COL_ROT_CORNER;
            else if (s < e0End)   col = COL_ROT_E0;
            else if (s < e1End)   col = COL_ROT_E1;
            else if (s < e2End)   col = COL_ROT_E2;
            else                  col = COL_INT;
            pool.clusterColor[slotPool*4 + 0] = col[0];
            pool.clusterColor[slotPool*4 + 1] = col[1];
            pool.clusterColor[slotPool*4 + 2] = col[2];
            pool.clusterColor[slotPool*4 + 3] = 255;
          }
        }
        pool.vertexCursor++;
        remap[s] = slotLocal;
      } else {
        remap[s] = domLocal;
      }
    }

    // Scratch tables: record this tri's actual mesh-local slots
    // keyed by ORIGINAL corner / edge index. Downstream tris always
    // look up by original index (their ownership refs are in original
    // indexing), so writing under the original key lets them read
    // directly without any rotation translation. Rotated slot K stores
    // original edge (K + R) % 3; rotated corner i stores original
    // corner (i + R) % 3.
    renderData.triCornerPoolSlot[triIdx*3 + ((0 + rotR) % 3)]    = remap[0];
    renderData.triCornerPoolSlot[triIdx*3 + ((1 + rotR) % 3)]    = remap[1];
    renderData.triCornerPoolSlot[triIdx*3 + ((2 + rotR) % 3)]    = remap[2];
    // triEdgeFirstPoolSlot must store the FIRST interior pool slot of
    // the edge, NOT remap[3] (which equals dom's *last* slot when this
    // tri is deferring + traversing in reverse: remap walks
    // dom_first+E-1 .. dom_first+0 as s steps through 3..3+E-1).
    // verifyRenderDataAfterTopology + downstream weld walkers all
    // assume tefps + 0..E-1 covers the edge interior; storing dom's
    // first slot keeps that invariant intact regardless of direction.
    const e0First = (E0 === 0) ? -1
      : ((shareEdges && !rotOwnE[0]) ? rotEdgeDomFirstLocal[0] : remap[3]);
    const e1First = (E1 === 0) ? -1
      : ((shareEdges && !rotOwnE[1]) ? rotEdgeDomFirstLocal[1] : remap[e0End]);
    const e2First = (E2 === 0) ? -1
      : ((shareEdges && !rotOwnE[2]) ? rotEdgeDomFirstLocal[2] : remap[e1End]);
    renderData.triEdgeFirstPoolSlot[triIdx*3 + ((0 + rotR) % 3)] = e0First;
    renderData.triEdgeFirstPoolSlot[triIdx*3 + ((1 + rotR) % 3)] = e1First;
    renderData.triEdgeFirstPoolSlot[triIdx*3 + ((2 + rotR) % 3)] = e2First;
    renderData.triEdgeVertCount[triIdx*3 + ((0 + rotR) % 3)]     = E0;
    renderData.triEdgeVertCount[triIdx*3 + ((1 + rotR) % 3)]     = E1;
    renderData.triEdgeVertCount[triIdx*3 + ((2 + rotR) % 3)]     = E2;
    } // end !skipPerSlotLoop

    // Per-prim emission, with optional skipping of Type 2 sections.
    // Type 2 splits prims into [0, numInteriorPrims) interior (the
    // parallelogram grid) and [numInteriorPrims, numPrims) gap (the
    // stitch strip). The toggles let us see each section alone.
    const triType = r.triangleType;
    const numInteriorPrims = (triType === 2)
      ? TessLookup.LookupInteriorNumPrims(r.token0.m_table, r.token2.m_table)
      : 0;
    const gapNumPrims = (triType === 2) ? (numTri - numInteriorPrims) : 0;
    if      (triType === 0) renderData.numType0++;
    else if (triType === 1) renderData.numType1++;
    else                    renderData.numType2++;
    renderData.numType2Interior += numInteriorPrims;
    renderData.numType2Gap      += gapNumPrims;
    let emitted = 0;
    if (skipPoolWrites) {
      // Just count emitted prims so triCursor advances correctly. The
      // render path consumes gpuOutA3cTriIndices in gpu_only mode, so
      // the pool index array is a dead write.
      if (triType === 2) {
        if (emitParallelogram) emitted += numInteriorPrims;
        if (emitGap)           emitted += gapNumPrims;
      } else {
        emitted = numTri;
      }
    } else {
      for (let p = 0; p < numTri; p++) {
        if (triType === 2) {
          const isInterior = p < numInteriorPrims;
          if ( isInterior && !emitParallelogram) continue;
          if (!isInterior && !emitGap)           continue;
        }
        const i0 = remap[tessIdx[p*3 + 0]];
        const i1 = remap[tessIdx[p*3 + 1]];
        const i2 = remap[tessIdx[p*3 + 2]];
        pool.triIndices[(triOff + emitted)*3 + 0] = i0;
        pool.triIndices[(triOff + emitted)*3 + 1] = i1;
        pool.triIndices[(triOff + emitted)*3 + 2] = i2;
        emitted++;
      }
    }

    pool.triCursor  += emitted;
  }

  renderData.vertexOffset = startVtx;
  renderData.vertexCount  = pool.vertexCursor - startVtx;
  renderData.triOffset    = startTri;
  renderData.triCount     = pool.triCursor    - startTri;
}


// =====================================================================
// Cheap post-Pass-A check on the scratch tables. In stage 1 every
// triangle owns everything, so every slot must point into the mesh's
// fresh allocation [0, vertexCount). In later stages deferring slots
// will hold the dominant's slot, which still satisfies that range.
// Crashes loudly on any out-of-range value.
// =====================================================================
function verifyRenderDataAfterTopology(mesh) {
  const r  = mesh.renderData;
  const nT = mesh.builtData.meshInfo.numTriangles;
  for (let t = 0; t < nT; t++) {
    for (let k = 0; k < 3; k++) {
      const cs = r.triCornerPoolSlot[t*3 + k];
      if (cs < 0 || cs >= r.vertexCount) {
        throw new Error(`triCornerPoolSlot[t=${t},k=${k}] = ${cs} out of [0, ${r.vertexCount})`);
      }
      const es = r.triEdgeFirstPoolSlot[t*3 + k];
      const ec = r.triEdgeVertCount[t*3 + k];
      if (ec < 0) throw new Error(`triEdgeVertCount[t=${t},k=${k}] = ${ec} (negative)`);
      if (ec > 0 && (es < 0 || es + ec > r.vertexCount)) {
        throw new Error(`triEdgeFirstPoolSlot[t=${t},k=${k}] = ${es} count=${ec} out of [0, ${r.vertexCount})`);
      }
    }
  }
}


// Bilinear sample of a 4-channel ImageData treated as a grayscale
// height map (R channel). UVs are OBJ convention (v=0 at bottom);
// ImageData rows are top-down, so we flip y to match. Repeat-wrap.
function sampleDisplacement(imgData, u, v) {
  const w = imgData.width, h = imgData.height;
  const data = imgData.data;
  let xf = u * w;
  let yf = (1 - v) * h;
  xf = xf - Math.floor(xf / w) * w;
  yf = yf - Math.floor(yf / h) * h;
  const x0 = Math.floor(xf), y0 = Math.floor(yf);
  const x1 = (x0 + 1) % w,   y1 = (y0 + 1) % h;
  const fx = xf - x0,        fy = yf - y0;
  const v00 = data[(y0 * w + x0) * 4];
  const v10 = data[(y0 * w + x1) * 4];
  const v01 = data[(y1 * w + x0) * 4];
  const v11 = data[(y1 * w + x1) * 4];
  const top = v00 * (1 - fx) + v10 * fx;
  const bot = v01 * (1 - fx) + v11 * fx;
  return (top * (1 - fy) + bot * fy) / 255;
}


// =====================================================================
// Pass B -- vertex transform AND displacement.
//
// Phong-tessellation eval per vertex (position + normal), barycentric UV interp, then
// optional height-map displacement folded into the position write. The
// displacement step is a no-op when amount is 0 or imgData is null --
// no-op being free is important because the harness validates B against
// the post-displacement snapshot. There is no longer a separate Pass C.
// =====================================================================
function runVertexPass(mesh, pool, centroidLerp, displacement, dispImageData,
                       dispEdgeFalloff, vertexFalloffArr, edgeTessFactors, forceFlatCc,
                       vertexCornerRadiusArr, dispEdgeFalloffNrm,
                       ccNrmOffset, ccNrmScale) {
  const { builtData, renderData, topology } = mesh;
  const phongSetups = builtData.phongSetups;
  const triUvs      = builtData.triUvs;
  // cornerPosIdx is per-source-tri; 3 ints (one per corner) into the
  // source mesh's position list. Used by the falloff lookup.
  const cornerPosIdx = builtData.cornerPosIdx;

  const start = renderData.vertexOffset;
  const count = renderData.vertexCount;
  // Debug aid: when centroidLerp > 0 we drag every tess vert's bary
  // toward its source tri's centroid (1/3, 1/3, 1/3). At t=1 every
  // vert in a source tri lands on the centroid, so when sharing turns
  // on the verts that are *supposed* to be shared between two tris
  // will visibly fail to coincide if the dedup is wrong.
  const t = centroidLerp || 0;
  const dispOn = dispImageData && displacement !== 0;
  // Per-source-mesh-vertex displacement-falloff multiplier. When the
  // toggle is on and vertexFalloffArr is provided, the per-vertex scale
  // attenuates BOTH the Phong / sphere curvature AND the displacement
  // push (computed inline per vertex below). Mirrors PASS_B_WGSL.
  // tessMode 'sphere' replaces Phong-tess with analytic projection
  // onto the unit sphere centered at origin (local space): linear-
  // interpolate corner positions, normalize, and let the normal
  // equal the (unit-length) projected position. Used by the
  // octahedron mesh so the 8 source faces tessellate into a true
  // sphere instead of a Phong-rounded approximation.
  const sphereMode = topology && topology.tessMode === 'sphere';
  // CC mode: when topology carries a ccState (set by setCcScene), use
  // the Catmull-Clark limit-surface evaluator (cc_render.js) instead
  // of Phong. The ccState's triPatchMap is indexed by source-tri ID
  // produced by buildBuiltData's fan-triangulation, which matches
  // amolite's poly-fan ordering. AMOLITE_DEFERRED_FIXES "numAdded > 1"
  // throws are swallowed back to the linear (flat-tri) fallback so a
  // small handful of irregular slots don't blank out the whole mesh.
  const ccState = topology && topology.ccState;

  for (let i = 0; i < count; i++) {
    const vIdx = start + i;
    const packedXY = pool.vertexBary[vIdx];
    const x = unpack2x16unorm_x(packedXY);
    const y = unpack2x16unorm_y(packedXY);
    let u = 1 - x - y, v = y, w = x;
    if (t > 0) {
      u = u + (1/3 - u) * t;
      v = v + (1/3 - v) * t;
      w = w + (1/3 - w) * t;
    }
    const triId = pool.vertexTriId[vIdx];
    const ps  = phongSetups[triId];
    // Per-vertex falloff scale. When the toggle is on, attenuates BOTH
    // Phong/sphere curvature AND the displacement push so low-tess
    // edges degrade to the flat linear-interp triangle.
    let dispScale = 1.0;
    if (dispEdgeFalloff && vertexFalloffArr) {
      const c0 = cornerPosIdx[triId * 3 + 0];
      const c1 = cornerPosIdx[triId * 3 + 1];
      const c2 = cornerPosIdx[triId * 3 + 2];
      dispScale = u * vertexFalloffArr[c0]
                + v * vertexFalloffArr[c1]
                + w * vertexFalloffArr[c2];
    }
    // Linear (flat-triangle) position + smooth corner-interp normal.
    // pos lerps pl -> curved by dispScale; normal stays smooth for
    // Phong, lerps smooth -> radial for sphere.
    const plx = u*ps.p0[0] + v*ps.p1[0] + w*ps.p2[0];
    const ply = u*ps.p0[1] + v*ps.p1[1] + w*ps.p2[1];
    const plz = u*ps.p0[2] + v*ps.p1[2] + w*ps.p2[2];
    const nrmLin = phongEvalNrm(ps, u, v, w);
    let posX, posY, posZ, nrmX, nrmY, nrmZ;
    if (sphereMode) {
      const slen = Math.hypot(plx, ply, plz) || 1;
      const sx = plx / slen, sy = ply / slen, sz = plz / slen;
      posX = plx + (sx - plx) * dispScale;
      posY = ply + (sy - ply) * dispScale;
      posZ = plz + (sz - plz) * dispScale;
      // Normal lerps smooth corner-interp -> radial.
      const nx = nrmLin[0] + (sx - nrmLin[0]) * dispScale;
      const ny = nrmLin[1] + (sy - nrmLin[1]) * dispScale;
      const nz = nrmLin[2] + (sz - nrmLin[2]) * dispScale;
      const nlen = Math.hypot(nx, ny, nz);
      if (nlen > 1e-20) { nrmX = nx / nlen; nrmY = ny / nlen; nrmZ = nz / nlen; }
      else              { nrmX = nrmLin[0]; nrmY = nrmLin[1]; nrmZ = nrmLin[2]; }
    } else if (ccState) {
      let ccP = null, ccN = null;
      try {
        const r = ccEvalPosNrm(ccState, triId, u, v, w);
        ccP = r.P; ccN = r.N;
      } catch (e) {
        ccP = null; ccN = null;
      }
      if (ccP) {
        // Flat baseline uses CC LIMIT P/N at this rendered tri's 3
        // canonical corners. Per-tri ccCornerSrcVert (3 u32/tri, static
        // uvBasis->poly convention) maps each tri corner to its source
        // vert id. Per-vert limByVertP/N (cc.P at corner; differs from
        // ccState.limitPosData for creased verts -- the stencil limit
        // diverges from patch-eval at corner there). Both cached on the
        // mesh. cornerPosIdx is stale for CC mode so we can't reuse it.
        // At corners flat == cc so the blend is a no-op there.
        let cccsv = mesh._ccCornerSrcVertCache;
        let cclbp = mesh._ccLimByVertPCache;
        let cclbn = mesh._ccLimByVertNCache;
        if (!cccsv) {
          const UV_TO_POLY = {
            '0,0,0,1,1,1': [0, 1, 2],
            '0,0,1,1,1,0': [0, 2, 3],
            '0,0,0,1,1,0': [0, 1, 2],
          };
          const numTris    = ccState.triPatchMap.length;
          const numSrcVerts= (ccState.mesh.m_posData.length / 3) | 0;
          cccsv = new Uint32Array(numTris * 3);
          cclbp = new Float32Array(numSrcVerts * 3);
          cclbn = new Float32Array(numSrcVerts * 3);
          const polySz  = ccState.mesh.m_polySizes;
          const polyIdx = ccState.mesh.m_polyIndexList;
          const polyStart = new Int32Array(polySz.length);
          let cum = 0;
          for (let i = 0; i < polySz.length; i++) { polyStart[i] = cum; cum += polySz[i]; }
          const filled = new Uint8Array(numSrcVerts);
          const tpm = ccState.triPatchMap;
          for (let tt = 0; tt < numTris; tt++) {
            const e = tpm[tt];
            const map = UV_TO_POLY[e.uvBasis.join(',')];
            if (!map) throw new Error('runVertexPass: unknown uvBasis ' + JSON.stringify(e.uvBasis));
            const ps = polyStart[e.patchIter];
            const pn = polySz[e.patchIter];
            for (let kk = 0; kk < 3; kk++) {
              const polyOff = map[kk];
              if (polyOff >= pn) { cccsv[tt*3+kk] = 0; continue; }
              const srcV = polyIdx[ps + polyOff];
              cccsv[tt*3+kk] = srcV >>> 0;
              if (!filled[srcV]) {
                const uu = kk===0?1:0, vv = kk===1?1:0, ww = kk===2?1:0;
                const r = ccEvalPosNrm(ccState, tt, uu, vv, ww);
                cclbp[srcV*3+0]=r.P[0]; cclbp[srcV*3+1]=r.P[1]; cclbp[srcV*3+2]=r.P[2];
                cclbn[srcV*3+0]=r.N[0]; cclbn[srcV*3+1]=r.N[1]; cclbn[srcV*3+2]=r.N[2];
                filled[srcV] = 1;
              }
            }
          }
          mesh._ccCornerSrcVertCache = cccsv;
          mesh._ccLimByVertPCache    = cclbp;
          mesh._ccLimByVertNCache    = cclbn;
        }
        const sv0 = cccsv[triId * 3 + 0];
        const sv1 = cccsv[triId * 3 + 1];
        const sv2 = cccsv[triId * 3 + 2];
        // Quadratic Bernstein blend (mirrors the WGSL CC branch). Six
        // weights summing to 1: 3 corner + 3 edge. On a shared edge,
        // only the 2 corner weights of that edge and the shared-edge
        // weight survive -- all per-vertex / per-edge globals, so the
        // blend is crack-free across adjacent tris.
        //
        // VALIDATION: c_K = 1.0 placeholder. Replace with a real
        // per-source-vertex quantity once cracks are confirmed gone.
        let tCCPos = 1.0;
        let tCCNrm = 1.0;
        // Compute the Bernstein blend + corner mask only if either
        // toggle is on -- it's the same blend either way; the toggles
        // just select whether pos / nrm consume it or stay raw (1.0).
        if ((dispEdgeFalloff || dispEdgeFalloffNrm) && edgeTessFactors) {
          const triEdgeIds = builtData.triEdgeIds;
          const f0 = edgeTessFactors[triEdgeIds[triId * 3 + 0]];
          const f1 = edgeTessFactors[triEdgeIds[triId * 3 + 1]];
          const f2 = edgeTessFactors[triEdgeIds[triId * 3 + 2]];
          const e0p = Math.max(0, Math.min(1, (f0 - 0.5) * 2));
          const e1p = Math.max(0, Math.min(1, (f1 - 0.5) * 2));
          const e2p = Math.max(0, Math.min(1, (f2 - 0.5) * 2));
          const e0n = Math.max(0, Math.min(1, (f0 - 1) * 0.5));
          const e1n = Math.max(0, Math.min(1, (f1 - 1) * 0.5));
          const e2n = Math.max(0, Math.min(1, (f2 - 1) * 0.5));
          const c0c = 1.0, c1c = 1.0, c2c = 1.0;
          const cw0 = u * u, cw1 = v * v, cw2 = w * w;
          const ew0 = 2 * u * v, ew1 = 2 * v * w, ew2 = 2 * w * u;
          const tBlendP    = cw0*c0c + cw1*c1c + cw2*c2c + ew0*e0p + ew1*e1p + ew2*e2p;
          const tBlendNRaw = cw0*c0c + cw1*c1c + cw2*c2c + ew0*e0n + ew1*e1n + ew2*e2n;
          // Dead-range + scale on merged normal-T value (see WGSL).
          const nOff   = (ccNrmOffset === undefined) ? 0   : ccNrmOffset;
          const nScale = (ccNrmScale  === undefined) ? 1.0 : ccNrmScale;
          const tBlendN = Math.max(0, Math.min(1, (tBlendNRaw - nOff) * nScale));
          let cornerMask = 1.0;
          if (vertexCornerRadiusArr) {
            const cr0 = vertexCornerRadiusArr[sv0];
            const cr1 = vertexCornerRadiusArr[sv1];
            const cr2 = vertexCornerRadiusArr[sv2];
            const m0 = Math.max(0, Math.min(1, (1 - u) / Math.max(cr0, 1e-20)));
            const m1 = Math.max(0, Math.min(1, (1 - v) / Math.max(cr1, 1e-20)));
            const m2 = Math.max(0, Math.min(1, (1 - w) / Math.max(cr2, 1e-20)));
            cornerMask = Math.min(m0, Math.min(m1, m2));
          }
          const blendedP = tBlendP * cornerMask;
          const blendedN = tBlendN * cornerMask;
          if (dispEdgeFalloff)    tCCPos = blendedP;
          if (dispEdgeFalloffNrm) tCCNrm = blendedN;
        }
        if (forceFlatCc) { tCCPos = 0; tCCNrm = 0; }
        const lp0 = [cclbp[sv0*3+0], cclbp[sv0*3+1], cclbp[sv0*3+2]];
        const lp1 = [cclbp[sv1*3+0], cclbp[sv1*3+1], cclbp[sv1*3+2]];
        const lp2 = [cclbp[sv2*3+0], cclbp[sv2*3+1], cclbp[sv2*3+2]];
        const ln0 = [cclbn[sv0*3+0], cclbn[sv0*3+1], cclbn[sv0*3+2]];
        const ln1 = [cclbn[sv1*3+0], cclbn[sv1*3+1], cclbn[sv1*3+2]];
        const ln2 = [cclbn[sv2*3+0], cclbn[sv2*3+1], cclbn[sv2*3+2]];
        const plLx = u*lp0[0] + v*lp1[0] + w*lp2[0];
        const plLy = u*lp0[1] + v*lp1[1] + w*lp2[1];
        const plLz = u*lp0[2] + v*lp1[2] + w*lp2[2];
        let nlinLx = u*ln0[0] + v*ln1[0] + w*ln2[0];
        let nlinLy = u*ln0[1] + v*ln1[1] + w*ln2[1];
        let nlinLz = u*ln0[2] + v*ln1[2] + w*ln2[2];
        const nlinLLen = Math.hypot(nlinLx, nlinLy, nlinLz);
        if (nlinLLen > 1e-20) { nlinLx /= nlinLLen; nlinLy /= nlinLLen; nlinLz /= nlinLLen; }
        else                  { nlinLx = 0; nlinLy = 0; nlinLz = 1; }

        const cpx = plLx + (ccP[0] - plLx) * tCCPos;
        const cpy = plLy + (ccP[1] - plLy) * tCCPos;
        const cpz = plLz + (ccP[2] - plLz) * tCCPos;
        const nmx = nlinLx + (ccN[0] - nlinLx) * tCCNrm;
        const nmy = nlinLy + (ccN[1] - nlinLy) * tCCNrm;
        const nmz = nlinLz + (ccN[2] - nlinLz) * tCCNrm;
        const nlen = Math.hypot(nmx, nmy, nmz);
        posX = cpx; posY = cpy; posZ = cpz;
        if (nlen > 1e-20) { nrmX = nmx / nlen; nrmY = nmy / nlen; nrmZ = nmz / nlen; }
        else              { nrmX = nlinLx; nrmY = nlinLy; nrmZ = nlinLz; }
      } else {
        posX = plx; posY = ply; posZ = plz;
        nrmX = nrmLin[0]; nrmY = nrmLin[1]; nrmZ = nrmLin[2];
      }
    } else {
      const phong = phongEvalPos(ps, u, v, w);
      posX = plx + (phong[0] - plx) * dispScale;
      posY = ply + (phong[1] - ply) * dispScale;
      posZ = plz + (phong[2] - plz) * dispScale;
      nrmX = nrmLin[0]; nrmY = nrmLin[1]; nrmZ = nrmLin[2];
    }
    const u0x = triUvs[triId*6 + 0], u0y = triUvs[triId*6 + 1];
    const u1x = triUvs[triId*6 + 2], u1y = triUvs[triId*6 + 3];
    const u2x = triUvs[triId*6 + 4], u2y = triUvs[triId*6 + 5];
    const uvX = u*u0x + v*u1x + w*u2x;
    const uvY = u*u0y + v*u1y + w*u2y;
    if (dispOn) {
      const h = sampleDisplacement(dispImageData, uvX, uvY);
      // dispScale already attenuated pos toward flat above; reuse it
      // so the push goes to zero on the same schedule.
      const push = (h - 0.5) * displacement * dispScale;
      posX += nrmX * push;
      posY += nrmY * push;
      posZ += nrmZ * push;
    }
    pool.positions[vIdx*3 + 0] = posX;
    pool.positions[vIdx*3 + 1] = posY;
    pool.positions[vIdx*3 + 2] = posZ;
    pool.normals  [vIdx*3 + 0] = nrmX;
    pool.normals  [vIdx*3 + 1] = nrmY;
    pool.normals  [vIdx*3 + 2] = nrmZ;
    pool.texcoords[vIdx*2 + 0] = uvX;
    pool.texcoords[vIdx*2 + 1] = uvY;
  }
}


// =====================================================================
// Pass D -- welding.
//
// For each weldable vertex set: collect the unique pool slots that
// represent the same world-space position (after Pass A's sharing has
// dedup'd within each cluster), then average their positions and
// normals and write the average back to all of them. Eliminates the
// cracks that displacement / centroid-lerp / UV-seam differences
// introduce at cluster and seam boundaries.
//
// For each weldable edge: walk each interior offset i along the edge,
// collect the unique pool slots across members (with direction so a
// reversed-traversal member's offset i maps to E-1-i), average, write
// back. Edge interior counts must match across the set; mismatch is a
// hard error.
// =====================================================================
function runWeldPass(mesh, pool, weldVerts, weldEdges) {
  if (!weldVerts && !weldEdges) return;
  const builtData = mesh.builtData;
  const r = mesh.renderData;
  const startVtx = r.vertexOffset;
  const tcps = r.triCornerPoolSlot;
  const tefps = r.triEdgeFirstPoolSlot;
  const tevc = r.triEdgeVertCount;
  // No owners-only filter. With sharing-on the non-owners' tcps
  // already point to the owner's pool slot so the slot-dedup below
  // catches them. With sharing-off (gpu_only fast path) every
  // non-owner has its OWN pool slot that needs welding -- the old
  // owner filter silently dropped those, leaving cracks at every
  // shared corner / edge.

  // Vertex welding.
  const wv = weldVerts ? builtData.weldableVerts : [];
  for (let g = 0; g < wv.length; g++) {
    const refs = wv[g];
    const slots = [];
    const seen = new Set();
    for (let i = 0; i < refs.length; i++) {
      const ref = refs[i];
      const tri = (ref / 3) | 0;
      const corner = ref - tri * 3;
      const slot = tcps[tri*3 + corner];
      if (!seen.has(slot)) { seen.add(slot); slots.push(slot); }
    }
    if (slots.length < 2) continue;
    let px=0, py=0, pz=0, nx=0, ny=0, nz=0;
    for (let i = 0; i < slots.length; i++) {
      const ps = startVtx + slots[i];
      px += pool.positions[ps*3 + 0]; py += pool.positions[ps*3 + 1]; pz += pool.positions[ps*3 + 2];
      nx += pool.normals  [ps*3 + 0]; ny += pool.normals  [ps*3 + 1]; nz += pool.normals  [ps*3 + 2];
    }
    const inv = 1 / slots.length;
    px *= inv; py *= inv; pz *= inv;
    const nl = Math.sqrt(nx*nx + ny*ny + nz*nz) || 1;
    nx /= nl; ny /= nl; nz /= nl;
    for (let i = 0; i < slots.length; i++) {
      const ps = startVtx + slots[i];
      pool.positions[ps*3 + 0] = px; pool.positions[ps*3 + 1] = py; pool.positions[ps*3 + 2] = pz;
      pool.normals  [ps*3 + 0] = nx; pool.normals  [ps*3 + 1] = ny; pool.normals  [ps*3 + 2] = nz;
    }
  }

  // Edge welding. (Same no-owner-filter rationale as the vertex
  // phase -- slot dedup below catches sharing-on duplicates.)
  const we = weldEdges ? builtData.weldableEdges : [];
  for (let g = 0; g < we.length; g++) {
    const refs = we[g].refs;
    const dirs = we[g].dirs;
    const owners = [];
    let E = -1;
    for (let m = 0; m < refs.length; m++) {
      const tri  = (refs[m] / 3) | 0;
      const slot = refs[m] - tri * 3;
      const myE = tevc[tri*3 + slot];
      if (E === -1) E = myE;
      else if (myE !== E) throw new Error(`weldable edge: count mismatch ${myE} vs ${E} at tri ${tri} edge ${slot}`);
      owners.push({ tri, slot, dir: dirs[m] });
    }
    if (owners.length < 2 || E <= 0) continue;

    for (let i = 0; i < E; i++) {
      const slots = [];
      const seen = new Set();
      for (let m = 0; m < owners.length; m++) {
        const o   = owners[m];
        const off = o.dir === 0 ? i : (E - 1 - i);
        const s   = tefps[o.tri*3 + o.slot] + off;
        if (!seen.has(s)) { seen.add(s); slots.push(s); }
      }
      if (slots.length < 2) continue;
      let px=0, py=0, pz=0, nx=0, ny=0, nz=0;
      for (let s = 0; s < slots.length; s++) {
        const ps = startVtx + slots[s];
        px += pool.positions[ps*3 + 0]; py += pool.positions[ps*3 + 1]; pz += pool.positions[ps*3 + 2];
        nx += pool.normals  [ps*3 + 0]; ny += pool.normals  [ps*3 + 1]; nz += pool.normals  [ps*3 + 2];
      }
      const inv = 1 / slots.length;
      px *= inv; py *= inv; pz *= inv;
      const nl = Math.sqrt(nx*nx + ny*ny + nz*nz) || 1;
      nx /= nl; ny /= nl; nz /= nl;
      for (let s = 0; s < slots.length; s++) {
        const ps = startVtx + slots[s];
        pool.positions[ps*3 + 0] = px; pool.positions[ps*3 + 1] = py; pool.positions[ps*3 + 2] = pz;
        pool.normals  [ps*3 + 0] = nx; pool.normals  [ps*3 + 1] = ny; pool.normals  [ps*3 + 2] = nz;
      }
    }
  }
}


// =====================================================================
// Diagnostic: measure the maximum world-space spread across each
// weldable set's owner-only members. "Spread" = max distance from the
// set's mean. At displacement = 0 with consistent Phong-tess evaluation the
// pre-weld spread should be near-zero across all sets; a high value
// before the weld pass means the welder is grouping the wrong slots.
// Run before AND after runWeldPass to verify the welder converged.
// =====================================================================
// Recompute per-vertex smooth normals for CC mode from the SOURCE
// MESH topology applied to amolite's CC limit positions. Computes
// area-weighted face normals per source polygon (face normal of the
// CC limit positions of its corners), accumulates them at each
// source vertex, then writes the normalized smooth normal to every
// pool slot at that source vertex's world position.
//
// This is TESS-INVARIANT: the smooth normal at a source vertex
// depends only on the source mesh topology + amolite's CC limit
// positions, neither of which change with tess factor. So the
// rendered normals at source verts are stable across tess factor
// transitions (3.0 -> 3.1 -> 4.0). Interior tess verts (which only
// appear at higher tess) keep amolite's CC limit normal -- those
// are within a single source patch and don't have neighbors to
// average against in the source topology.
//
// Why source mesh face normals: amolite's CC limit normals disagree
// with the actual surface shape by 18-120 degrees at many vertices
// (CC_FiniteDiffNormal). Using source mesh smooth normals computed
// from CC limit positions matches phong's stable shading approach
// while staying consistent with the rendered geometry's winding.
function recomputeCCSmoothNormals(mesh, pool) {
  const r = mesh.renderData;
  const v0 = r.vertexOffset;
  const P = pool.positions;
  const N = pool.normals;
  const ccState = mesh.topology && mesh.topology.ccState;
  if (!ccState) return;
  // Compute the per-source-vert smooth normal once -- the result
  // depends only on source mesh topology + CC limit positions, both
  // tess-invariant. Cache on the ccState so consecutive calls reuse.
  if (!ccState._cachedSmoothNormalEpoch || ccState._cachedSmoothNormalEpoch !== ccState.frameEpoch) {
    const limitPos = ccState.limitPosData;
    const polySizes = ccState.mesh.m_polySizes;
    const polyIdx = ccState.mesh.m_polyIndexList;
    const vi = ccState.patches.m_vertexInfo;
    const nSrcVerts = vi.length;
    const accumNX = new Float32Array(nSrcVerts);
    const accumNY = new Float32Array(nSrcVerts);
    const accumNZ = new Float32Array(nSrcVerts);
    // For each source polygon, fan-triangulate its corners using CC
    // limit positions, accumulate area-weighted face normals onto
    // each source vert in the poly's corners.
    let off = 0;
    for (let p = 0; p < polySizes.length; p++) {
      const ps = polySizes[p];
      // limitStartIndex per corner -- gives the index into limitPos.
      const corners = new Array(ps);
      for (let i = 0; i < ps; i++) {
        const v = polyIdx[off + i];
        const slot = vi[v].m_limitStartIndex;
        corners[i] = { v, P: limitPos[slot] };
      }
      // Fan-triangulate around corner 0; accumulate per-tri face
      // normal onto all 3 corners' source verts.
      for (let i = 1; i < ps - 1; i++) {
        const a = corners[0].P, b = corners[i].P, c = corners[i+1].P;
        if (!a || !b || !c) continue;
        const ex1 = b[0]-a[0], ey1 = b[1]-a[1], ez1 = b[2]-a[2];
        const ex2 = c[0]-a[0], ey2 = c[1]-a[1], ez2 = c[2]-a[2];
        const fx = ey1*ez2 - ez1*ey2;
        const fy = ez1*ex2 - ex1*ez2;
        const fz = ex1*ey2 - ey1*ex2;
        accumNX[corners[0].v] += fx; accumNY[corners[0].v] += fy; accumNZ[corners[0].v] += fz;
        accumNX[corners[i].v] += fx; accumNY[corners[i].v] += fy; accumNZ[corners[i].v] += fz;
        accumNX[corners[i+1].v] += fx; accumNY[corners[i+1].v] += fy; accumNZ[corners[i+1].v] += fz;
      }
      off += ps;
    }
    // Normalize, cache.
    const srcNX = new Float32Array(nSrcVerts);
    const srcNY = new Float32Array(nSrcVerts);
    const srcNZ = new Float32Array(nSrcVerts);
    for (let v = 0; v < nSrcVerts; v++) {
      const len = Math.sqrt(accumNX[v]*accumNX[v] + accumNY[v]*accumNY[v] + accumNZ[v]*accumNZ[v]);
      if (len > 1e-20) {
        srcNX[v] = accumNX[v] / len;
        srcNY[v] = accumNY[v] / len;
        srcNZ[v] = accumNZ[v] / len;
      }
    }
    // Also cache the source vert limit positions for fast nearest
    // match below.
    const srcPosX = new Float32Array(nSrcVerts);
    const srcPosY = new Float32Array(nSrcVerts);
    const srcPosZ = new Float32Array(nSrcVerts);
    for (let v = 0; v < nSrcVerts; v++) {
      const slot = vi[v].m_limitStartIndex;
      const lp = limitPos[slot];
      if (lp) { srcPosX[v] = lp[0]; srcPosY[v] = lp[1]; srcPosZ[v] = lp[2]; }
    }
    ccState._smoothNormalCache = { srcNX, srcNY, srcNZ, srcPosX, srcPosY, srcPosZ, nSrcVerts };
    ccState._cachedSmoothNormalEpoch = ccState.frameEpoch;
  }
  const cache = ccState._smoothNormalCache;
  // For each pool vert, find the source vert whose limit position
  // is closest. If within tolerance, override pool.normal with the
  // cached source smooth normal. Otherwise (interior tess vert),
  // keep amolite's CC limit normal.
  // Find extent of LIMIT positions for tolerance.
  let mn0 = Infinity, mn1 = Infinity, mn2 = Infinity;
  let mx0 = -Infinity, mx1 = -Infinity, mx2 = -Infinity;
  for (let v = 0; v < cache.nSrcVerts; v++) {
    if (cache.srcPosX[v] < mn0) mn0 = cache.srcPosX[v];
    if (cache.srcPosX[v] > mx0) mx0 = cache.srcPosX[v];
    if (cache.srcPosY[v] < mn1) mn1 = cache.srcPosY[v];
    if (cache.srcPosY[v] > mx1) mx1 = cache.srcPosY[v];
    if (cache.srcPosZ[v] < mn2) mn2 = cache.srcPosZ[v];
    if (cache.srcPosZ[v] > mx2) mx2 = cache.srcPosZ[v];
  }
  const extent = Math.max(mx0-mn0, mx1-mn1, mx2-mn2);
  const matchTol = Math.max(1e-5, extent * 1e-4);
  const matchTolSq = matchTol * matchTol;
  // Spatial hash for fast nearest source vert lookup.
  const bucket = new Map();
  for (let v = 0; v < cache.nSrcVerts; v++) {
    const k = Math.floor(cache.srcPosX[v]/matchTol) + ',' + Math.floor(cache.srcPosY[v]/matchTol) + ',' + Math.floor(cache.srcPosZ[v]/matchTol);
    let bb = bucket.get(k); if (!bb) { bb = []; bucket.set(k, bb); }
    bb.push(v);
  }
  for (let i = 0; i < r.vertexCount; i++) {
    const x = P[(v0+i)*3+0], y = P[(v0+i)*3+1], z = P[(v0+i)*3+2];
    // Search 3x3x3 neighborhood for nearest source vert.
    let bestV = -1, bestD2 = matchTolSq;
    for (let dx = -1; dx <= 1; dx++)
      for (let dy = -1; dy <= 1; dy++)
        for (let dz = -1; dz <= 1; dz++) {
          const k = (Math.floor(x/matchTol)+dx) + ',' + (Math.floor(y/matchTol)+dy) + ',' + (Math.floor(z/matchTol)+dz);
          const arr = bucket.get(k); if (!arr) continue;
          for (const v of arr) {
            const ddx = x-cache.srcPosX[v], ddy = y-cache.srcPosY[v], ddz = z-cache.srcPosZ[v];
            const d2 = ddx*ddx + ddy*ddy + ddz*ddz;
            if (d2 < bestD2) { bestD2 = d2; bestV = v; }
          }
        }
    if (bestV >= 0 && (cache.srcNX[bestV] !== 0 || cache.srcNY[bestV] !== 0 || cache.srcNZ[bestV] !== 0)) {
      N[(v0+i)*3+0] = cache.srcNX[bestV];
      N[(v0+i)*3+1] = cache.srcNY[bestV];
      N[(v0+i)*3+2] = cache.srcNZ[bestV];
    }
    // else: keep amolite's CC limit normal (interior tess vert, no
    // matching source vert within tolerance).
  }
}

function measureWeldSpread(mesh, pool) {
  const builtData = mesh.builtData;
  const r = mesh.renderData;
  const startVtx = r.vertexOffset;
  const tcps  = r.triCornerPoolSlot;
  const tefps = r.triEdgeFirstPoolSlot;
  const tevc  = r.triEdgeVertCount;
  const ownerVert = builtData.triVertOwnership;
  const ownerEdge = builtData.triEdgeOwnership;

  let maxVert = 0;
  for (const refs of builtData.weldableVerts) {
    const slots = [];
    const seen = new Set();
    for (let i = 0; i < refs.length; i++) {
      if (ownerVert[refs[i]] !== -1) continue;
      const tri = (refs[i] / 3) | 0;
      const corner = refs[i] - tri * 3;
      const slot = tcps[tri*3 + corner];
      if (!seen.has(slot)) { seen.add(slot); slots.push(slot); }
    }
    if (slots.length < 2) continue;
    let cx=0, cy=0, cz=0;
    for (let s = 0; s < slots.length; s++) {
      const ps = startVtx + slots[s];
      cx += pool.positions[ps*3+0]; cy += pool.positions[ps*3+1]; cz += pool.positions[ps*3+2];
    }
    const inv = 1 / slots.length;
    cx *= inv; cy *= inv; cz *= inv;
    for (let s = 0; s < slots.length; s++) {
      const ps = startVtx + slots[s];
      const dx = pool.positions[ps*3+0] - cx;
      const dy = pool.positions[ps*3+1] - cy;
      const dz = pool.positions[ps*3+2] - cz;
      const d = Math.sqrt(dx*dx + dy*dy + dz*dz);
      if (d > maxVert) maxVert = d;
    }
  }

  let maxEdge = 0;
  for (const set of builtData.weldableEdges) {
    const refs = set.refs;
    const dirs = set.dirs;
    const owners = [];
    let E = -1;
    for (let m = 0; m < refs.length; m++) {
      if (ownerEdge[refs[m]] !== -1) continue;
      const tri = (refs[m] / 3) | 0;
      const slot = refs[m] - tri * 3;
      const myE = tevc[tri*3 + slot];
      if (E === -1) E = myE;
      owners.push({ tri, slot, dir: dirs[m] });
    }
    if (owners.length < 2 || E <= 0) continue;
    for (let i = 0; i < E; i++) {
      let cx=0, cy=0, cz=0;
      for (let m = 0; m < owners.length; m++) {
        const o = owners[m];
        const off = o.dir === 0 ? i : (E - 1 - i);
        const ps = startVtx + tefps[o.tri*3 + o.slot] + off;
        cx += pool.positions[ps*3+0]; cy += pool.positions[ps*3+1]; cz += pool.positions[ps*3+2];
      }
      const inv = 1 / owners.length;
      cx *= inv; cy *= inv; cz *= inv;
      for (let m = 0; m < owners.length; m++) {
        const o = owners[m];
        const off = o.dir === 0 ? i : (E - 1 - i);
        const ps = startVtx + tefps[o.tri*3 + o.slot] + off;
        const dx = pool.positions[ps*3+0] - cx;
        const dy = pool.positions[ps*3+1] - cy;
        const dz = pool.positions[ps*3+2] - cz;
        const d = Math.sqrt(dx*dx + dy*dy + dz*dz);
        if (d > maxEdge) maxEdge = d;
      }
    }
  }

  return { vert: maxVert, edge: maxEdge };
}
