Resources Numerical accuracy Calculation policy

UV coordinates: unwrap result, projections, packing, stretch and editing

Writes texture coordinates for each face corner from an unwrap, a box, cylinder, sphere or view projection, packs UV islands into the unit square, measures stretch and moves UVs in the UV editor.

How the precision is kept

The arithmetic is 64-bit, but UVs live in the mesh's 'uv' corner layer, which is a Float32Array, so every coordinate written is rounded to 32-bit and every later step (packing, stretch, editing) starts from those 32-bit values. The stretch per face is also returned in a Float32Array; ws-uv.js prints the mean of it to three decimals.

Open item. Store the 'uv' corner layer and the stretch result in 64-bit, or state on this policy that texture coordinates are kept in 32-bit.

Calculations under this policy

12 calculations in 3 source files. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.

mesh/uv.js ModelCraft 0.8.1 32-bit in part 10 calculations Reviewed

Tests tools/modelcraft-test/mesh.mjs

Storing an unwrap

32-bit in part

Checks which way up the flattened island came out and writes its UVs to the mesh.

signed = Σ over triangles of (u_b − u_a)(v_c − v_a) − (u_c − u_a)(v_b − v_a); u is negated when signed < 0

The signed area is summed in 64-bit from the solver's Float64Array. The UVs are then written with setAttr to the 'uv' layer, a Float32Array, which rounds each coordinate to 32-bit.

  • mesh/uv.js
  • lines 340 to 347
  • ModelCraft 0.8.1
// conformal maps can come out mirrored: keep the faces facing up
let signed = 0;
for (let t = 0; t < nT; t++) {
  const [a, b, c] = [tri[t * 3], tri[t * 3 + 1], tri[t * 3 + 2]];
  signed += (uv[b * 2] - uv[a * 2]) * (uv[c * 2 + 1] - uv[a * 2 + 1]) - (uv[c * 2] - uv[a * 2]) * (uv[b * 2 + 1] - uv[a * 2 + 1]);
}
const flip = signed < 0;
for (const f of isl.faces) for (const h of m.faceCorners(f, [])) { const w = isl.wedges[h]; putUV(m, h, flip ? -uv[w * 2] : uv[w * 2], uv[w * 2 + 1]); }

Are two UVs the same

32-bit in part

Decides whether two face corners share a UV point, which decides where UV islands end.

|u_a − u_b| ≤ 1e-7 and |v_a − v_b| ≤ 1e-7

The values compared are read from the 32-bit layer. The tolerance is absolute: between 0.5 and 1 neighboring 32-bit numbers are about 6e-8 apart, between 1 and 2 about 1.2e-7, so above 1 only identical stored values count as the same.

  • mesh/uv.js
  • lines 65 to 68
  • ModelCraft 0.8.1
const EPS_UV = 1e-7;
const same = (uv, a, b) => Math.abs(uv[a * 2] - uv[b * 2]) <= EPS_UV && Math.abs(uv[a * 2 + 1] - uv[b * 2 + 1]) <= EPS_UV;
/** Do the two faces of edge h / t share both ends' UVs? (h runs a→b in its face, t b→a: a's corners are h and next(t).) */
const uvJoined = (m, uv, h, t) => same(uv, h, m.hNx[t]) && same(uv, m.hNx[h], t);

Box projection

32-bit in part

Maps each face onto the side of a box its normal faces most.

u = (pᵢ − loᵢ) ÷ s, v = (pⱼ − loⱼ) ÷ s; u → 1 − u on the sides seen from behind

One subtraction and one division per coordinate in 64-bit, with s the largest extent of the faces' box (at least 1e-12) or the given size; the result is stored in the 32-bit uv layer. The side is chosen by the largest absolute component of the unit face normal.

  • mesh/uv.js
  • lines 384 to 401
  • ModelCraft 0.8.1
/** Each face onto the side of a box its normal faces most; `size` the box (default: the faces' largest extent). */
export function projectBox(m, faces, { size } = {}) {
  ensureUV(m);
  const list = faceList(m, faces), B = bounds(m, list), s = size || B.size, n = [0, 0, 0];
  for (const f of list) {
    m.faceNormal(f, n);
    const k = [0, 1, 2].reduce((b, i) => (Math.abs(n[i]) > Math.abs(n[b]) ? i : b), 0);
    // the two other axes, turned so the face reads the right way round from outside
    const [i, j] = k === 0 ? [1, 2] : k === 1 ? [0, 2] : [0, 1], sg = n[k] >= 0 ? 1 : -1;
    for (const h of m.faceCorners(f, [])) {
      const p = pos(m, h);
      let u = (p[i] - B.lo[i]) / s, v = (p[j] - B.lo[j]) / s;
      if ((k === 1 && sg > 0) || (k !== 1 && sg < 0)) u = 1 - u;
      putUV(m, h, u, v);
    }
  }
  return list.length;
}

