// =====================================================================
// BuiltData construction. Fan-triangulates each source polygon
// (triangles emit one triangle; quads emit two: (c0,c1,c2) + (c0,c2,c3)).
// Dedupes corners by full (posIdx, normalIdx, uvIdx) triple, then
// dedupes edges by canonical (sorted) corner-id pair. After
// smoothNormalsInPlace, normalIdx == posIdx, so the only thing that
// splits a shared position into multiple corners is a UV seam --
// triangles on either side of a seam will not share an edge.
// =====================================================================
function buildBuiltData(objs) {
  const obj0 = objs[0];   // positions/normals/texcoords are shared across all sub-objs.
  const numVertices = obj0.positions.length;
  let numTriangles = 0;
  for (const obj of objs) {
    for (const poly of obj.polygons) numTriangles += poly.corners.length - 2;
  }

  const cornerIdMap = new Map();
  function getCornerId(c) {
    const key = c[0] + ',' + c[1] + ',' + c[2];
    let id = cornerIdMap.get(key);
    if (id === undefined) { id = cornerIdMap.size; cornerIdMap.set(key, id); }
    return id;
  }

  const edgeIdMap = new Map();
  const edgePos0List = [];
  const edgePos1List = [];
  function getEdgeId(idA, idB, posA, posB) {
    const lo = idA < idB ? idA : idB;
    const hi = idA < idB ? idB : idA;
    const key = lo + ',' + hi;
    let id = edgeIdMap.get(key);
    if (id === undefined) {
      id = edgeIdMap.size;
      edgeIdMap.set(key, id);
      edgePos0List.push(posA);
      edgePos1List.push(posB);
    }
    return id;
  }

  const triEdgeIds      = new Int32Array(numTriangles * 3);
  const cornerIds       = new Int32Array(numTriangles * 3);
  const cornerPosIdx    = new Int32Array(numTriangles * 3);
  const cornerNormalIdx = new Int32Array(numTriangles * 3);
  const cornerUvIdx     = new Int32Array(numTriangles * 3);
  // Per-rendered-triangle back-link to source polygon -- used below in
  // the tess-factor-source pass and surfaced as builtData.triSrcPolyIdx
  // so the CC integration (Phase 5) can look up (patchIter, sub-uv).
  // Index is GLOBAL across objs in walk order; obj boundaries are
  // implicit since each obj's polygons are consumed in sequence.
  const triSrcPolyIdx = new Int32Array(numTriangles);
  const allPolygons   = [];
  let triIdx = 0;
  for (const obj of objs) {
    for (const poly of obj.polygons) {
      const polyIdx = allPolygons.length;
      allPolygons.push(poly);
      const cs = poly.corners;
      // Fan: (cs[0], cs[i], cs[i+1]) for i = 1 .. cs.length - 2.
      for (let i = 1; i < cs.length - 1; i++) {
        const c0 = cs[0], c1 = cs[i], c2 = cs[i+1];
        const id0 = getCornerId(c0);
        const id1 = getCornerId(c1);
        const id2 = getCornerId(c2);
        cornerIds[triIdx*3 + 0] = id0;
        cornerIds[triIdx*3 + 1] = id1;
        cornerIds[triIdx*3 + 2] = id2;
        triEdgeIds[triIdx*3 + 0] = getEdgeId(id0, id1, c0[0], c1[0]);
        triEdgeIds[triIdx*3 + 1] = getEdgeId(id1, id2, c1[0], c2[0]);
        triEdgeIds[triIdx*3 + 2] = getEdgeId(id0, id2, c0[0], c2[0]);
        cornerPosIdx   [triIdx*3 + 0] = c0[0]; cornerPosIdx   [triIdx*3 + 1] = c1[0]; cornerPosIdx   [triIdx*3 + 2] = c2[0];
        cornerNormalIdx[triIdx*3 + 0] = c0[1]; cornerNormalIdx[triIdx*3 + 1] = c1[1]; cornerNormalIdx[triIdx*3 + 2] = c2[1];
        cornerUvIdx    [triIdx*3 + 0] = c0[2]; cornerUvIdx    [triIdx*3 + 1] = c1[2]; cornerUvIdx    [triIdx*3 + 2] = c2[2];
        triSrcPolyIdx[triIdx] = polyIdx;
        triIdx++;
      }
    }
  }

  // ----------------------------------------------------------------
  // Per-edge tess-factor source. Topology edges (triEdgeIds) stay
  // dedup'd by corner-id pairs; here we pick the *positions* whose
  // distance feeds the tess factor for each topology edge.
  //
  // Rule (driven by source-mesh polygons):
  //   - If any triangle uses this topology edge as a polygon
  //     PERIMETER edge, the factor uses the edge's own length.
  //   - Else (the edge is only used as a diagonal added by fan
  //     triangulation), the factor uses the OWNING POLYGON's longest
  //     perimeter edge length. By construction a diagonal lives in
  //     exactly one polygon, so all triangles sharing the diagonal
  //     came from that same polygon and agree on the source.
  //
  // Why: with shortest-at-slot-2 rotation, we don't want an interior
  // diagonal to be picked as the "shortest" just because it's
  // numerically a bit shorter than the polygon edges. Reusing the
  // polygon's longest factor for the diagonal guarantees that the
  // two adjacent triangles tessellate that internal seam at matching
  // density and that the rotation key is driven by polygon edges.
  // ----------------------------------------------------------------
  const numEdges = edgeIdMap.size;
  const edgeTfPos0   = new Int32Array(numEdges);
  const edgeTfPos1   = new Int32Array(numEdges);
  const edgePerimUse = new Uint8Array(numEdges);
  for (let i = 0; i < numEdges; i++) { edgeTfPos0[i] = -1; edgeTfPos1[i] = -1; }
  for (let t = 0; t < numTriangles; t++) {
    const poly = allPolygons[triSrcPolyIdx[t]];
    const perimSet = new Set();
    for (let i = 0; i < poly.perimEdges.length; i++) {
      const ab = poly.perimEdges[i];
      const lo = ab[0] < ab[1] ? ab[0] : ab[1];
      const hi = ab[0] < ab[1] ? ab[1] : ab[0];
      perimSet.add(lo + ',' + hi);
    }
    const c0p = cornerPosIdx[t*3 + 0];
    const c1p = cornerPosIdx[t*3 + 1];
    const c2p = cornerPosIdx[t*3 + 2];
    const triEnds = [[c0p, c1p], [c1p, c2p], [c0p, c2p]];
    for (let k = 0; k < 3; k++) {
      const a = triEnds[k][0], b = triEnds[k][1];
      const lo = a < b ? a : b;
      const hi = a < b ? b : a;
      const eId = triEdgeIds[t*3 + k];
      if (perimSet.has(lo + ',' + hi)) {
        edgeTfPos0[eId] = a;
        edgeTfPos1[eId] = b;
        edgePerimUse[eId] = 1;
      } else if (!edgePerimUse[eId]) {
        edgeTfPos0[eId] = poly.longestPerimEdge[0];
        edgeTfPos1[eId] = poly.longestPerimEdge[1];
      }
    }
  }

  // 2-bit shortest-edge-at-slot-2 rotation per source tri.
  //
  // Convention: the shortest original edge is rotated into slot 2 of
  // the tessellator's input. R is the corner-cycle step that maps the
  // original tri to that rotated layout:
  //   rotated_V_i = V_{(i + R) % 3}
  //   rotated_e_K = original edge (K + R) % 3
  // We want rotated_e2 == original_e_shortest, so
  //   (2 + R) % 3 == shortestIndex
  //   R = (shortestIndex + 1) % 3
  //
  // Length comparison uses the per-edge EFFECTIVE length (sourced
  // through edgeTfPos0/Pos1). For polygon-perimeter edges that equals
  // the edge's own length; for interior diagonals it equals the
  // polygon's longest perimeter length, so a diagonal will not be
  // picked as the shortest.
  //
  // Why shortest-at-slot-2 (not slot-0): TessLookup's interior
  // parallelogram is keyed by (t0, t2). With (t0, t1) both large and
  // t2 the small one, the parallelogram is a full grid that covers
  // the bulk of the tri and the gap stitch (between t0 and t1) stays
  // a regular E0 ~= E1 ribbon.
  //
  // Stored standalone as a Uint8Array; computeClusterScopedOwnership
  // packs it into bits 6-7 of triTopoMask so a future compute pass
  // can read mask + rotation in a single byte.
  const triRotation = new Uint8Array(numTriangles);
  for (let t = 0; t < numTriangles; t++) {
    const e0 = triEdgeIds[t*3 + 0];
    const e1 = triEdgeIds[t*3 + 1];
    const e2 = triEdgeIds[t*3 + 2];
    const a0 = obj0.positions[edgeTfPos0[e0]], b0 = obj0.positions[edgeTfPos1[e0]];
    const a1 = obj0.positions[edgeTfPos0[e1]], b1 = obj0.positions[edgeTfPos1[e1]];
    const a2 = obj0.positions[edgeTfPos0[e2]], b2 = obj0.positions[edgeTfPos1[e2]];
    const dx0 = a0[0]-b0[0], dy0 = a0[1]-b0[1], dz0 = a0[2]-b0[2];
    const dx1 = a1[0]-b1[0], dy1 = a1[1]-b1[1], dz1 = a1[2]-b1[2];
    const dx2 = a2[0]-b2[0], dy2 = a2[1]-b2[1], dz2 = a2[2]-b2[2];
    const len0sq = dx0*dx0 + dy0*dy0 + dz0*dz0;
    const len1sq = dx1*dx1 + dy1*dy1 + dz1*dz1;
    const len2sq = dx2*dx2 + dy2*dy2 + dz2*dz2;
    let shortestIdx;
    if      (len0sq <= len1sq && len0sq <= len2sq) shortestIdx = 0;
    else if (len1sq <= len2sq)                     shortestIdx = 1;
    else                                           shortestIdx = 2;
    triRotation[t] = (shortestIdx + 1) % 3;
  }

  // Phong-tessellation setup is fully determined by the source tri's
  // positions and smooth normals -- both fixed at load time, so cache
  // the setups. 6 vec3 per tri (corners + normals); position/normal
  // evaluation happens in pass B at every tessellated vertex.
  const phongSetups = new Array(numTriangles);
  const triUvs      = new Float32Array(numTriangles * 6);
  for (let t = 0; t < numTriangles; t++) {
    const p0 = obj0.positions[cornerPosIdx[t*3 + 0]];
    const p1 = obj0.positions[cornerPosIdx[t*3 + 1]];
    const p2 = obj0.positions[cornerPosIdx[t*3 + 2]];
    let n0, n1, n2;
    if (cornerNormalIdx[t*3 + 0] >= 0 && cornerNormalIdx[t*3 + 1] >= 0 && cornerNormalIdx[t*3 + 2] >= 0) {
      n0 = obj0.normals[cornerNormalIdx[t*3 + 0]];
      n1 = obj0.normals[cornerNormalIdx[t*3 + 1]];
      n2 = obj0.normals[cornerNormalIdx[t*3 + 2]];
    } else {
      const fn = V3.norm(V3.cross(V3.sub(p1, p0), V3.sub(p2, p0)));
      n0 = n1 = n2 = fn;
    }
    phongSetups[t] = phongSetup(p0, p1, p2, n0, n1, n2);

    for (let k = 0; k < 3; k++) {
      const uvIdx = cornerUvIdx[t*3 + k];
      let uv;
      if (obj0.texcoords && uvIdx >= 0) uv = obj0.texcoords[uvIdx];
      else uv = (k === 0) ? [0, 0] : (k === 1) ? [1, 0] : [0, 1];
      triUvs[t*6 + k*2 + 0] = uv[0];
      triUvs[t*6 + k*2 + 1] = uv[1];
    }
  }

  const builtData = {
    // numOwnedCorners / numOwnedEdges are filled in once
    // computeClusterScopedOwnership runs (it knows the cluster
    // assignments and so produces correct cluster-scoped totals).
    meshInfo:  { numVertices, numTriangles, numEdges, numOwnedCorners: 0, numOwnedEdges: 0 },
    triEdgeIds,
    edgePos0:  new Int32Array(edgePos0List),
    edgePos1:  new Int32Array(edgePos1List),
    // Tess-factor source positions per topology edge: == edge itself
    // for polygon-perimeter edges, == the owning polygon's longest
    // perimeter for fan-triangulation diagonals.
    edgeTfPos0,
    edgeTfPos1,
    cornerIds,
    cornerPosIdx, cornerNormalIdx, cornerUvIdx,
    triVertOwnership: null, triEdgeOwnership: null, triTopoMask: null,
    triRotation,
    phongSetups, triUvs,
    // Per-rendered-tri back-link to source polygon, used by CC to map
    // (triId, bary) -> (patchIter, sub-uv). Phong path ignores it.
    triSrcPolyIdx,
    srcPolygons: allPolygons,
  };
  return builtData;
}


