Resources Numerical accuracy Calculation policy

Sketch profiles for exact solids

Turns a sketch's lines, arcs, circles and splines into closed loops, decides which loops are holes, and hands the exact curves to the solid kernel.

How the precision is kept

Curves stay exact: lines by their end points, circles by center and radius, splines by their poles. End points that meet are merged onto one shared point so neighboring edges join at a single vertex. Arcs are passed to OpenCascade as three points. Polylines of the curves are used only to decide nesting and orientation.

Open item. Build profile arcs from the sketch's center and radius rather than from three points, so merging end points cannot change the radius.

Calculations under this policy

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

brep.js PartCraft 0.8.1 64-bit 6 calculations Reviewed

Tests bin/test-partcraft.mjs

Closed loops with their curves

64-bit

Collects a sketch's curves into closed loops, keeping each curve's exact definition.

tol = 1e-6 × max(1, extent); ends within 10 × tol share a node; arc mid = c + r (cos, sin)(a₀ + sweep ÷ 2)

Each segment's ends are replaced by the shared node's coordinates, which are those of the first end point met there. The 3-decimal rounding in this function is only in error messages.

  • brep.js
  • lines 185 to 255
  • PartCraft 0.8.1
/**
 * Closed loops of a sketch's non-construction lines, arcs and circles, keeping each curve:
 * → { loops: [{ segs: [{ id, type, a, b, mid?, c?, r? }], poly }] } or { error }.
 */
export function profileCurves(sk) {
  const tol = 1e-6 * Math.max(1, extent(sk));
  const loops = [];
  const segs = [];
  for (const e of sk.entities) {
    if (e.construction) continue;
    if (e.type === 'circle') {
      if (!(e.r > tol)) return { error: tr('model.brep.circle_no_size') };
      const c = pointXY(sk, e.c);
      loops.push({ segs: [{ id: e.id, type: 'circle', c, r: e.r }], poly: tessCircle(c, e.r, 128) });
    } else if (e.type === 'line') {
      const a = pointXY(sk, e.p1), b = pointXY(sk, e.p2);
      if (Math.hypot(b[0] - a[0], b[1] - a[1]) <= tol) return { error: tr('model.brep.line_no_length') };
      segs.push({ id: e.id, type: 'line', a, b, pts: [a, b] });
    } else if (e.type === 'arc') {
      const { c, r, a0, sweep } = arcAngles(sk, e);
      if (!(r > tol)) return { error: tr('model.brep.arc_no_size') };
      const pts = tessArc(sk, e, 128);
      segs.push({ id: e.id, type: 'arc', a: pts[0], b: pts[pts.length - 1], c, r, mid: [c[0] + r * Math.cos(a0 + sweep / 2), c[1] + r * Math.sin(a0 + sweep / 2)], pts });
    } else if (e.type === 'spline') {
      // exact B-spline (sketch-spline.js); the polyline only decides nesting
      let bs;
      try { bs = splineCurve(sk, e); } catch (err) { return { error: err.message }; }
      const pts = SP.tessellate(bs, 16);
      if (e.closed) { pts.pop(); loops.push({ segs: [{ id: e.id, type: 'spline', bs, closed: true }], poly: pts }); }
      else segs.push({ id: e.id, type: 'spline', a: pts[0], b: pts[pts.length - 1], bs, pts });
    }
  }
  const nodes = [];
  const nodeOf = (p) => {
    for (let i = 0; i < nodes.length; i++) if (Math.hypot(nodes[i].p[0] - p[0], nodes[i].p[1] - p[1]) <= tol * 10) return i;
    nodes.push({ p, ends: [] });
    return nodes.length - 1;
  };
  for (const s of segs) {
    s.n0 = nodeOf(s.a); s.n1 = nodeOf(s.b);
    nodes[s.n0].ends.push(s); nodes[s.n1].ends.push(s);
  }
  const fx = (v) => String(+v.toFixed(3));
  for (const n of nodes) {
    if (n.ends.length === 1) return { error: tr('model.brep.profile_open', { x: fx(n.p[0]), y: fx(n.p[1]) }), at: n.p };
    if (n.ends.length > 2) return { error: tr('model.brep.profile_branches', { x: fx(n.p[0]), y: fx(n.p[1]) }), at: n.p };
  }
  const used = new Set();
  for (const s0 of segs) {
    if (used.has(s0)) continue;
    const out = [], poly = [];
    const start = s0.n0;
    let s = s0, from = start;
    for (let guard = 0; guard <= segs.length; guard++) {
      used.add(s);
      const fwd = s.n0 === from;
      const to = fwd ? s.n1 : s.n0;
      // both ends sit exactly on the shared node, so neighbouring edges meet at one vertex
      out.push({ id: s.id, type: s.type, a: nodes[from].p, b: nodes[to].p, mid: s.mid, c: s.c, r: s.r, bs: s.bs && (fwd ? s.bs : SP.reverse(s.bs)) });
      const pts = fwd ? s.pts : [...s.pts].reverse();
      for (let i = 0; i < pts.length - 1; i++) poly.push(pts[i]);
      if (to === start) break;
      const nx = nodes[to].ends.find((q) => q !== s);
      if (!nx || used.has(nx)) break;
      from = to; s = nx;
    }
    if (poly.length < 3) return { error: tr('model.brep.loop_too_few') };
    loops.push({ segs: out, poly });
  }
  return { loops };
}

