Resources Numerical accuracy Calculation policy

Decal placement and size

Works out where a clicked point lies on a face in the face's own coordinates, and the size a placed decal is stored with.

How the precision is kept

The clicked point and the face normal are carried from world coordinates into the definition's coordinates with the inverse of the placement matrix, in 64-bit numbers. A decal's width is kept as typed; its height is worked out from the picture's aspect ratio and rounded to 0.001 model units before it is stored.

Open item. Store the decal height unrounded and round only where it is shown.

Calculations under this policy

5 calculations in 1 source file. 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.

ws-shading.js ModelCraft 0.8.1 64-bit 5 calculations Reviewed

Tests tools/shading-test/browser-ui.mjs

Point and normal under the pointer

64-bit

The clicked point and the face normal in the face's own coordinates, and where on the material's tile the point lies.

p = M⁻¹ × hit; n = (b − a) × (c − a), made unit; tile uv = planar uv ÷ tile size

The point and the triangle corners go through the inverse placement matrix in 64-bit numbers. For a mesh, the triangle is chosen by the smallest plane distance under 1e-3 with weights not below −1e-4, both absolute and not scaled to the mesh size. The point and normal are handed to a new decal when one is placed.

  • ws-shading.js
  • lines 591 to 629
  • ModelCraft 0.8.1
/** What lies under screen point ev: the face, its placement path, the point and normal in its definition's coordinates, its paint,
 *  and where on its material's tile it is (tile units: a mesh's UVs, else metres along the face ÷ the tile) | null. */
export function surfaceAt(h, ev) {
  const hit = h.scene().hit(h.cam.ray(ev.x, ev.y));
  if (!hit) return null;
  const path = hit.f.path, ctx = h.model.context(path);
  if (!ctx) return null;
  const def = ctx.def, inv = invert(ctx.m), pL = apply(inv, hit.p);
  const [a, b, c] = hit.f.tris[0].map((q) => apply(inv, q)), nL = norm(cross(sub(b, a), sub(c, a)));
  // the placement's paint: the innermost placement that has one (as the renderers walk them)
  let paint = null, d = h.model.root;
  for (const iid of path) { const inst = d.insts.get(iid); if (!inst) break; if (inst.color) paint = inst.color; d = h.model.def(inst.def); }
  let face = null, uv = null;
  if (def.mesh) {
    // the shown mesh's triangle holding the point: its corners' UVs, weighted
    const M = def.shown || def.mesh, T = M.triangulate(), P = M.P, hV = M.hV, uvA = M.attrArray('uv');
    let best = null;
    for (let i = 0; i < T.n; i++) {
      const q = [0, 1, 2].map((k) => { const v = hV[T.tri[i * 3 + k]] * 3; return [P[v], P[v + 1], P[v + 2]]; });
      const w = bary(pL, q[0], q[1], q[2]);
      if (!w || w.dist > (best ? best.dist : 1e-3) || Math.min(w.a, w.b, w.c) < -1e-4) continue;
      best = { i, ...w };
    }
    if (best) {
      face = M.facePaint(T.face[best.i]);
      if (uvA) { const hs = [0, 1, 2].map((k) => T.tri[best.i * 3 + k]); uv = [0, 1].map((j) => best.a * uvA[hs[0] * 2 + j] + best.b * uvA[hs[1] * 2 + j] + best.c * uvA[hs[2] * 2 + j]); }
    }
  } else {
    face = def.faces.get(hit.f.id)?.color || null;
  }
  const p = face || paint, mid = isMaterialPaint(p) ? p.slice(4) : null;
  const m = mid && materials.has(mid) ? libMat(mid) : null;
  let tuv = null;
  if (m) {
    if (def.mesh) tuv = uv;
    else { const w = planarUV(pL, nL); tuv = [w[0] / m.size[0], w[1] / m.size[1]]; }
  }
  return { hit, path, def, pL, nL, paint: p, material: m ? mid : null, tuv };
}

Barycentric weights of a point in a triangle

