Resources Numerical accuracy Calculation policy

Trimmed surfaces: loops, cut tessellation and area

Keeps the part of a surface inside trim loops drawn in its (u, v) plane, cuts the tessellation along them and reports the area of the part shown.

How the precision is kept

A trim loop is a polygon of (u, v) points: a circle is a polygon of n points and a projected curve is 256 sampled closest points. Inside and crossing tests are plain 64-bit sign tests without exact arithmetic. The area is the sum of the flat triangles of the cut tessellation, so it carries the tessellation tolerance.

Open item. Integrate the trimmed area on the surface itself, or state the tessellation tolerance beside the reported area.

Calculations under this policy

11 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.

nurbs/trim.js ModelCraft 0.8.1 64-bit 11 calculations Reviewed

Tests None found by the review

Signed area of a loop

64-bit

The signed area of a loop in the (u, v) plane; its sign tells which way the loop runs.

a = Σ (uᵢ·vᵢ₊₁ − uᵢ₊₁·vᵢ) ÷ 2

A plain sum of cross terms in vertex order, halved at the end.

  • nurbs/trim.js
  • line 33
  • ModelCraft 0.8.1
const shoelace = (L) => { let a = 0; for (let i = 0; i < L.length; i++) { const p = L[i], q = L[(i + 1) % L.length]; a += p[0] * q[1] - q[0] * p[1]; } return a / 2; };

Point inside a loop

64-bit

Decides whether a (u, v) point is inside a loop.

count edges with (v_a > v) ≠ (v_b > v) and u < (u_b − u_a)·(v − v_a) ÷ (v_b − v_a) + u_a

The crossing-number test with one division per edge and no tolerance; as the code's comment says, a point on the boundary may go either way.

  • nurbs/trim.js
  • lines 36 to 44
  • ModelCraft 0.8.1
/** Crossing-number test: is [x, y] inside the polygon L (a boundary point may go either way). */
function inPoly(L, x, y) {
  let inside = false;
  for (let i = 0, j = L.length - 1; i < L.length; j = i++) {
    const a = L[i], b = L[j];
    if ((a[1] > y) !== (b[1] > y) && x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0]) inside = !inside;
  }
  return inside;
}

Do two segments cross

64-bit

Decides whether two loop segments properly cross.

t, u = cross products ÷ d; crossing when 1e-12 < t < 1 − 1e-12 and 1e-12 < u < 1 − 1e-12

Parallel segments are recognized only when the denominator is exactly 0. Segments that touch at an end, within 1e-12 of the parameter, are not counted as crossing.

  • nurbs/trim.js
  • lines 46 to 52
  • ModelCraft 0.8.1
function segCross(p, q, r, s) {
  const d = (q[0] - p[0]) * (s[1] - r[1]) - (q[1] - p[1]) * (s[0] - r[0]);
  if (d === 0) return false;
  const t = ((r[0] - p[0]) * (s[1] - r[1]) - (r[1] - p[1]) * (s[0] - r[0])) / d;
  const u = ((r[0] - p[0]) * (q[1] - p[1]) - (r[1] - p[1]) * (q[0] - p[0])) / d;
  return t > 1e-12 && t < 1 - 1e-12 && u > 1e-12 && u < 1 - 1e-12;
}

Cleaning a loop

64-bit

Checks a loop's points, clamps them to the patch and drops repeats.

refused when |area| < 1e-14·(u range)·(v range)

Points outside the patch's parameter range are moved onto its border. Repeated points are dropped only when they are exactly equal. The degeneracy threshold scales with the domain.

  • nurbs/trim.js
  • lines 71 to 85
  • ModelCraft 0.8.1
function cleanLoop(x, s) {
  if (!Array.isArray(x)) return null;
  const [ua, ub] = S.domainU(s), [va, vb] = S.domainV(s);
  const L = [];
  for (const p of x) {
    if (!Array.isArray(p) || p.length < 2 || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) fail('nurbs.err.trim_loop');
    const q = [Math.min(ub, Math.max(ua, p[0])), Math.min(vb, Math.max(va, p[1]))];
    const last = L[L.length - 1];
    if (!last || last[0] !== q[0] || last[1] !== q[1]) L.push(q);
  }
  if (L.length > 1 && L[0][0] === L[L.length - 1][0] && L[0][1] === L[L.length - 1][1]) L.pop();
  if (L.length > MAX_LOOP_POINTS) fail('nurbs.err.trim_loop');
  if (L.length < 3 || Math.abs(shoelace(L)) < 1e-14 * (ub - ua) * (vb - va)) fail('nurbs.err.trim_loop');
  return L;
}