Cylinder projection

32-bit in part

u is the angle round an axis through the faces' center, v the height along it.

u = (atan2(p_j − c_j, p_i − c_i) ÷ 2π + 1) mod 1; v = (p_k − lo_k) ÷ height

Computed in 64-bit and stored in the 32-bit uv layer. The height is floored at 1e-12. A face whose u values span more than 0.5 has its small values raised by 1, so u can exceed 1 there.

  • mesh/uv.js
  • lines 402 to 408
  • ModelCraft 0.8.1
/** Round an axis (through the faces' centre): u the angle round it, v the height along it. */
export function projectCylinder(m, faces, { axis = 'z' } = {}) {
  ensureUV(m);
  const list = faceList(m, faces), B = bounds(m, list), k = AX[axis], i = (k + 1) % 3, j = (k + 2) % 3, hgt = Math.max(1e-12, B.hi[k] - B.lo[k]);
  for (const f of list) wrapFace(m, f, (p) => [(Math.atan2(p[j] - B.c[j], p[i] - B.c[i]) / TAU + 1) % 1, (p[k] - B.lo[k]) / hgt]);
  return list.length;
}

Sphere projection

32-bit in part

u is the angle round the axis, v the latitude, seen from the faces' center.

d = p − c; u = (atan2(d_j, d_i) ÷ 2π + 1) mod 1; v = 1 − acos(clamp(d_k ÷ |d|, −1, 1)) ÷ π

The ratio is clamped to −1…1 before acos, which loses digits near the two poles. A point at the center divides by 1 instead of 0. The result is stored in the 32-bit uv layer.

  • mesh/uv.js
  • lines 409 to 418
  • ModelCraft 0.8.1
/** From the faces' centre: u the angle round the axis, v the latitude. */
export function projectSphere(m, faces, { axis = 'z' } = {}) {
  ensureUV(m);
  const list = faceList(m, faces), B = bounds(m, list), k = AX[axis], i = (k + 1) % 3, j = (k + 2) % 3;
  for (const f of list) wrapFace(m, f, (p) => {
    const d = sub(p, B.c), r = ln3(d) || 1;
    return [(Math.atan2(d[j], d[i]) / TAU + 1) % 1, 1 - Math.acos(Math.max(-1, Math.min(1, d[k] / r))) / Math.PI];
  });
  return list.length;
}

View projection

32-bit in part

UVs from where each corner appears on screen, fitted to the unit square.

s = max(1e-12, width, height); u = (x − x₀) ÷ s; v = 1 − (y − y₀) ÷ s

The screen points come from the caller's projection. Both axes are divided by the same s, so the proportions seen are kept. The result is stored in the 32-bit uv layer.

  • mesh/uv.js
  • lines 426 to 435
  • ModelCraft 0.8.1
/** As seen: toScreen(p) → [x, y] (the camera, the placement's matrix applied by the caller); fitted to the unit square. */
export function projectView(m, faces, toScreen) {
  ensureUV(m);
  const list = faceList(m, faces), pts = new Map();
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
  for (const f of list) for (const h of m.faceCorners(f, [])) { const s = toScreen(pos(m, h)); if (!s) continue; pts.set(h, s); x0 = Math.min(x0, s[0]); y0 = Math.min(y0, s[1]); x1 = Math.max(x1, s[0]); y1 = Math.max(y1, s[1]); }
  const s = Math.max(1e-12, x1 - x0, y1 - y0);
  for (const [h, p] of pts) putUV(m, h, (p[0] - x0) / s, 1 - (p[1] - y0) / s);   // screen y runs down
  return list.length;
}

Smallest bounding rectangle

64-bit

The rotation that makes an island's bounding rectangle smallest.

for each hull edge: θ = −atan2(Δy, Δx); area = (x₁ − x₀)·(y₁ − y₀) of the rotated hull; keep the smallest