// =====================================================================
// permuteMeshTrisByCluster -- reorder builtData.* per-tri arrays so
// each cluster's source tris occupy a CONTIGUOUS range in mesh-tri
// indexing. Within a cluster, tris keep their existing
// triangleSourceIds order (which buildClusters has already sorted
// mesh-ascending).
//
// Why: cluster-scoped GPU passes (A1c, A3c, ...) naturally write
// per-cluster outputs in cluster order, while CPU runTopologyPass
// iterates 0..numTris in mesh order. If clusters are interleaved in
// mesh order, the two sides agree on totals but produce different
// pool layouts (CPU: tri-by-tri across clusters; GPU: cluster-grouped).
// Permuting once at topology-build time so mesh-ascending == cluster-
// grouped lets CPU keep its mesh-order walk and have GPU layouts
// match bit-for-bit, with no downstream changes.
//
// Mutates builtData per-tri arrays in place (cornerIds, cornerPosIdx,
// cornerNormalIdx, cornerUvIdx, triEdgeIds, triRotation, triUvs,
// phongSetups). Replaces cluster.triangleSourceIds with the new
// contiguous range and rewrites triClusterId.
//
// Must run AFTER buildClusters and BEFORE computeClusterScopedOwnership
// (the latter writes ownership refs as tri-slot indices that need to
// reflect the new ordering).
// =====================================================================
function permuteMeshTrisByCluster(builtData, clusters, triClusterId) {
  const numTris = builtData.meshInfo.numTriangles;

  const newToOld = new Uint32Array(numTris);
  const clusterStart = new Uint32Array(clusters.length);
  let cursor = 0;
  for (let c = 0; c < clusters.length; c++) {
    clusterStart[c] = cursor;
    const oldIds = clusters[c].triangleSourceIds;
    for (let i = 0; i < oldIds.length; i++) newToOld[cursor++] = oldIds[i];
  }
  if (cursor !== numTris) {
    throw new Error(`permuteMeshTrisByCluster: cursor ${cursor} != numTris ${numTris}`);
  }

  function permuteStrided(src, stride) {
    const dst = new src.constructor(src.length);
    for (let n = 0; n < numTris; n++) {
      const o = newToOld[n];
      for (let k = 0; k < stride; k++) {
        dst[n * stride + k] = src[o * stride + k];
      }
    }
    return dst;
  }
  builtData.cornerIds       = permuteStrided(builtData.cornerIds,       3);
  builtData.cornerPosIdx    = permuteStrided(builtData.cornerPosIdx,    3);
  builtData.cornerNormalIdx = permuteStrided(builtData.cornerNormalIdx, 3);
  builtData.cornerUvIdx     = permuteStrided(builtData.cornerUvIdx,     3);
  builtData.triEdgeIds      = permuteStrided(builtData.triEdgeIds,      3);
  builtData.triRotation     = permuteStrided(builtData.triRotation,     1);
  builtData.triUvs          = permuteStrided(builtData.triUvs,          6);
  builtData.triSrcPolyIdx   = permuteStrided(builtData.triSrcPolyIdx,   1);

  // phongSetups is a JS array of objects (not a typed array), so the
  // per-element copy is plain assignment.
  const newPhong = new Array(numTris);
  for (let n = 0; n < numTris; n++) newPhong[n] = builtData.phongSetups[newToOld[n]];
  builtData.phongSetups = newPhong;

  // Rewrite cluster.triangleSourceIds to the new contiguous range, and
  // rebuild triClusterId from the new cluster ranges. After this each
  // cluster c covers tris [clusterStart[c], clusterStart[c] + size).
  for (let c = 0; c < clusters.length; c++) {
    const len   = clusters[c].triangleSourceIds.length;
    const start = clusterStart[c];
    const ids   = new Uint32Array(len);
    for (let i = 0; i < len; i++) ids[i] = start + i;
    clusters[c].triangleSourceIds = ids;
    for (let i = 0; i < len; i++) triClusterId[start + i] = c;
  }

  // Stash newToOld so external per-tri tables (e.g. ccState.triPatchMap,
  // which is in pre-permute amolite source-tri order) can be brought
  // into the post-permute order builtData now uses. Without this, any
  // post-permute consumer that indexes ccState.triPatchMap[t] reads
  // the wrong patch -- the cause of CC mode T-junctions on fixtures
  // whose clusters require a non-trivial permutation.
  builtData.triNewToOld = newToOld;
}


// =====================================================================
// Cluster-scoped ownership analysis.
//
// Greedy first-encountered-wins, but RESTART per cluster. A position
// (or edge id) referenced by tris in two different clusters gets ONE
// dominant in each cluster -- by construction, cluster boundaries
// behave like UV seams: the verts and edges along them are duplicated
// across the boundary, never shared.
//
// Mutates builtData in place: writes triVertOwnership, triEdgeOwnership,
// triTopoMask, and the meshInfo.numOwned* counters.
// =====================================================================
function computeClusterScopedOwnership(builtData, triClusterId, numClusters) {
  const numTris      = builtData.meshInfo.numTriangles;
  const cornerPosIdx = builtData.cornerPosIdx;
  const triEdgeIds   = builtData.triEdgeIds;
  const triRotation  = builtData.triRotation;

  const triVertOwnership = new Int32Array(numTris * 3);
  const triEdgeOwnership = new Int32Array(numTris * 3);
  // triTopoMask layout: bit 0-2 = own corner k, bit 3-5 = own edge k,
  // bit 6-7 = rotation (0..2). One byte for everything.
  const triTopoMask      = new Uint8Array(numTris);

  const trisByCluster = new Array(numClusters);
  for (let c = 0; c < numClusters; c++) trisByCluster[c] = [];
  for (let t = 0; t < numTris; t++) trisByCluster[triClusterId[t]].push(t);

  let numOwnedCorners = 0;
  let numOwnedEdges   = 0;
  for (let c = 0; c < numClusters; c++) {
    const positionOwner = new Map();
    const edgeOwner     = new Map();
    const tris = trisByCluster[c];
    for (let i = 0; i < tris.length; i++) {
      const t = tris[i];
      let mask = 0;
      for (let k = 0; k < 3; k++) {
        const posIdx = cornerPosIdx[t*3 + k];
        const slot   = t*3 + k;
        if (!positionOwner.has(posIdx)) {
          positionOwner.set(posIdx, slot);
          triVertOwnership[slot] = -1;
          mask |= (1 << k);
          numOwnedCorners++;
        } else {
          triVertOwnership[slot] = positionOwner.get(posIdx);
        }
      }
      for (let k = 0; k < 3; k++) {
        const edgeId = triEdgeIds[t*3 + k];
        const slot   = t*3 + k;
        if (!edgeOwner.has(edgeId)) {
          edgeOwner.set(edgeId, slot);
          triEdgeOwnership[slot] = -1;
          mask |= (1 << (3 + k));
          numOwnedEdges++;
        } else {
          triEdgeOwnership[slot] = edgeOwner.get(edgeId);
        }
      }
      triTopoMask[t] = mask | (triRotation[t] << 6);
    }
  }

  builtData.triVertOwnership = triVertOwnership;
  builtData.triEdgeOwnership = triEdgeOwnership;
  builtData.triTopoMask      = triTopoMask;
  builtData.meshInfo.numOwnedCorners = numOwnedCorners;
  builtData.meshInfo.numOwnedEdges   = numOwnedEdges;
}