Circle loop

64-bit

A circular trim loop given by a center and radius as shares of the parameter range.

pointᵢ = [u_a + (u_b − u_a)·(u + r·cos aᵢ), v_a + (v_b − v_a)·(v + r·sin aᵢ)], aᵢ = 2π·i ÷ n

The loop is an n-sided polygon (64 by default, 96 from the trim command), round in the unit square of parameters. On a patch whose two parameter ranges or speeds differ it is not a circle on the surface.

  • nurbs/trim.js
  • lines 105 to 113
  • ModelCraft 0.8.1
export function circleLoop(s, u, v, r, n = 64) {
  const [ua, ub] = S.domainU(s), [va, vb] = S.domainV(s);
  const out = [];
  for (let i = 0; i < n; i++) {
    const a = 2 * Math.PI * i / n;
    out.push([ua + (ub - ua) * (u + r * Math.cos(a)), va + (vb - va) * (v + r * Math.sin(a))]);
  }
  return out;
}

Loop from a curve on the surface

64-bit

Projects a closed 3D curve into the surface's (u, v) plane as a trim loop.

256 points of the curve, each → closest point (u, v) on the surface

The loop is the polygon of the sampled closest points, each started from the previous one. A sample farther than the tolerance (default 0.1) from the surface refuses the curve; the number in that error is rounded to 0.001 for the message only.

  • nurbs/trim.js
  • lines 115 to 130
  • ModelCraft 0.8.1
export function curveLoop(s, curve, { tol = 0.1, samples = 256 } = {}) {
  const size = S.size(s);
  const [a, b] = C.domain(curve);
  const e = C.endsOf(curve);
  if (V.dist(e.start, e.end) > Math.max(tol, 1e-6 * size)) fail('nurbs.err.trim_open');
  const L = [];
  let uv0 = null;
  for (let i = 0; i < samples; i++) {
    const p = C.point(curve, a + (b - a) * i / samples);
    const r = S.closestPoint(s, p, uv0 ? { uv0 } : {});
    if (r.d > tol) fail('nurbs.err.trim_off', { mm: Math.round(r.d * 1e3) / 1e3 });
    uv0 = [r.u, r.v];
    L.push([r.u, r.v]);
  }
  return L;
}

Moving loop points off grid lines

64-bit

Shifts loop points that fall on a tessellation grid line, and keeps all of them inside the patch.

d = 1e-6 × parameter range; a point within d ÷ 2 of a grid line moves to line + d; every point is clamped to [start + d, end − d]

The shown region is therefore cut along loops that can differ from the stored ones by up to 1e-6 of the parameter range, including a loop drawn exactly on the patch's border. The stored trim is not changed.

  • nurbs/trim.js
  • lines 245 to 252
  • ModelCraft 0.8.1
const du = (up[nu] - up[0]) * 1e-6, dv = (vp[nv] - vp[0]) * 1e-6;

// loops: off the grid lines (a vertex on a line is moved a hair), inside the patch
const nudge = (x, lines, d) => { for (const L of lines) if (Math.abs(x - L) < d * 0.5) return L + d; return x; };
const loops = [];
if (trim.outer) loops.push(trim.outer);
for (const h of trim.holes) loops.push(h);
const fixed = loops.map((L) => L.map((p) => [Math.min(up[nu] - du, Math.max(up[0] + du, nudge(p[0], up, du))), Math.min(vp[nv] - dv, Math.max(vp[0] + dv, nudge(p[1], vp, dv)))]));

Where a loop crosses the grid

64-bit

The points where a loop segment crosses the tessellation's parameter lines.

t = (line − p) ÷ (q − p); point = p + t·(q − p)

One division and one linear blend per crossing. Vertices are registered by the text of their two coordinates, so the cells on both sides of a line share the same vertex and no crack opens.

  • nurbs/trim.js
  • lines 270 to 274
  • ModelCraft 0.8.1
const i0 = cellOf(up, p[0]), i1 = cellOf(up, q[0]);
for (let k = Math.min(i0, i1) + 1; k <= Math.max(i0, i1); k++) { const t = (up[k] - p[0]) / (q[0] - p[0]); cuts.push({ t, u: up[k], v: p[1] + t * (q[1] - p[1]), line: 'u', k }); }
const j0 = cellOf(vp, p[1]), j1 = cellOf(vp, q[1]);
for (let k = Math.min(j0, j1) + 1; k <= Math.max(j0, j1); k++) { const t = (vp[k] - p[1]) / (q[1] - p[1]); cuts.push({ t, u: p[0] + t * (q[0] - p[0]), v: vp[k], line: 'v', k }); }
cuts.sort((a, b) => a.t - b.t);