The hull is built with a plain 64-bit cross product compared with zero, with no exact predicate. A new angle replaces the best only when its area is smaller by more than 1e-15 (absolute, in scaled UV units).

  • mesh/uv.js
  • lines 465 to 477
  • ModelCraft 0.8.1
/** The angle that makes the points' bounding rectangle smallest (each hull edge tried: the best one lies along one). */
export function bestAngle(pts) {
  const H = hull(pts);
  let best = 0, area = Infinity;
  for (let i = 0; i < H.length; i++) {
    const a = H[i], b = H[(i + 1) % H.length], ang = -Math.atan2(b[1] - a[1], b[0] - a[0]), c = Math.cos(ang), s = Math.sin(ang);
    let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
    for (const q of H) { const x = q[0] * c - q[1] * s, y = q[0] * s + q[1] * c; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
    const A = (x1 - x0) * (y1 - y0);
    if (A < area - 1e-15) { area = A; best = ang; }
  }
  return best;
}

Packing: island scale

32-bit in part

Scales each island so that its UV area matches its area on the mesh.

A₃ = Σ |(b − a) × (c − a)| ÷ 2; A₂ = Σ |UV triangle area|; k = √(A₃ ÷ A₂)

The 3D area is from 64-bit positions; the UV area is from coordinates read back from the 32-bit layer, differenced and multiplied in 64-bit. When the UV area is not above 1e-30 the scale is 1.

  • mesh/uv.js
  • lines 500 to 505
  • ModelCraft 0.8.1
let a3 = 0, a2 = 0;
for (const f of fs) { const t = triOf.get(f) || []; for (let i = 0; i < t.length; i += 3) {
  a3 += ln3(crs(sub(pos(m, t[i + 1]), pos(m, t[i])), sub(pos(m, t[i + 2]), pos(m, t[i])))) / 2;
  a2 += Math.abs((uv[t[i + 1] * 2] - uv[t[i] * 2]) * (uv[t[i + 2] * 2 + 1] - uv[t[i] * 2 + 1]) - (uv[t[i + 2] * 2] - uv[t[i] * 2]) * (uv[t[i + 1] * 2 + 1] - uv[t[i] * 2 + 1])) / 2;
} }
const k = a2 > 1e-30 ? Math.sqrt(a3 / a2) : 1;

Packing: placing the islands

32-bit in part

Places the islands' rectangles on shelves and scales the whole layout into the unit square.

pad = margin·√(total area)·1.1; widths tried: max(widest, √(sum of areas)·(0.7 + 0.05·k)), k = 0…23; uv = (offset + pad + point) ÷ side

Offsets are running sums of rectangle widths and heights in 64-bit. The final division by the side of the layout is 64-bit and the result is stored in the 32-bit uv layer, so a packed unwrap has been rounded to 32-bit twice: once when unwrapped and once here.

  • mesh/uv.js
  • lines 519 to 531
  • ModelCraft 0.8.1
const total = isl.reduce((s, I) => s + I.w * I.h, 0), pad = margin * Math.sqrt(Math.max(total, 1e-30)) * 1.1;
const rects = isl.map((I) => [I.w + 2 * pad, I.h + 2 * pad]);
const widest = Math.max(...rects.map((r) => r[0])), sum = rects.reduce((s, r) => s + r[0] * r[1], 0);
let best = null;
for (let k = 0; k < 24; k++) {
  const W = Math.max(widest, Math.sqrt(sum) * (0.7 + k * 0.05));
  const r = shelves(rects, W), side = Math.max(r.width, r.height);
  if (!best || side < best.side - 1e-15) best = { ...r, side };
}
const S = best.side || 1;
isl.forEach((I, i) => {
  const [ox, oy] = best.at[i];
  I.corners.forEach((h, q) => putUV(m, h, (ox + pad + I.pts[q][0]) / S, (oy + pad + I.pts[q][1]) / S));

Stretch per face

32-bit in part

How much each face's UVs distort it, from 0 for none to 1 for a lot.

σ₁,₂ = √(tr ÷ 2 ± √(tr² ÷ 4 − det)); angle: 1 − σ₂ ÷ σ₁; area: min(1, |ln(σ₁·σ₂)| ÷ ln 4); per face: area-weighted mean over its triangles

UV differences are read from the 32-bit layer and the per-face value is written to a Float32Array. For a nearly undistorted face tr² ÷ 4 − det subtracts two nearly equal numbers; it and det are clamped at 0. Flipped faces and faces with a UV area not above 1e-30 get 1.

  • mesh/uv.js
  • lines 537 to 575
  • ModelCraft 0.8.1
/**
 * How much each face's UVs stretch it, 0 none … 1 a lot: 'angle' 1 − σ₂/σ₁ (shapes bent), 'area' |log(σ₁σ₂)| over log 4
 * (made bigger or smaller than the mesh's average), σ the singular values of the 3-D → UV map; a flipped face is 1, a face
 * with no UV area is 1.
 */
export function stretch(m, mode = 'angle') {
  const uv = m.attrArray('uv'), out = new Float32Array(m.nf);
  if (!uv) return out.fill(1);
  const T = m.triangulate(), per = [];
  let A3 = 0, A2 = 0;
  for (let i = 0; i < T.n; i++) {
    const [a, b, c] = [T.tri[i * 3], T.tri[i * 3 + 1], T.tri[i * 3 + 2]];
    const L = local2(pos(m, a), pos(m, b), pos(m, c));
    const area3 = Math.abs(L[2] * L[5]) / 2;
    const du1 = uv[b * 2] - uv[a * 2], dv1 = uv[b * 2 + 1] - uv[a * 2 + 1], du2 = uv[c * 2] - uv[a * 2], dv2 = uv[c * 2 + 1] - uv[a * 2 + 1];
    const area2 = (du1 * dv2 - du2 * dv1) / 2;
    A3 += area3; A2 += Math.abs(area2);
    per.push({ f: T.face[i], L, d: [du1, dv1, du2, dv2], area3, area2 });
  }
  const k = A3 > 0 && A2 > 0 ? Math.sqrt(A2 / A3) : 1;
  const sum = new Float64Array(m.nf), wsum = new Float64Array(m.nf);
  for (const t of per) {
    let s;
    if (!(t.area3 > 0)) continue;
    if (!(t.area2 > 1e-30)) s = 1;
    else {
      // Jacobian of (x, y) → (u, v) for the triangle: local A (0,0), B (L2, 0), C (L4, L5)
      const [, , x1, , x2, y2] = t.L, [du1, dv1, du2, dv2] = t.d;
      const Ju = [du1 / x1, (du2 - du1 * x2 / x1) / y2], Jv = [dv1 / x1, (dv2 - dv1 * x2 / x1) / y2];
      const a = (Ju[0] * Ju[0] + Jv[0] * Jv[0]) / (k * k), c = (Ju[1] * Ju[1] + Jv[1] * Jv[1]) / (k * k), b = (Ju[0] * Ju[1] + Jv[0] * Jv[1]) / (k * k);
      const tr = a + c, det = Math.max(0, a * c - b * b), disc = Math.sqrt(Math.max(0, tr * tr / 4 - det));
      const s1 = Math.sqrt(Math.max(0, tr / 2 + disc)), s2 = Math.sqrt(Math.max(0, tr / 2 - disc));
      s = mode === 'area' ? Math.min(1, Math.abs(Math.log(Math.max(1e-12, s1 * s2))) / Math.log(4)) : s1 > 0 ? 1 - s2 / s1 : 1;
    }
    sum[t.f] += s * t.area3; wsum[t.f] += t.area3;
  }
  for (let f = 0; f < m.nf; f++) out[f] = wsum[f] > 0 ? sum[f] / wsum[f] : 0;
  return out;
}
mesh/uv-editor.js ModelCraft 0.8.1 32-bit in part 1 calculation Reviewed

Tests None found by the review

Move, rotate and scale UVs

32-bit in part

Moves, turns or scales the selected faces' UVs about their mean as the pointer moves in the UV editor.

move: (u + Δu, v + Δv); rotate by a = atan2(v₁ − c_v, u₁ − c_u) − atan2(v₀ − c_v, u₀ − c_u); scale by k = |p₁ − c| ÷ max(1e-9, |p₀ − c|)

The starting UVs are read once from the 32-bit layer and every frame is computed from them in 64-bit, so frames do not add up; the result is stored back in the 32-bit layer. The amounts come from pointer positions divided by the editor's zoom.

  • mesh/uv-editor.js
  • lines 220 to 228
  • ModelCraft 0.8.1
preview() {
  const o = this.op, [u0, v0] = this.toUV(o.p0), [u1, v1] = this.toUV(o.p), [cx, cy] = o.c;
  let fn;
  if (o.kind === 'move') fn = ([u, v]) => [u + u1 - u0, v + v1 - v0];
  else if (o.kind === 'rotate') { const a = Math.atan2(v1 - cy, u1 - cx) - Math.atan2(v0 - cy, u0 - cx), c = Math.cos(a), s = Math.sin(a); fn = ([u, v]) => [cx + (u - cx) * c - (v - cy) * s, cy + (u - cx) * s + (v - cy) * c]; }
  else { const k = Math.hypot(u1 - cx, v1 - cy) / Math.max(1e-9, Math.hypot(u0 - cx, v0 - cy)); fn = ([u, v]) => [cx + (u - cx) * k, cy + (v - cy) * k]; }
  this.h.model.meshPreview(this.tool.defId, (me) => { o.corners.forEach((h, i) => { const q = fn(o.base[i]); me.setAttr('uv', h, q); }); });
  this.h.changed?.(); this.h.view?.invalidate?.(); this.draw();
}
mesh/primitives.js ModelCraft 0.8.1 32-bit in part 1 calculation Reviewed

Tests tools/modelcraft-test/mesh.mjs, bin/test-animation.mjs

UVs and sharp edges of a primitive

32-bit in part

Stores a new primitive's positions, its UVs per face corner and the flags of its sharp edges.

sharp when n₁·n₂ < cos(sharpDeg·π ÷ 180)

Positions are copied into a Float64Array. The UVs, fractions such as i ÷ n computed in 64-bit, are assigned straight into the Float32Array of the 'uv' layer. The sharp test compares the dot product of two unit face normals with a cosine and sets a flag only.

  • mesh/primitives.js
  • lines 19 to 42
  • ModelCraft 0.8.1
/** A mesh from points, faces and per-face corner UVs ([[u, v] per corner] or null), then sharp edges where faces bend more than `sharpDeg`. */
function build(pts, faces, uvs, sharpDeg = 0) {
  const m = new Mesh();
  const p = new Float64Array(pts.length * 3);
  pts.forEach((q, i) => { p[i * 3] = q[0]; p[i * 3 + 1] = q[1]; p[i * 3 + 2] = q[2]; });
  const fc = new Int32Array(faces.map((f) => f.length)), fi = new Int32Array(faces.flat());
  const keep = m._bulk(p, pts.length, fc, fi);
  if (uvs) {
    m.addAttr('uv', 'corner', 2);
    const a = m.attrArray('uv');
    let c = 0;
    faces.forEach((f, i) => { const uv = uvs[i]; for (let k = 0; k < f.length; k++, c++) { const h = keep.cornerOf[c]; if (h >= 0 && uv) { a[h * 2] = uv[k][0]; a[h * 2 + 1] = uv[k][1]; } } });
  }
  if (sharpDeg > 0) {
    const lim = Math.cos((sharpDeg * Math.PI) / 180), A = [0, 0, 0], B = [0, 0, 0];
    for (let h = 0; h < m.nh; h++) {
      const t = m.hTw[h];
      if (t < 0 || t < h) continue;
      m.faceNormal(m.hF[h], A); m.faceNormal(m.hF[t], B);
      if (A[0] * B[0] + A[1] * B[1] + A[2] * B[2] < lim) { m.hFl[h] |= FL_SHARP; m.hFl[t] |= FL_SHARP; }
    }
  }
  return m;
}

In detail

Method
Projections divide offsets from the faces' box by its size or take atan2 and acos about its center. Packing scales each island to the same texel density, turns it to its smallest bounding rectangle over the convex hull's edges and places rectangles on shelves, trying 24 shelf widths. Stretch uses the singular values of the 3D to UV map per triangle.
Number types
Float32Array for the uv layer and for the stretch result; 64-bit numbers for everything computed between reads and writes.
Tolerances
Two UVs are the same within 1e-7 (absolute). Box sizes are floored at 1e-12; UV areas at 1e-30; the smallest rectangle and the best shelf width must improve by more than 1e-15; the UV editor's scale divides by at least 1e-9.
Limits
A UV coordinate carries about 7 digits. Between 1 and 2 neighboring 32-bit numbers are about 1.2e-7 apart, more than the 1e-7 used to decide whether two UVs are the same.

Approach: 32-bit step, flagged

One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.

Every policy that takes this approach