// =====================================================================
// Cheap consistency check on Phase 1 output. Crashes loudly on any
// inconsistency -- per CLAUDE.md no-silent-fallbacks. Always on (cost
// is linear in numTriangles + numEdges).
// =====================================================================
function verifyOwnership(builtData) {
  const { meshInfo, triVertOwnership, triEdgeOwnership, triTopoMask } = builtData;
  const numTris = meshInfo.numTriangles;

  let ownedC = 0, ownedE = 0;
  for (let t = 0; t < numTris; t++) {
    const mask = triTopoMask[t];
    for (let k = 0; k < 3; k++) {
      const v = triVertOwnership[t*3 + k];
      const ownBit = (mask >> k) & 1;
      if (v === -1) {
        if (ownBit !== 1) throw new Error(`triTopoMask bit ${k} disagrees with vert ownership at tri ${t}`);
        ownedC++;
      } else {
        if (ownBit !== 0) throw new Error(`triTopoMask bit ${k} disagrees with vert ownership at tri ${t}`);
        // The dominant slot must itself be a dominant (-1).
        if (triVertOwnership[v] !== -1) {
          throw new Error(`vert ref at tri ${t} corner ${k} points to non-dominant slot ${v}`);
        }
      }
      const e = triEdgeOwnership[t*3 + k];
      const eBit = (mask >> (3 + k)) & 1;
      if (e === -1) {
        if (eBit !== 1) throw new Error(`triTopoMask bit ${3+k} disagrees with edge ownership at tri ${t}`);
        ownedE++;
      } else {
        if (eBit !== 0) throw new Error(`triTopoMask bit ${3+k} disagrees with edge ownership at tri ${t}`);
        if (triEdgeOwnership[e] !== -1) {
          throw new Error(`edge ref at tri ${t} edge ${k} points to non-dominant slot ${e}`);
        }
      }
    }
  }
  if (ownedC !== meshInfo.numOwnedCorners) throw new Error(`numOwnedCorners mismatch: ${ownedC} vs ${meshInfo.numOwnedCorners}`);
  if (ownedE !== meshInfo.numOwnedEdges)   throw new Error(`numOwnedEdges mismatch: ${ownedE} vs ${meshInfo.numOwnedEdges}`);
  // Note: with cluster-scoped ownership, numOwnedEdges >= numEdges --
  // edges crossing cluster boundaries are owned independently on each
  // side, so the global "every edge has exactly one owner" invariant
  // no longer holds. The per-cluster invariant is enforced inside
  // computeClusterScopedOwnership instead.
}


// =====================================================================
// Per-cluster packed topology shadow (cluster.topo).
//
// This is the layout we'll consume from the compute path. The
// existing CPU runtime keeps using the mesh-wide builtData arrays;
// cluster.topo is the "compute-ready" representation produced
// alongside.
//
// Per cluster (max 64 verts, 64 tris):
//   numEdges                 - unique edges within this cluster (<=192)
//   triCornerLocal [Uint8 numTris*3]
//                              cluster-local vertex index per tri-corner
//                              (0..numVerts-1; numVerts <= 64)
//   triEdgeLocal   [Uint8 numTris*3]
//                              cluster-local edge id per tri-edge
//                              (0..numEdges-1)
//   edgeStart, edgeEnd [Uint8 numEdges]
//                              cluster-local vertex indices for each
//                              edge id, captured in the order first
//                              encountered (canonical lo->hi)
//   triVertOwnerLocal [Uint8 numTris*3]
//   triEdgeOwnerLocal [Uint8 numTris*3]
//                              0xFF = this triangle is the dominant
//                              owner for that corner / edge.
//                              Otherwise: triLocal*3 + cornerOrSlot
//                              (0..191) within this cluster.
//   triTopoAndRot     [Uint8 numTris]
//                              bit 0-2: own corner 0/1/2
//                              bit 3-5: own edge 0/1/2
//                              bit 6-7: shortest-at-slot-2 rotation (0..2)
//
// Total per tri: 6 indices + 6 ownership refs + 1 topo/rot byte = 13B,
// vs the current Int32 layout's ~50+ bytes.
// =====================================================================
function buildClusterTopo(cluster, mesh) {
  const builtData    = mesh.builtData;
  const cornerIds    = builtData.cornerIds;
  const cornerPosIdx = builtData.cornerPosIdx;
  const triRotation  = builtData.triRotation;
  const triIds       = cluster.triangleSourceIds;
  const numTris      = triIds.length;
  const numVerts     = cluster.vertexSourceIds.length;

  // cornerId -> cluster-local vertex index.
  const cidToLocal = new Map();
  for (let i = 0; i < numVerts; i++) cidToLocal.set(cluster.vertexSourceIds[i], i);

  const triCornerLocal = new Uint8Array(numTris * 3);
  for (let i = 0; i < numTris; i++) {
    const t = triIds[i];
    triCornerLocal[i*3 + 0] = cidToLocal.get(cornerIds[t*3 + 0]);
    triCornerLocal[i*3 + 1] = cidToLocal.get(cornerIds[t*3 + 1]);
    triCornerLocal[i*3 + 2] = cidToLocal.get(cornerIds[t*3 + 2]);
  }

  // Cluster-internal edge dedup: canonical (lo, hi) pair of cluster-
  // local vertex indices.
  const edgeMap = new Map();
  const edgeStartList = [];
  const edgeEndList   = [];
  function getLocalEdgeId(a, b) {
    const lo = a < b ? a : b;
    const hi = a < b ? b : a;
    const key = lo * 256 + hi;     // numVerts <= 64 fits comfortably
    let id = edgeMap.get(key);
    if (id === undefined) {
      id = edgeMap.size;
      edgeMap.set(key, id);
      edgeStartList.push(lo);
      edgeEndList.push(hi);
    }
    return id;
  }
  const triEdgeLocal = new Uint8Array(numTris * 3);
  for (let i = 0; i < numTris; i++) {
    const c0 = triCornerLocal[i*3 + 0];
    const c1 = triCornerLocal[i*3 + 1];
    const c2 = triCornerLocal[i*3 + 2];
    triEdgeLocal[i*3 + 0] = getLocalEdgeId(c0, c1);
    triEdgeLocal[i*3 + 1] = getLocalEdgeId(c1, c2);
    triEdgeLocal[i*3 + 2] = getLocalEdgeId(c0, c2);
  }
  const numEdges = edgeMap.size;
  if (numEdges > 255) throw new Error(`cluster has ${numEdges} edges, exceeds Uint8 range`);
  const edgeStart = new Uint8Array(edgeStartList);
  const edgeEnd   = new Uint8Array(edgeEndList);

  // Cluster-scoped first-encountered-wins ownership, encoded as
  // Uint8 with 0xFF as the "I am dominant" sentinel. Refs are within-
  // cluster: triLocal*3 + cornerOrSlot (max 191).
  const triVertOwnerLocal = new Uint8Array(numTris * 3);
  const triEdgeOwnerLocal = new Uint8Array(numTris * 3);
  const triTopoAndRot     = new Uint8Array(numTris);
  const positionToOwner   = new Map();   // posIdx -> within-cluster ref
  const edgeIdToOwner     = new Map();   // localEdgeId -> within-cluster ref
  for (let i = 0; i < numTris; i++) {
    const t = triIds[i];
    let mask = 0;
    for (let k = 0; k < 3; k++) {
      const ref    = i * 3 + k;
      const posIdx = cornerPosIdx[t*3 + k];
      const prev   = positionToOwner.get(posIdx);
      if (prev === undefined) {
        positionToOwner.set(posIdx, ref);
        triVertOwnerLocal[ref] = 0xFF;
        mask |= (1 << k);
      } else {
        triVertOwnerLocal[ref] = prev;
      }
    }
    for (let k = 0; k < 3; k++) {
      const ref     = i * 3 + k;
      const edgeId  = triEdgeLocal[ref];
      const prev    = edgeIdToOwner.get(edgeId);
      if (prev === undefined) {
        edgeIdToOwner.set(edgeId, ref);
        triEdgeOwnerLocal[ref] = 0xFF;
        mask |= (1 << (3 + k));
      } else {
        triEdgeOwnerLocal[ref] = prev;
      }
    }
    triTopoAndRot[i] = mask | (triRotation[t] << 6);
  }

  cluster.topo = {
    numEdges,
    triCornerLocal,
    triEdgeLocal,
    edgeStart, edgeEnd,
    triVertOwnerLocal,
    triEdgeOwnerLocal,
    triTopoAndRot,
  };
}