64-bit

The three weights of a point against a triangle's corners and its distance from the triangle's plane.

n = ab × ac; b = ((ap × ac) · n) ÷ |n|²; c = ((ab × ap) · n) ÷ |n|²; a = 1 − b − c; dist = |ap · n| ÷ |n|

64-bit cross and dot products. A triangle with |n|² not above 1e-20 returns nothing; that limit is absolute. The first weight is 1 minus the other two, so the three always add to 1 to rounding.

  • ws-shading.js
  • lines 630 to 637
  • ModelCraft 0.8.1
function bary(p, a, b, c) {
  const ab = sub(b, a), ac = sub(c, a), n = cross(ab, ac), nn = n[0] * n[0] + n[1] * n[1] + n[2] * n[2];
  if (!(nn > 1e-20)) return null;
  const ap = sub(p, a), dist = Math.abs(ap[0] * n[0] + ap[1] * n[1] + ap[2] * n[2]) / Math.sqrt(nn);
  const cb = cross(ap, ac), cc = cross(ab, ap);
  const bw = (cb[0] * n[0] + cb[1] * n[1] + cb[2] * n[2]) / nn, cw = (cc[0] * n[0] + cc[1] * n[1] + cc[2] * n[2]) / nn;
  return { a: 1 - bw - cw, b: bw, c: cw, dist };
}

Size of a new decal

64-bit

The width and height a new decal and its material tile are stored with.

height = round(width × (picture height ÷ picture width) × 1000) ÷ 1000

The height is rounded to 0.001 model units with Math.round before it is stored, so the stored decal does not keep the picture's exact aspect ratio.

  • ws-shading.js
  • line 777
  • ModelCraft 0.8.1
const size = [st.decal.width, Math.round(st.decal.width * aspect * 1000) / 1000];

Resize a decal

64-bit

A decal's new height and depth when its width is changed.

h = round(w × size₁ ÷ size₀ × 1000) ÷ 1000; depth = max(0.01, min(w, h) ÷ 2)

The ratio is taken from the sizes already stored, which were rounded to 0.001, and the new height is rounded to 0.001 again. Repeated resizes therefore work from rounded values. The depth has an absolute floor of 0.01 model units.

  • ws-shading.js
  • lines 795 to 801
  • ModelCraft 0.8.1
function resizeDecal(id, w) {
  const d = materials.decal(id);
  if (!d || !(w > 0)) return;
  const h = Math.round((w * d.size[1]) / d.size[0] * 1000) / 1000;
  materials.updateDecal(id, { size: [w, h], depth: Math.max(0.01, Math.min(w, h) / 2) });
  syncDecalTile(id);
}

Turn a decal a quarter turn

64-bit

The decal's new width direction and swapped sizes after a quarter turn about its normal.

u′ = n × u; size′ = [size₁, size₀]

One 64-bit cross product. The result is not made unit again here, so its length is |n| × |u| × sin of the angle between them.

  • ws-shading.js
  • lines 788 to 794
  • ModelCraft 0.8.1
/** Turn a decal a quarter turn about its normal; resize it (its material's tile follows). */
function turnDecal(id) {
  const d = materials.decal(id);
  if (!d) return;
  materials.updateDecal(id, { u: cross(d.n, d.u), size: [d.size[1], d.size[0]] });
  syncDecalTile(id);
}

In detail

Method
Inverse placement matrix times the hit point; face normal from the cross product of two triangle edges; barycentric weights from cross products for the mesh triangle under the point.
Number types
64-bit numbers. The mesh UVs read for texture painting come from the mesh's 'uv' layer.
Tolerances
A mesh triangle is accepted when the point is within 1e-3 model units of its plane and no barycentric weight is below −1e-4 (both absolute). A triangle whose squared doubled area is not above 1e-20 is skipped.
Limits
Decal height and depth are stored rounded: height to 0.001 model units, depth never below 0.01.

Approach: 64-bit, direct

Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.

Every policy that takes this approach