Arc edge through three points

64-bit

Builds an arc of the profile in the kernel.

arc through a (start node), mid, b (end node)

OpenCascade is given three points, not the center and radius. The midpoint is computed from the sketch's center, radius and angles; the ends are the merged nodes. When the three are not exactly on the sketch's circle, the kernel's arc has its own center and radius.

  • brep.js
  • lines 307 to 308
  • PartCraft 0.8.1
const arc = T(T(new oc.GC_MakeArcOfCircle(H.pnt(W(s.a)), H.pnt(W(s.mid)), H.pnt(W(s.b)))).Value());
e = T(new oc.BRepBuilderAPI_MakeEdge(arc, vtx(s.a), vtx(s.b))).Edge();

Loop orientation

64-bit

Whether a loop runs counterclockwise, from the sign of its area.

A = ½ Σ (x_j y_i − x_i y_j)

The shoelace sum added in order. Only its sign is used here, to orient wires and to find the inner side. For a loop far from the origin the terms are large and nearly cancel, which matters only for a loop of almost no area.

  • brep.js
  • lines 273 to 277
  • PartCraft 0.8.1
function signedArea(poly) {
  let s = 0;
  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) s += poly[j][0] * poly[i][1] - poly[i][0] * poly[j][1];
  return s / 2;
}

Point just inside a loop

64-bit

A test point a small step inside the loop's longest edge, used to decide nesting.

p = midpoint of longest edge + normal × 1e-6 × max(1, edge length)

The step scales with the edge length (at least 1e-6). On the polyline of a circle or arc the point is inside the polygon, which lies inside the true curve.

  • brep.js
  • lines 342 to 353
  • PartCraft 0.8.1
/** A point just inside a loop polygon's first edge (for nesting tests that do not hit shared lines). */
function innerPoint(poly) {
  // the centroid of a triangle fan sample is fragile for concave loops; use a point a hair to the inner side of the longest edge
  let best = 0, bi = 0;
  for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; const l = Math.hypot(b[0] - a[0], b[1] - a[1]); if (l > best) { best = l; bi = i; } }
  const a = poly[bi], b = poly[(bi + 1) % poly.length];
  const m = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
  const nx = -(b[1] - a[1]) / best, ny = (b[0] - a[0]) / best;
  const s = Math.sign(signedArea(poly)) || 1;   // left of the edge is inside for a CCW loop
  const eps = 1e-6 * Math.max(1, best);
  return [m[0] + nx * eps * s, m[1] + ny * eps * s];
}

Which loops are holes

64-bit

Counts how many other loops contain each loop; odd depth is a hole.

depth_i = number of loops whose polygon contains innerPoint(loop_i); face when depth is even

A yes-or-no decision made on the loops' polylines with ordinary 64-bit arithmetic, not an exact predicate. Two loops closer together than the polyline's deviation from the true curve can be nested wrongly.

  • brep.js
  • lines 318 to 324
  • PartCraft 0.8.1
const depth = loops.map((L, i) => loops.reduce((k, M, j) => k + (j !== i && insideLoops([M.poly], innerPoint(L.poly)) ? 1 : 0), 0));
const pln = T(new oc.gp_Pln(T(new oc.gp_Ax3(H.pnt(V.add(fr.o, V.mul(fr.n, lift))), H.dir(fr.n), H.dir(fr.x)))));
const faces = [];
for (let i = 0; i < n; i++) {
  // regions (picked contours, brep-design.js regionsAt): each { outer, holes } overrides the even-odd rule
  const reg = regions ? regions.find((g) => g.outer === i) : null;
  if (regions ? !reg : depth[i] % 2) continue;

Stored edge and face reference points

64-bit

The point saved with a picked edge or face so it can be found again after a rebuild.

at = coordinates rounded to 6 decimals

The saved point is rounded to 1e-6 mm with toFixed(6). It is used only to choose the nearest of several edges or faces with the same name, not as geometry.

  • brep.js
  • lines 108 to 109
  • PartCraft 0.8.1
/** A reference to an edge of a regen, for a fillet or chamfer. */
export const edgeRef = (e) => ({ faces: [...e.faces], at: e.at.map((v) => +v.toFixed(6)) });

In detail

Method
Node merge by distance, loop walk, even-odd nesting by a point just inside each loop, orientation by the sign of the shoelace area.
Number types
64-bit numbers.
Tolerances
tol = 1e-6 × max(1, sketch extent). Circles, arcs and lines smaller than tol are refused. End points within 10 × tol are merged. The inside test point sits 1e-6 × max(1, longest edge) inside the loop's longest edge.
Limits
A merged end point moves a curve's end by up to 10 × tol; an arc rebuilt through its moved ends has a slightly different center and radius than the sketch's.

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