// =====================================================================
// Weldable sets (pre-build pass, run once at load).
//
// "Weldable" means: vertices or edges that occupy the same world-space
// position but were NOT merged by the cluster-scoped sharing analysis
// -- either because they cross a cluster boundary or sit on a UV seam.
// After Pass C (displacement) runs, the same world-space vertex can
// have slightly different positions in different pool slots (different
// UVs sample different displacement values, etc.). Pass D averages
// across each weldable set so the final geometry is crack-free.
//
// Outputs (stored on builtData):
//   weldableVerts: Array of Uint32Array
//     Each entry is a list of (tri*3 + corner) refs that all share one
//     world position. Only kept when length >= 2 (a 1-element group
//     can never need welding).
//   weldableEdges: Array of { refs: Uint32Array, dirs: Uint8Array }
//     Each entry is a list of (tri*3 + edge-slot) refs that share one
//     canonical (lo, hi) position pair, with per-ref direction:
//       dir = 0 if (start, end) == (lo, hi)
//       dir = 1 if (start, end) == (hi, lo)  (reversed)
//     Only kept when length >= 2.
// =====================================================================
function buildWeldableSets(builtData) {
  const numTris      = builtData.meshInfo.numTriangles;
  const cornerPosIdx = builtData.cornerPosIdx;
  const cornerIds    = builtData.cornerIds;
  const triRotation  = builtData.triRotation;

  const vertGroups = new Map();
  for (let t = 0; t < numTris; t++) {
    for (let k = 0; k < 3; k++) {
      const posIdx = cornerPosIdx[t*3 + k];
      let arr = vertGroups.get(posIdx);
      if (arr === undefined) { arr = []; vertGroups.set(posIdx, arr); }
      arr.push(t*3 + k);
    }
  }
  // weldableVerts: every group of >= 2 corners that share a position.
  //   Geometric weld -- closes UV seams as well as cluster boundaries.
  // weldableVertsSeam: subset where members have differing cornerIds
  //   (= corners at the same position belonging to different source
  //   vertices, i.e. a UV / attribute seam). This is the "weld only
  //   seams" classification: the corners came from a SINGLE source
  //   vertex if they share cornerIds, so geometric position alone
  //   isn't a seam; only differing cornerIds are.
  const weldableVerts     = [];
  const weldableVertsSeam = [];
  for (const arr of vertGroups.values()) {
    if (arr.length < 2) continue;
    const refs = new Uint32Array(arr);
    weldableVerts.push(refs);
    let isSeam = false;
    const firstCid = cornerIds[arr[0]];
    for (let i = 1; i < arr.length; i++) {
      if (cornerIds[arr[i]] !== firstCid) { isSeam = true; break; }
    }
    if (isSeam) weldableVertsSeam.push(refs);
  }

  // For each (tri, original edge slot k), the pool's interior order
  // along that edge follows the ROTATED traversal direction (since
  // runTopologyPass feeds rotated factors into CalcPlainBaryPtsForTess
  // and stores the result under the original edge key). Compute dir
  // against the rotated traversal so runWeldPass averages matching
  // interior offsets across members.
  const edgeGroups = new Map();
  for (let t = 0; t < numTris; t++) {
    const R = triRotation[t];
    for (let k = 0; k < 3; k++) {
      const kRot = (k - R + 3) % 3;
      const startCornerOrig = (EDGE_START_CORNER[kRot] + R) % 3;
      const endCornerOrig   = (EDGE_END_CORNER  [kRot] + R) % 3;
      const posStart = cornerPosIdx[t*3 + startCornerOrig];
      const posEnd   = cornerPosIdx[t*3 + endCornerOrig];
      const lo = posStart < posEnd ? posStart : posEnd;
      const hi = posStart < posEnd ? posEnd   : posStart;
      const key = lo + ',' + hi;
      let list = edgeGroups.get(key);
      if (list === undefined) { list = []; edgeGroups.set(key, list); }
      // cidLo / cidHi: cornerIds at the canonical lo / hi endpoints.
      // For non-seam edges, all members of the group must agree on
      // both endpoints' cornerIds.
      const cidStart = cornerIds[t*3 + startCornerOrig];
      const cidEnd   = cornerIds[t*3 + endCornerOrig];
      const cidLo = posStart === lo ? cidStart : cidEnd;
      const cidHi = posStart === lo ? cidEnd   : cidStart;
      list.push({ ref: t*3 + k, dir: posStart === lo ? 0 : 1, cidLo, cidHi });
    }
  }
  const weldableEdges     = [];
  const weldableEdgesSeam = [];
  for (const list of edgeGroups.values()) {
    if (list.length < 2) continue;
    const refs = new Uint32Array(list.length);
    const dirs = new Uint8Array(list.length);
    for (let i = 0; i < list.length; i++) {
      refs[i] = list[i].ref;
      dirs[i] = list[i].dir;
    }
    const entry = { refs, dirs };
    weldableEdges.push(entry);
    // Seam edge: any member disagrees with member-0 on the cornerIds
    // at either canonical endpoint.
    let isSeam = false;
    const firstLo = list[0].cidLo;
    const firstHi = list[0].cidHi;
    for (let i = 1; i < list.length; i++) {
      if (list[i].cidLo !== firstLo || list[i].cidHi !== firstHi) {
        isSeam = true;
        break;
      }
    }
    if (isSeam) weldableEdgesSeam.push(entry);
  }

  builtData.weldableVerts     = weldableVerts;
  builtData.weldableEdges     = weldableEdges;
  builtData.weldableVertsSeam = weldableVertsSeam;
  builtData.weldableEdgesSeam = weldableEdgesSeam;
}


// =====================================================================
// Cluster generator -- thin wrapper around the JS port of meshopt's
// buildMeshlets (see meshopt_clusterizer.js).
//
// Inputs: BuiltData and the source-mesh positions array. We use FULL
// corner identity (cornerIds) as the meshopt vertex space, so a UV
// seam contributes two distinct meshopt vertices and the resulting
// meshlets respect attribute discontinuities. Positions are looked up
// from cornerPosIdx; normals/UVs aren't needed for clustering.
//
// Returns:
//   clusters [Array]
//     { triangleSourceIds: Uint32Array, vertexSourceIds: Uint32Array }
//   triClusterId [Int32Array]
//     For each source tri, the cluster index it ended up in.
//
// meshopt-side post-processing:
//   meshopt's output is a pile of meshlets sharing two big arrays
//   (meshlet_vertices, meshlet_triangles). We translate each meshlet
//   back into our { triangleSourceIds, vertexSourceIds } shape and
//   reverse-look-up source tri ids by matching index triples.
// =====================================================================
function buildClusters(builtData, positions, maxVerts, maxTris) {
  if (maxVerts === undefined) maxVerts = 64;
  if (maxTris  === undefined) maxTris  = 64;
  const numTris    = builtData.meshInfo.numTriangles;
  const cornerIds  = builtData.cornerIds;
  const cornerPosIdx = builtData.cornerPosIdx;

  // Flatten cornerId -> position(vec3) into a tightly-packed
  // Float32Array. cornerIds may have gaps if some ids are referenced
  // only once (they aren't), so vertexCount = max cornerId + 1.
  let maxCid = -1;
  for (let i = 0; i < cornerIds.length; i++) {
    if (cornerIds[i] > maxCid) maxCid = cornerIds[i];
  }
  const vertexCount = maxCid + 1;
  const vertexPositions = new Float32Array(vertexCount * 3);
  const cidSeen = new Uint8Array(vertexCount);
  for (let i = 0; i < cornerIds.length; i++) {
    const cid = cornerIds[i];
    if (cidSeen[cid]) continue;
    cidSeen[cid] = 1;
    const p = positions[cornerPosIdx[i]];
    vertexPositions[cid*3 + 0] = p[0];
    vertexPositions[cid*3 + 1] = p[1];
    vertexPositions[cid*3 + 2] = p[2];
  }

  // Indices for meshopt: numTris*3 entries, each is a corner id.
  const indices = new Uint32Array(numTris * 3);
  for (let i = 0; i < indices.length; i++) indices[i] = cornerIds[i];

  const out = MeshOpt.buildMeshlets({
    indices, vertexPositions, vertexCount,
    vertexStride: 3,
    maxVertices: maxVerts,
    maxTriangles: maxTris,
    coneWeight: 0.0,    // pure size, no cone-cull bias
  });

  // Translate meshopt's output back into our cluster shape and recover
  // source tri ids. meshopt returns triangles as 3 meshlet-local vert
  // ids per tri; we map those to corner ids (via meshlet_vertices) and
  // then lookup-by-corner-id-triple to get the source tri index.
  //
  // Build a hash from "(c0,c1,c2) sorted" -> source tri idx so the
  // mapping is order-independent.
  const triKeyToSrc = new Map();
  for (let t = 0; t < numTris; t++) {
    const a = cornerIds[t*3 + 0];
    const b = cornerIds[t*3 + 1];
    const c = cornerIds[t*3 + 2];
    const lo = Math.min(a, b, c);
    const hi = Math.max(a, b, c);
    const md = a + b + c - lo - hi;
    triKeyToSrc.set(lo + ',' + md + ',' + hi, t);
  }

  const clusters = new Array(out.meshlets.length);
  const triClusterId = new Int32Array(numTris);
  for (let t = 0; t < numTris; t++) triClusterId[t] = -1;

  for (let m = 0; m < out.meshlets.length; m++) {
    const ml = out.meshlets[m];
    const triIds = new Uint32Array(ml.triangle_count);
    for (let i = 0; i < ml.triangle_count; i++) {
      const lt0 = out.meshlet_triangles[ml.triangle_offset + i*3 + 0];
      const lt1 = out.meshlet_triangles[ml.triangle_offset + i*3 + 1];
      const lt2 = out.meshlet_triangles[ml.triangle_offset + i*3 + 2];
      const c0 = out.meshlet_vertices[ml.vertex_offset + lt0];
      const c1 = out.meshlet_vertices[ml.vertex_offset + lt1];
      const c2 = out.meshlet_vertices[ml.vertex_offset + lt2];
      const lo = Math.min(c0, c1, c2);
      const hi = Math.max(c0, c1, c2);
      const md = c0 + c1 + c2 - lo - hi;
      const srcTri = triKeyToSrc.get(lo + ',' + md + ',' + hi);
      if (srcTri === undefined) throw new Error(`cluster ${m} tri ${i}: no source tri for corners (${c0},${c1},${c2})`);
      triIds[i] = srcTri;
      triClusterId[srcTri] = m;
    }
    const vertIds = new Uint32Array(ml.vertex_count);
    for (let i = 0; i < ml.vertex_count; i++) {
      vertIds[i] = out.meshlet_vertices[ml.vertex_offset + i];
    }
    // Sort cluster tris in MESH-ASCENDING order. Two reasons:
    //  - CPU runTopologyPass iterates mesh tri-index 0..numTris and
    //    looks up dominants via renderData.triCornerPoolSlot which is
    //    only populated when the dominant tri has been iterated; that
    //    requires dominants to have lower mesh tri index than their
    //    referrers (first-encountered-wins within the cluster).
    //  - GPU A3c iterates cluster.triangleSourceIds in stored order;
    //    matching mesh ascending makes both sides walk the same order
    //    so cluster-local pool layout is bit-identical.
    triIds.sort();
    clusters[m] = { triangleSourceIds: triIds, vertexSourceIds: vertIds };
  }

  // Sanity: every tri got assigned, caps respected, total covered.
  for (let t = 0; t < numTris; t++) {
    if (triClusterId[t] === -1) throw new Error(`tri ${t} not assigned to a cluster`);
  }
  for (let c = 0; c < clusters.length; c++) {
    const cl = clusters[c];
    if (cl.triangleSourceIds.length === 0)     throw new Error(`cluster ${c} has zero tris`);
    if (cl.triangleSourceIds.length > maxTris) throw new Error(`cluster ${c} has ${cl.triangleSourceIds.length} tris (max ${maxTris})`);
    if (cl.vertexSourceIds.length   > maxVerts) throw new Error(`cluster ${c} has ${cl.vertexSourceIds.length} verts (max ${maxVerts})`);
  }
  let totalAssigned = 0;
  for (let c = 0; c < clusters.length; c++) totalAssigned += clusters[c].triangleSourceIds.length;
  if (totalAssigned !== numTris) throw new Error(`cluster coverage: ${totalAssigned} != ${numTris}`);

  return { clusters, triClusterId };
}