Triangulating a cut cell

64-bit

Triangles for a polygon left in a grid cell after cutting.

Ears are chosen by the sign of a 64-bit cross product; an absolute value below 1e-18 is treated as collinear and emitted as a flat triangle so that no shared vertex is dropped. If no ear is found the rest is filled as a fan.

  • nurbs/trim.js
  • lines 168 to 197
  • ModelCraft 0.8.1
/** A counter-clockwise ring (array of {x, y, id}) → triangles (id triples). Collinear vertices keep a degenerate triangle, so a
 *  vertex a neighbour also uses is never dropped (no crack). Falls back to a fan when numbers leave no ear. */
function earClip(ring) {
  const tris = [];
  let r = ring.slice();
  let guard = r.length * r.length + 16;
  while (r.length > 3 && guard-- > 0) {
    const n = r.length;
    let cut = -1;
    for (let i = 0; i < n && cut < 0; i++) {
      const a = r[(i + n - 1) % n], b = r[i], c = r[(i + 1) % n];
      const cr = cross3(a, b, c);
      if (Math.abs(cr) < 1e-18) { cut = i; break; }   // collinear (or a spike): emit it flat and drop b
      if (cr < 0) continue;
      let clear = true;
      for (let k = 0; k < n; k++) {
        const p = r[k];
        if (p === a || p === b || p === c || sameXY(p, a) || sameXY(p, b) || sameXY(p, c)) continue;
        if (inTri(a, b, c, p)) { clear = false; break; }
      }
      if (clear) cut = i;
    }
    if (cut < 0) break;
    const a = r[(cut + n - 1) % n], b = r[cut], c = r[(cut + 1) % n];
    tris.push([a.id, b.id, c.id]);
    r.splice(cut, 1);
  }
  if (r.length >= 3) for (let i = 1; i + 1 < r.length; i++) tris.push([r[0].id, r[i].id, r[i + 1].id]);
  return tris;
}

Area of the shown part

64-bit

The area of a trimmed surface.

A = Σ |(b − a) × (c − a)| ÷ 2 over the triangles of the cut tessellation

The sum of flat triangle areas in 64-bit, added in order. It is the area of the facets at the tessellation tolerance inside polygonal loops, not an integral over the surface, so it differs from surface.area by an amount set by that tolerance.

  • nurbs/trim.js
  • lines 382 to 392
  • ModelCraft 0.8.1
export function area(s, trim, opts = {}) {
  const t = tessellate(s, trim, opts);
  let a = 0;
  const P = t.positions;
  for (let i = 0; i < t.indices.length; i += 3) {
    const o = [t.indices[i] * 3, t.indices[i + 1] * 3, t.indices[i + 2] * 3];
    const p = o.map((k) => [P[k], P[k + 1], P[k + 2]]);
    a += V.len(V.cross(V.sub(p[1], p[0]), V.sub(p[2], p[0]))) / 2;
  }
  return a;
}

Area in the parameter plane

64-bit

The area of the shown part in (u, v).

outer loop's area (or the whole domain) + Σ holes' signed areas

Holes run clockwise, so their signed areas are negative and are added. With no outer loop the domain's rectangle is used.

  • nurbs/trim.js
  • lines 153 to 158
  • ModelCraft 0.8.1
export const uvArea = (trim, s = null) => {
  if (!trim) return null;
  let a = trim.outer ? shoelace(trim.outer) : (s ? (S.domainU(s)[1] - S.domainU(s)[0]) * (S.domainV(s)[1] - S.domainV(s)[0]) : 0);
  for (const h of trim.holes) a += shoelace(h);   // holes are clockwise: negative
  return a;
};

In detail

Method
Point in polygon by crossing number; segment crossing by two parameters; ear clipping with hole bridges; a tessellation grid cut where loops cross its lines; area as a sum of triangle areas.
Number types
64-bit numbers; Float64Array positions, normals and parameters; Uint32Array indices.
Tolerances
Crossing parameters must lie in (1e-12, 1 − 1e-12). A loop is refused when its (u, v) area is below 1e-14 of the domain's. A projected curve must close within max(tol, 1e-6 × size) and lie within tol (default 0.1) of the surface. Loop points are kept 1e-6 of the parameter range away from grid lines and from the patch's border. Ear clipping treats |cross| < 1e-18 as collinear.
Limits
The area of a trimmed surface is from facets at the tessellation tolerance (0.05 in the trim commands of tools.js), while an untrimmed surface's area is integrated. A point exactly on a loop may count as inside or outside.

Approach: 64-bit, stated limit

The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.

Every policy that takes this approach