// =====================================================================
// Per-topology blob packer.
//
// Produces ONE flat ArrayBuffer (builtData.blob) holding every per-tri
// and per-edge array plus the Phong-setup floats. After packing,
// builtData.* are typed-array views into the blob (and phongSetups[i]
// is an object whose vec3 fields are subarrays of the same blob), so
// all downstream readers (passes, welder, cluster builder) keep working
// unchanged but every load goes through the blob -- if any field
// offset or pack rule is wrong the CPU breaks immediately.
//
// Run AFTER computeClusterScopedOwnership and buildWeldableSets so
// every field is populated. Weldable sets and per-cluster topo are
// NOT in this blob yet -- they have variable-length structure that
// needs CSR-style packing; those land in a follow-up.
//
// Layout (mixed types in one ArrayBuffer; aligned to 4 bytes):
//   header[64 u32]: magic 'TOP1', counts, then (byteOffset, count)
//                   pairs for each section
//   triEdgeIds      (i32, numTris*3)
//   cornerIds       (i32, numTris*3)
//   cornerPosIdx    (i32, numTris*3)
//   cornerNormalIdx (i32, numTris*3, may be -1)
//   cornerUvIdx     (i32, numTris*3, may be -1)
//   triVertOwnership(i32, numTris*3, -1 == owner else dom ref)
//   triEdgeOwnership(i32, numTris*3, -1 == owner else dom ref)
//   triTopoMask     (u8,  numTris,  padded to 4)
//   triRotation     (u8,  numTris,  padded to 4)
//   edgePos0/1      (i32, numEdges) -- topology endpoints
//   edgeTfPos0/1    (i32, numEdges) -- tess-factor source positions
//   phongSetups     (f32, numTris*18)  -- 6 vec3 per tri (p0..2, n0..2)
//   triUvs          (f32, numTris*6)
//   weldVertOffsets (u32, numWvGroups+1)  -- CSR
//   weldVertRefs    (u32, totalWvRefs)    -- (tri*3+corner) packed
//   weldEdgeOffsets (u32, numWeGroups+1)  -- CSR
//   weldEdgeRefs    (u32, totalWeRefs)    -- (tri*3+edgeSlot) packed
//   weldEdgeDirs    (u8,  totalWeRefs, padded to 4)
// =====================================================================
function packTopologyBlob(builtData, clusters, triClusterId) {
  const numTris  = builtData.meshInfo.numTriangles;
  const numEdges = builtData.meshInfo.numEdges;
  const numVerts = builtData.meshInfo.numVertices;

  const align4 = (n) => (n + 3) & ~3;
  // Header layout grew at each CC consolidation phase:
  //   64 -> 80: seam weld slots (55..66)
  //   80 -> 96: CC static section offsets (67..78) [Phase B] and CC
  //             per-frame metadata (79..86) [Phase C]
  // 96 slots leaves slots 87..95 reserved for the unified PASS_B
  // mode flag + future expansion. Cost: 64 bytes per topology blob.
  const HEADER_BYTES = 96 * 4;

  // ---- cluster data sizing ----
  // Each cluster occupies one contiguous block in the cluster-data
  // section. Sections within the block are in fixed order; sizes are
  // derived from numTris/numVerts/numCEdges so the GPU shader can
  // compute section offsets from the directory entry alone.
  const numClusters = clusters ? clusters.length : 0;
  // Directory entry: 5 u32 (20 bytes per cluster):
  //   [0] numTrisC, [1] numVertsC, [2] numCEdges, [3] blockByteOff,
  //   [4] firstTri (= clusterStart[c] post permuteMeshTrisByCluster).
  // firstTri lets shaders compute the absolute mesh tri index for
  // cluster-local tIdx as (firstTri + tIdx) directly. After phase 4b
  // the per-cluster triangleSourceIds array no longer lives in the
  // GPU blob -- shaders use firstTri + tIdx, and CPU keeps a
  // standalone Uint32Array on the cluster object.
  const CLUSTER_DIR_FIELDS = 5;
  const clusterDirN = numClusters * CLUSTER_DIR_FIELDS;
  function clusterBlockSize(numTrisC, numVertsC, numCEdges) {
    return numVertsC * 4                 // vertexSourceIds (u32)
         + align4(numTrisC * 3)          // triCornerLocal (u8)
         + align4(numTrisC * 3)          // triEdgeLocal (u8)
         + align4(numCEdges)             // edgeStart (u8)
         + align4(numCEdges)             // edgeEnd (u8)
         + align4(numTrisC * 3)          // triVertOwnerLocal (u8)
         + align4(numTrisC * 3)          // triEdgeOwnerLocal (u8)
         + align4(numTrisC);             // triTopoAndRot (u8)
  }
  let clusterDataBytes = 0;
  for (let c = 0; c < numClusters; c++) {
    const cl = clusters[c];
    clusterDataBytes += clusterBlockSize(
      cl.triangleSourceIds.length,
      cl.vertexSourceIds.length,
      cl.topo.numEdges,
    );
  }
  const triClusterIdN = triClusterId ? triClusterId.length : 0;

  const triEdgeIdsN       = numTris  * 3;
  const cornerIdsN        = numTris  * 3;
  const cornerPosIdxN     = numTris  * 3;
  const cornerNormalIdxN  = numTris  * 3;
  const cornerUvIdxN      = numTris  * 3;
  const triVertOwnN       = numTris  * 3;
  const triEdgeOwnN       = numTris  * 3;
  const triTopoMaskN      = numTris;
  const triRotationN      = numTris;
  const edgePos0N         = numEdges;
  const edgePos1N         = numEdges;
  const edgeTfPos0N       = numEdges;
  const edgeTfPos1N       = numEdges;
  const phongSetupsN      = numTris  * 18;
  const triUvsN           = numTris  * 6;

  // Weldable sets: CSR. Standard prefix-sum offsets, length+1 so each
  // group g lives in refs[offsets[g]..offsets[g+1]).
  //
  // Two parallel sets:
  //   weldableVerts/Edges     -- ALL position-matching groups (mode "all")
  //   weldableVertsSeam/Edges -- subset that crosses cornerIds (mode "seam")
  // Both are packed into the blob so runtime can pick which to use
  // via dispatch-uniform offsets.
  const wv  = builtData.weldableVerts     || [];
  const we  = builtData.weldableEdges     || [];
  const wvs = builtData.weldableVertsSeam || [];
  const wes = builtData.weldableEdgesSeam || [];
  const numWvGroups     = wv.length;
  const numWeGroups     = we.length;
  const numWvGroupsSeam = wvs.length;
  const numWeGroupsSeam = wes.length;
  let totalWvRefs = 0;
  for (let g = 0; g < numWvGroups; g++) totalWvRefs += wv[g].length;
  let totalWeRefs = 0;
  for (let g = 0; g < numWeGroups; g++) totalWeRefs += we[g].refs.length;
  let totalWvRefsSeam = 0;
  for (let g = 0; g < numWvGroupsSeam; g++) totalWvRefsSeam += wvs[g].length;
  let totalWeRefsSeam = 0;
  for (let g = 0; g < numWeGroupsSeam; g++) totalWeRefsSeam += wes[g].refs.length;
  const wvOffsetsN     = numWvGroups + 1;
  const wvRefsN        = totalWvRefs;
  const weOffsetsN     = numWeGroups + 1;
  const weRefsN        = totalWeRefs;
  const weDirsN        = totalWeRefs;
  const wvOffsetsSeamN = numWvGroupsSeam + 1;
  const wvRefsSeamN    = totalWvRefsSeam;
  const weOffsetsSeamN = numWeGroupsSeam + 1;
  const weRefsSeamN    = totalWeRefsSeam;
  const weDirsSeamN    = totalWeRefsSeam;

  let off = HEADER_BYTES;
  const triEdgeIdsOff      = off; off += triEdgeIdsN     * 4;
  const cornerIdsOff       = off; off += cornerIdsN      * 4;
  const cornerPosIdxOff    = off; off += cornerPosIdxN   * 4;
  const cornerNormalIdxOff = off; off += cornerNormalIdxN * 4;
  const cornerUvIdxOff     = off; off += cornerUvIdxN    * 4;
  const triVertOwnOff      = off; off += triVertOwnN     * 4;
  const triEdgeOwnOff      = off; off += triEdgeOwnN     * 4;
  const triTopoMaskOff     = off; off += align4(triTopoMaskN);
  const triRotationOff     = off; off += align4(triRotationN);
  const edgePos0Off        = off; off += edgePos0N       * 4;
  const edgePos1Off        = off; off += edgePos1N       * 4;
  const edgeTfPos0Off      = off; off += edgeTfPos0N     * 4;
  const edgeTfPos1Off      = off; off += edgeTfPos1N     * 4;
  const phongSetupsOff     = off; off += phongSetupsN    * 4;
  const triUvsOff          = off; off += triUvsN         * 4;
  const wvOffsetsOff       = off; off += wvOffsetsN      * 4;
  const wvRefsOff          = off; off += wvRefsN         * 4;
  const weOffsetsOff       = off; off += weOffsetsN      * 4;
  const weRefsOff          = off; off += weRefsN         * 4;
  const weDirsOff          = off; off += align4(weDirsN);
  const wvOffsetsSeamOff   = off; off += wvOffsetsSeamN  * 4;
  const wvRefsSeamOff      = off; off += wvRefsSeamN     * 4;
  const weOffsetsSeamOff   = off; off += weOffsetsSeamN  * 4;
  const weRefsSeamOff      = off; off += weRefsSeamN     * 4;
  const weDirsSeamOff      = off; off += align4(weDirsSeamN);
  const triClusterIdOff    = off; off += triClusterIdN   * 4;
  const clusterDirOff      = off; off += clusterDirN     * 4;
  const clusterDataOff     = off; off += clusterDataBytes;
  const totalBytes         = off;

  const buf = new ArrayBuffer(totalBytes);
  const u32 = new Uint32Array(buf);

  // Header: magic + counts + (offset, count) pairs.
  u32[0] = 0x504f5431;  // 'TOP1'
  u32[1] = numVerts;
  u32[2] = numTris;
  u32[3] = numEdges;
  u32[4] = builtData.meshInfo.numOwnedCorners;
  u32[5] = builtData.meshInfo.numOwnedEdges;
  u32[6] = numWvGroups;
  u32[7] = numWeGroups;
  u32[ 8] = triEdgeIdsOff;       u32[ 9] = triEdgeIdsN;
  u32[10] = cornerIdsOff;        u32[11] = cornerIdsN;
  u32[12] = cornerPosIdxOff;     u32[13] = cornerPosIdxN;
  u32[14] = cornerNormalIdxOff;  u32[15] = cornerNormalIdxN;
  u32[16] = cornerUvIdxOff;      u32[17] = cornerUvIdxN;
  u32[18] = triVertOwnOff;       u32[19] = triVertOwnN;
  u32[20] = triEdgeOwnOff;       u32[21] = triEdgeOwnN;
  u32[22] = triTopoMaskOff;      u32[23] = triTopoMaskN;
  u32[24] = triRotationOff;      u32[25] = triRotationN;
  u32[26] = edgePos0Off;         u32[27] = edgePos0N;
  u32[28] = edgePos1Off;         u32[29] = edgePos1N;
  u32[30] = edgeTfPos0Off;       u32[31] = edgeTfPos0N;
  u32[32] = edgeTfPos1Off;       u32[33] = edgeTfPos1N;
  u32[34] = phongSetupsOff;      u32[35] = phongSetupsN;
  u32[36] = triUvsOff;           u32[37] = triUvsN;
  u32[38] = wvOffsetsOff;        u32[39] = wvOffsetsN;
  u32[40] = wvRefsOff;           u32[41] = wvRefsN;
  u32[42] = weOffsetsOff;        u32[43] = weOffsetsN;
  u32[44] = weRefsOff;           u32[45] = weRefsN;
  u32[46] = weDirsOff;           u32[47] = weDirsN;
  u32[48] = triClusterIdOff;     u32[49] = triClusterIdN;
  u32[50] = clusterDirOff;       u32[51] = clusterDirN;
  u32[52] = clusterDataOff;      u32[53] = clusterDataBytes;
  u32[54] = numClusters;         // raw count (also derivable: clusterDirN/4)
  // Seam-only weldable CSR (mode "seam" in PASS_D_WELD). Same shape
  // as slots 38-47 but populated only with groups that cross
  // cornerIds (UV / attribute seams).
  u32[55] = numWvGroupsSeam;
  u32[56] = numWeGroupsSeam;
  u32[57] = wvOffsetsSeamOff;    u32[58] = wvOffsetsSeamN;
  u32[59] = wvRefsSeamOff;       u32[60] = wvRefsSeamN;
  u32[61] = weOffsetsSeamOff;    u32[62] = weOffsetsSeamN;
  u32[63] = weRefsSeamOff;       u32[64] = weRefsSeamN;
  u32[65] = weDirsSeamOff;       u32[66] = weDirsSeamN;

  // Bulk-copy each typed-array section via .set on a view of the blob.
  new Int32Array(buf, triEdgeIdsOff,      triEdgeIdsN     ).set(builtData.triEdgeIds);
  new Int32Array(buf, cornerIdsOff,       cornerIdsN      ).set(builtData.cornerIds);
  new Int32Array(buf, cornerPosIdxOff,    cornerPosIdxN   ).set(builtData.cornerPosIdx);
  new Int32Array(buf, cornerNormalIdxOff, cornerNormalIdxN).set(builtData.cornerNormalIdx);
  new Int32Array(buf, cornerUvIdxOff,     cornerUvIdxN    ).set(builtData.cornerUvIdx);
  new Int32Array(buf, triVertOwnOff,      triVertOwnN     ).set(builtData.triVertOwnership);
  new Int32Array(buf, triEdgeOwnOff,      triEdgeOwnN     ).set(builtData.triEdgeOwnership);
  new Uint8Array(buf, triTopoMaskOff,     triTopoMaskN    ).set(builtData.triTopoMask);
  new Uint8Array(buf, triRotationOff,     triRotationN    ).set(builtData.triRotation);
  new Int32Array(buf, edgePos0Off,        edgePos0N       ).set(builtData.edgePos0);
  new Int32Array(buf, edgePos1Off,        edgePos1N       ).set(builtData.edgePos1);
  new Int32Array(buf, edgeTfPos0Off,      edgeTfPos0N     ).set(builtData.edgeTfPos0);
  new Int32Array(buf, edgeTfPos1Off,      edgeTfPos1N     ).set(builtData.edgeTfPos1);
  new Float32Array(buf, triUvsOff,        triUvsN         ).set(builtData.triUvs);

  // phongSetups: 6 vec3 fields per tri (p0, p1, p2, n0, n1, n2), in
  // the order phongSetup() returns them. Lay them out tightly so the
  // GPU shader can index by tri and field with simple arithmetic.
  const phongFloats = new Float32Array(buf, phongSetupsOff, phongSetupsN);
  const PHONG_FIELDS = ['p0','p1','p2','n0','n1','n2'];
  for (let t = 0; t < numTris; t++) {
    const s = builtData.phongSetups[t];
    const base = t * 18;
    for (let k = 0; k < 6; k++) {
      const v = s[PHONG_FIELDS[k]];
      phongFloats[base + k*3 + 0] = v[0];
      phongFloats[base + k*3 + 1] = v[1];
      phongFloats[base + k*3 + 2] = v[2];
    }
  }

  // Reinstall builtData fields as views into the blob. Indexed reads
  // (.cornerIds[i], .triRotation[t], etc.) keep working unchanged.
  builtData.blob             = buf;
  builtData.triEdgeIds       = new Int32Array(buf, triEdgeIdsOff,      triEdgeIdsN);
  builtData.cornerIds        = new Int32Array(buf, cornerIdsOff,       cornerIdsN);
  builtData.cornerPosIdx     = new Int32Array(buf, cornerPosIdxOff,    cornerPosIdxN);
  builtData.cornerNormalIdx  = new Int32Array(buf, cornerNormalIdxOff, cornerNormalIdxN);
  builtData.cornerUvIdx      = new Int32Array(buf, cornerUvIdxOff,     cornerUvIdxN);
  builtData.triVertOwnership = new Int32Array(buf, triVertOwnOff,      triVertOwnN);
  builtData.triEdgeOwnership = new Int32Array(buf, triEdgeOwnOff,      triEdgeOwnN);
  builtData.triTopoMask      = new Uint8Array(buf, triTopoMaskOff,     triTopoMaskN);
  builtData.triRotation      = new Uint8Array(buf, triRotationOff,     triRotationN);
  builtData.edgePos0         = new Int32Array(buf, edgePos0Off,        edgePos0N);
  builtData.edgePos1         = new Int32Array(buf, edgePos1Off,        edgePos1N);
  builtData.edgeTfPos0       = new Int32Array(buf, edgeTfPos0Off,      edgeTfPos0N);
  builtData.edgeTfPos1       = new Int32Array(buf, edgeTfPos1Off,      edgeTfPos1N);
  builtData.triUvs           = new Float32Array(buf, triUvsOff,        triUvsN);

  // phongSetups: each entry is a fixed object whose 6 vec3 fields are
  // Float32Array subarrays of the blob (no per-access allocation).
  // Pass B's phongEvalPos / phongEvalNrm read these as `s.p0[0]` etc.,
  // which now goes straight into the blob backing store.
  const phongViews = new Array(numTris);
  for (let t = 0; t < numTris; t++) {
    const base = t * 18;
    const view = {};
    for (let k = 0; k < 6; k++) {
      view[PHONG_FIELDS[k]] = phongFloats.subarray(base + k*3, base + k*3 + 3);
    }
    phongViews[t] = view;
  }
  builtData.phongSetups = phongViews;

  // Weldable-set CSR. Build the offset+refs+dirs arrays in the blob,
  // then expose JS-friendly per-group views so runWeldPass /
  // measureWeldSpread keep using `for (const refs of weldableVerts)`
  // and `set.refs / set.dirs` unchanged. Subarrays don't allocate
  // backing memory -- they just window into the blob.
  // Helper: pack a list of ref arrays into CSR (offsets + refs).
  // Returns nothing -- writes directly into the provided typed
  // arrays.
  function packCsrVerts(srcGroups, offsetsView, refsView) {
    let cursor = 0;
    offsetsView[0] = 0;
    for (let g = 0; g < srcGroups.length; g++) {
      const src = srcGroups[g];
      for (let i = 0; i < src.length; i++) refsView[cursor + i] = src[i];
      cursor += src.length;
      offsetsView[g + 1] = cursor;
    }
  }
  function packCsrEdges(srcGroups, offsetsView, refsView, dirsView) {
    let cursor = 0;
    offsetsView[0] = 0;
    for (let g = 0; g < srcGroups.length; g++) {
      const src = srcGroups[g];
      for (let i = 0; i < src.refs.length; i++) {
        refsView[cursor + i] = src.refs[i];
        dirsView[cursor + i] = src.dirs[i];
      }
      cursor += src.refs.length;
      offsetsView[g + 1] = cursor;
    }
  }

  const wvOffsets = new Uint32Array(buf, wvOffsetsOff, wvOffsetsN);
  const wvRefs    = new Uint32Array(buf, wvRefsOff,    wvRefsN);
  const weOffsets = new Uint32Array(buf, weOffsetsOff, weOffsetsN);
  const weRefs    = new Uint32Array(buf, weRefsOff,    weRefsN);
  const weDirs    = new Uint8Array (buf, weDirsOff,    weDirsN);
  packCsrVerts(wv, wvOffsets, wvRefs);
  packCsrEdges(we, weOffsets, weRefs, weDirs);

  const wvOffsetsSeam = new Uint32Array(buf, wvOffsetsSeamOff, wvOffsetsSeamN);
  const wvRefsSeam    = new Uint32Array(buf, wvRefsSeamOff,    wvRefsSeamN);
  const weOffsetsSeam = new Uint32Array(buf, weOffsetsSeamOff, weOffsetsSeamN);
  const weRefsSeam    = new Uint32Array(buf, weRefsSeamOff,    weRefsSeamN);
  const weDirsSeam    = new Uint8Array (buf, weDirsSeamOff,    weDirsSeamN);
  packCsrVerts(wvs, wvOffsetsSeam, wvRefsSeam);
  packCsrEdges(wes, weOffsetsSeam, weRefsSeam, weDirsSeam);

  const wvViews = new Array(numWvGroups);
  for (let g = 0; g < numWvGroups; g++) {
    wvViews[g] = wvRefs.subarray(wvOffsets[g], wvOffsets[g + 1]);
  }
  const weViews = new Array(numWeGroups);
  for (let g = 0; g < numWeGroups; g++) {
    const lo = weOffsets[g], hi = weOffsets[g + 1];
    weViews[g] = { refs: weRefs.subarray(lo, hi), dirs: weDirs.subarray(lo, hi) };
  }
  const wvViewsSeam = new Array(numWvGroupsSeam);
  for (let g = 0; g < numWvGroupsSeam; g++) {
    wvViewsSeam[g] = wvRefsSeam.subarray(wvOffsetsSeam[g], wvOffsetsSeam[g + 1]);
  }
  const weViewsSeam = new Array(numWeGroupsSeam);
  for (let g = 0; g < numWeGroupsSeam; g++) {
    const lo = weOffsetsSeam[g], hi = weOffsetsSeam[g + 1];
    weViewsSeam[g] = { refs: weRefsSeam.subarray(lo, hi), dirs: weDirsSeam.subarray(lo, hi) };
  }
  builtData.weldableVerts     = wvViews;
  builtData.weldableEdges     = weViews;
  builtData.weldableVertsSeam = wvViewsSeam;
  builtData.weldableEdgesSeam = weViewsSeam;

  // ---- triClusterId + cluster directory + per-cluster blocks ----
  // triClusterId stays as-is (per-source-tri cluster index). The
  // directory has 4 u32 per cluster (numTris, numVerts, numCEdges,
  // blockOff in bytes). Each block holds the same fields the JS
  // cluster object exposed before -- we just rewrite cluster.* and
  // cluster.topo.* as typed-array views into the blob.
  if (numClusters > 0) {
    const triClusterIdView = new Uint32Array(buf, triClusterIdOff, triClusterIdN);
    for (let i = 0; i < triClusterIdN; i++) triClusterIdView[i] = triClusterId[i];

    const clusterDir = new Uint32Array(buf, clusterDirOff, clusterDirN);

    let blockCursor = clusterDataOff;
    let firstTriCursor = 0;
    for (let c = 0; c < numClusters; c++) {
      const cl = clusters[c];
      const numTrisC  = cl.triangleSourceIds.length;
      const numVertsC = cl.vertexSourceIds.length;
      const numCEdges = cl.topo.numEdges;

      // Directory entry (5 u32 per cluster; firstTri lets shaders
      // compute the absolute mesh tri index for cluster-local tIdx
      // as firstTri + tIdx).
      clusterDir[c*CLUSTER_DIR_FIELDS + 0] = numTrisC;
      clusterDir[c*CLUSTER_DIR_FIELDS + 1] = numVertsC;
      clusterDir[c*CLUSTER_DIR_FIELDS + 2] = numCEdges;
      clusterDir[c*CLUSTER_DIR_FIELDS + 3] = blockCursor;
      clusterDir[c*CLUSTER_DIR_FIELDS + 4] = firstTriCursor;
      firstTriCursor += numTrisC;

      // Section offsets within the block (must match clusterBlockSize
      // section order exactly; GPU shader will derive these from the
      // directory entry the same way).
      let so = blockCursor;
      const vertSrcOff  = so; so += numVertsC * 4;
      const tcOff       = so; so += align4(numTrisC * 3);
      const teOff       = so; so += align4(numTrisC * 3);
      const esOff       = so; so += align4(numCEdges);
      const eeOff       = so; so += align4(numCEdges);
      const tvoOff      = so; so += align4(numTrisC * 3);
      const teoOff      = so; so += align4(numTrisC * 3);
      const ttrOff      = so; so += align4(numTrisC);

      // Bulk-copy each section into the blob then install a view in
      // place of the original cluster array. triangleSourceIds is
      // CPU-only after phase 4b -- copy into a standalone Uint32Array
      // (not blob-backed), since the GPU shader uses firstTri + tIdx.
      const vertSrc = new Uint32Array(buf, vertSrcOff, numVertsC);
      const triSrc  = new Uint32Array(numTrisC);
      triSrc.set(cl.triangleSourceIds);
      vertSrc.set(cl.vertexSourceIds);
      cl.triangleSourceIds = triSrc;
      cl.vertexSourceIds   = vertSrc;

      const tc  = new Uint8Array(buf, tcOff,  numTrisC * 3);
      const te  = new Uint8Array(buf, teOff,  numTrisC * 3);
      const es  = new Uint8Array(buf, esOff,  numCEdges);
      const ee  = new Uint8Array(buf, eeOff,  numCEdges);
      const tvo = new Uint8Array(buf, tvoOff, numTrisC * 3);
      const teo = new Uint8Array(buf, teoOff, numTrisC * 3);
      const ttr = new Uint8Array(buf, ttrOff, numTrisC);
      tc .set(cl.topo.triCornerLocal);
      te .set(cl.topo.triEdgeLocal);
      es .set(cl.topo.edgeStart);
      ee .set(cl.topo.edgeEnd);
      tvo.set(cl.topo.triVertOwnerLocal);
      teo.set(cl.topo.triEdgeOwnerLocal);
      ttr.set(cl.topo.triTopoAndRot);
      cl.topo.triCornerLocal    = tc;
      cl.topo.triEdgeLocal      = te;
      cl.topo.edgeStart         = es;
      cl.topo.edgeEnd           = ee;
      cl.topo.triVertOwnerLocal = tvo;
      cl.topo.triEdgeOwnerLocal = teo;
      cl.topo.triTopoAndRot     = ttr;

      blockCursor = so;
    }
    // Replace the topology-level triClusterId with the blob view too,
    // so the (potentially externally retained) reference points into
    // the same buffer the directory uses. Caller updates its own
    // reference via the side-channel installer below.
    builtData._triClusterIdView = triClusterIdView;
  }
}

// Install the blob-backed triClusterId on the topology after pack.
// Caller (makeTopology) does this since triClusterId lives on the
// topology object, not on builtData.
function installTopologyClusterIdView(topology) {
  if (topology.builtData && topology.builtData._triClusterIdView) {
    topology.triClusterId = topology.builtData._triClusterIdView;
    delete topology.builtData._triClusterIdView;
  }
}

// =====================================================================
// CC -> PASS_B consolidation Phase B.
//
// CC static metadata (polyInfo, triPatchMap, cpRedirect, regionData,
// irregularInfo, irregularEdgeSh) was previously bound as 6 separate
// storage buffers via CcGpuPatchCtx. Goal of the consolidation is to
// have one unified vertex compute pass that handles both phong and CC,
// reusing PASS_B's existing storage bindings. To get there without
// blowing the maxStorageBuffersPerShaderStage = 10 cap, all CC static
// gets packed into the per-topology blob (which PASS_B already binds
// as `topo`).
//
// The original packTopologyBlob runs at mesh load -- before any CC
// state exists -- and leaves header slots 67..78 zeroed. After setCc
// Scene builds the ccState, this function returns a NEW ArrayBuffer
// containing the original blob bytes followed by the CC sections,
// with the header CC offsets populated.
//
// Header slot map for CC (slots 67..86, all u32):
//   67: polyInfoOff (bytes from start of blob)
//   68: polyInfoCount (entries; each entry is 128 bytes / 32 u32)
//   69: triPatchMapOff
//   70: triPatchMapCount (entries; each entry 32 bytes / 8 u32)
//   71: cpRedirectOff
//   72: cpRedirectCount (i32 entries)
//   73: regionDataOff
//   74: regionDataCount (entries; each entry 32 bytes / 8 u32)
//   75: irregularInfoOff
//   76: irregularInfoCount (entries; each entry 64 bytes / 16 u32)
//   77: irregularEdgeShOff
//   78: irregularEdgeShCount (f32 entries)
//   ----- Phase C -----
//   79: ccVec3DataOff (bytes from start of blob)
//   80: ccVec3DataByteLen (total bytes; floor(len / 12) = #vec3s)
//   81: ccCpDataOffsetV3 (vec3-units within vec3Data, NOT bytes)
//   82: ccFullWedgeOffsetV3
//   83: ccLimitPosOffsetV3
//   84: ccLimitNrmOffsetV3
//   85: ccNumRegularPoints
//   86: ccNumIrregularVertexWedges
//
// vec3Data is per-frame (changes on deform). Static CC sections
// (slots 67..78) only change on topology rebuild. Slots 87..95
// reserved for future PASS_B unified-mode plumbing.
//
// Non-CC scenes leave slots 67..78 = 0; the eventual WGSL evalCC will
// short-circuit on a polyType-aware path so reading 0 here is safe.
//
// Note: the input blob's existing JS views (builtData.cornerPosIdx,
// builtData.triRotation, etc.) point into the OLD buffer. We don't
// rewrite them; they keep working for CPU code. Only the GPU buffer
// is replaced with the new combined bytes.
function appendCcStaticToTopoBlob(blob, ccState) {
  if (!blob || !ccState) {
    throw new Error('appendCcStaticToTopoBlob: blob and ccState required');
  }

  // 1. Run each CC packer. Sizes vary per fixture.
  const polyInfoBytes      = packPolyInfoTable(ccState);          // ArrayBuffer
  const triPatchBytes      = packTriPatchMapTable(ccState);       // ArrayBuffer
  const cpRedirectI32      = flattenCpRedirect(ccState);          // Int32Array
  const regionDataBytes    = packRegionDataTable(ccState);        // ArrayBuffer
  const irregularInfoBytes = packIrregularInfoTable(ccState);     // ArrayBuffer
  const irregularEdgeShF32 = flattenIrregularEdgeSharpness(ccState); // Float32Array
  // Phase C per-frame vec3Data. packVec3Data returns
  //   { buffer: Float32Array, cpDataOffsetV3, fullWedgeOffsetV3,
  //     limitPosOffsetV3, limitNrmOffsetV3 }
  // Offsets are in vec3 units. .buffer.byteLength is the total byte
  // size; vec3 count = byteLength / 12.
  const vec3Packed = packVec3Data(ccState);
  const vec3DataF32 = vec3Packed.buffer; // Float32Array

  // Per-rendered-tri corner source-vert index (3 u32 per tri) + per-
  // source-vert CC limit P/N (2 vec3 per vert). Built once at scene
  // load. PASS_B's flat<->CC blend reads ccCornerSrcVert[t*3+k] -> srcV
  // -> limByVert{P,N}[srcV] to fetch the corner LIMIT pos/nrm without
  // storing 18 floats per tri.
  //
  // builtData.cornerPosIdx is stale for CC mode (it reflects the source
  // mesh's pre-triPatchMap triangulation), so we can't reuse it. Build
  // a fresh per-tri-corner -> src-vert table by static convention from
  // (uvBasis, polyIndexList[patchIter]). For all 7 test fixtures the
  // canonical uvBasis values map to deterministic poly-vert offsets:
  //
  //   uvBasis [0,0, 0,1, 1,1] (quad upper-left)  -> poly[0], poly[1], poly[2]
  //   uvBasis [0,0, 1,1, 1,0] (quad lower-right) -> poly[0], poly[2], poly[3]
  //   uvBasis [0,0, 0,1, 1,0] (triangle)         -> poly[0], poly[1], poly[2]
  //
  // For the per-vert limit P/N we eval CC at each corner once per src
  // vert (not per tri-corner) and skip re-fills via filled[]. The eval
  // result is the patch surface value at the corner, which is the right
  // baseline for the blend (NOT limitPosData -- that's a stencil-based
  // limit that diverges from cc.P at corner for creased/border verts).
  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 ? ccState.triPatchMap.length : 0;
  const numSrcVerts  = ccState.mesh ? (ccState.mesh.m_posData.length / 3) | 0 : 0;
  const ccCornerSrcVertU32 = new Uint32Array(Math.max(numTris * 3, 3));
  const limByVertPF32      = new Float32Array(Math.max(numSrcVerts * 3, 3));
  const limByVertNF32      = new Float32Array(Math.max(numSrcVerts * 3, 3));
  if (numTris > 0 && ccState._ccEvalPosNrmForTopo) {
    const evalFn  = ccState._ccEvalPosNrmForTopo;
    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 t = 0; t < numTris; t++) {
      const e = tpm[t];
      const key = e.uvBasis.join(',');
      const map = UV_TO_POLY[key];
      if (!map) throw new Error('appendCcStaticToTopoBlob: unknown uvBasis ' + key);
      const ps = polyStart[e.patchIter];
      const pn = polySz[e.patchIter];
      for (let k = 0; k < 3; k++) {
        const polyOff = map[k];
        if (polyOff >= pn) { ccCornerSrcVertU32[t * 3 + k] = 0; continue; }
        const srcV = polyIdx[ps + polyOff];
        ccCornerSrcVertU32[t * 3 + k] = srcV >>> 0;
        if (!filled[srcV]) {
          const u = k === 0 ? 1 : 0, v = k === 1 ? 1 : 0, w = k === 2 ? 1 : 0;
          const r = evalFn(t, u, v, w);
          limByVertPF32[srcV * 3 + 0] = r.P[0];
          limByVertPF32[srcV * 3 + 1] = r.P[1];
          limByVertPF32[srcV * 3 + 2] = r.P[2];
          limByVertNF32[srcV * 3 + 0] = r.N[0];
          limByVertNF32[srcV * 3 + 1] = r.N[1];
          limByVertNF32[srcV * 3 + 2] = r.N[2];
          filled[srcV] = 1;
        }
      }
    }
  }

  // Section byte sizes -- 4-byte align each so subsequent offsets stay
  // on u32/f32 word boundaries (the blob is read through Uint32Array /
  // Float32Array views).
  const align4 = (n) => (n + 3) & ~3;
  const polyBytes        = align4(polyInfoBytes.byteLength);
  const triPatchBytes2   = align4(triPatchBytes.byteLength);
  const cpRedirBytes     = align4(cpRedirectI32.byteLength);
  const regionBytes      = align4(regionDataBytes.byteLength);
  const irregInfBytes    = align4(irregularInfoBytes.byteLength);
  const irregEdgeBytes   = align4(irregularEdgeShF32.byteLength);
  const vec3Bytes        = align4(vec3DataF32.byteLength);
  const ccCornerSrcBytes = align4(ccCornerSrcVertU32.byteLength);
  const limByVertPBytes  = align4(limByVertPF32.byteLength);
  const limByVertNBytes  = align4(limByVertNF32.byteLength);

  // 2. Allocate the combined buffer. CC sections follow the existing
  // blob contents verbatim. Phase C: vec3Data comes after the static
  // CC sections so the per-frame upload (writeBuffer at vec3DataOff)
  // doesn't touch any other section.
  const oldBytes = blob.byteLength;
  const ccTotalBytes = polyBytes + triPatchBytes2 + cpRedirBytes
                     + regionBytes + irregInfBytes + irregEdgeBytes
                     + vec3Bytes + ccCornerSrcBytes
                     + limByVertPBytes + limByVertNBytes;
  const newSize = oldBytes + ccTotalBytes;
  const newBuf  = new ArrayBuffer(newSize);
  new Uint8Array(newBuf).set(new Uint8Array(blob));

  // 3. Write CC sections at sequential offsets after the old data.
  let off = oldBytes;
  const polyInfoOff = off;
  new Uint8Array(newBuf, off, polyInfoBytes.byteLength).set(new Uint8Array(polyInfoBytes));
  off += polyBytes;

  const triPatchOff = off;
  new Uint8Array(newBuf, off, triPatchBytes.byteLength).set(new Uint8Array(triPatchBytes));
  off += triPatchBytes2;

  const cpRedirectOff = off;
  new Int32Array(newBuf, off, cpRedirectI32.length).set(cpRedirectI32);
  off += cpRedirBytes;

  const regionDataOff = off;
  new Uint8Array(newBuf, off, regionDataBytes.byteLength).set(new Uint8Array(regionDataBytes));
  off += regionBytes;

  const irregularInfoOff = off;
  new Uint8Array(newBuf, off, irregularInfoBytes.byteLength).set(new Uint8Array(irregularInfoBytes));
  off += irregInfBytes;

  const irregularEdgeShOff = off;
  new Float32Array(newBuf, off, irregularEdgeShF32.length).set(irregularEdgeShF32);
  off += irregEdgeBytes;

  // Phase C: vec3Data segment. Placed last so per-frame deform
  // updates can rewrite just this range via a targeted writeBuffer.
  const vec3DataOff = off;
  new Float32Array(newBuf, off, vec3DataF32.length).set(vec3DataF32);
  off += vec3Bytes;

  const ccCornerSrcOff = off;
  new Uint32Array(newBuf, off, ccCornerSrcVertU32.length).set(ccCornerSrcVertU32);
  off += ccCornerSrcBytes;

  const limByVertPOff = off;
  new Float32Array(newBuf, off, limByVertPF32.length).set(limByVertPF32);
  off += limByVertPBytes;

  const limByVertNOff = off;
  new Float32Array(newBuf, off, limByVertNF32.length).set(limByVertNF32);
  off += limByVertNBytes;

  // 4. Update header CC slots in newBuf. polyInfo: stride 128 (32 u32);
  // triPatchMap: stride 32 (8 u32); regionData: stride 32 (8 u32);
  // irregularInfo: stride 64 (16 u32). Counts are in ENTRIES (not bytes).
  const u32 = new Uint32Array(newBuf);
  u32[67] = polyInfoOff;        u32[68] = polyInfoBytes.byteLength      / 128;
  u32[69] = triPatchOff;        u32[70] = triPatchBytes.byteLength      / 32;
  u32[71] = cpRedirectOff;      u32[72] = cpRedirectI32.length;
  u32[73] = regionDataOff;      u32[74] = regionDataBytes.byteLength    / 32;
  u32[75] = irregularInfoOff;   u32[76] = irregularInfoBytes.byteLength / 64;
  u32[77] = irregularEdgeShOff; u32[78] = irregularEdgeShF32.length;
  // Phase C metadata.
  u32[79] = vec3DataOff;
  u32[80] = vec3DataF32.byteLength;
  u32[81] = vec3Packed.cpDataOffsetV3 >>> 0;
  u32[82] = vec3Packed.fullWedgeOffsetV3 >>> 0;
  u32[83] = vec3Packed.limitPosOffsetV3 >>> 0;
  u32[84] = vec3Packed.limitNrmOffsetV3 >>> 0;
  u32[85] = (ccState.patches && ccState.patches.m_numRegularPoints) >>> 0;
  u32[86] = (ccState.patches && ccState.patches.m_numIrregularVertexWedges) >>> 0;
  // Slot 87/88: per-rendered-tri corner source-vert index (3 u32 per
  // tri). Built by static uvBasis->poly-vert convention. Slot 89/90/91:
  // per-source-vert CC LIMIT P/N (separate from limitPosData -- the
  // stencil-based limit differs from cc.P at corner for creased/border
  // verts; here we store cc.P at corner as the right blend baseline).
  u32[87] = ccCornerSrcOff;     u32[88] = numTris;
  u32[89] = limByVertPOff;
  u32[90] = limByVertNOff;
  u32[91] = numSrcVerts;

  return newBuf;
}

// Re-pack ONLY the per-frame ccVec3Data segment from ccBeginFrame
// output. Returns { bytes, byteOff, vec3OffsetsV3 } -- bytes is a
// Float32Array suitable for device.queue.writeBuffer at the absolute
// byte offset `byteOff` inside topology.gpuBlob. vec3OffsetsV3 holds
// the 4 segment offsets (cpData / fullWedge / limitPos / limitNrm)
// in case the caller needs to update header slots 81..84 (it
// shouldn't unless something larger than the initial pack appears,
// which would mean the source-position list changed shape -- not
// possible on a deform).
//
// The caller must have previously called appendCcStaticToTopoBlob
// and recorded the returned blob's slot[79] (vec3DataOff) so it can
// pass it back here as `vec3DataByteOff`.
function packCcVec3DataForTopoUpload(ccState) {
  const v = packVec3Data(ccState);
  return {
    bytes: v.buffer,            // Float32Array, ready for writeBuffer
    vec3OffsetsV3: {
      cpDataOffsetV3:    v.cpDataOffsetV3,
      fullWedgeOffsetV3: v.fullWedgeOffsetV3,
      limitPosOffsetV3:  v.limitPosOffsetV3,
      limitNrmOffsetV3:  v.limitNrmOffsetV3,
    },
  };
}
