Resources Numerical accuracy Calculation policy

Drafting lines and hatch regions

Keeps the drafting lines and hatch regions drawn in a view, checks them, moves them and works out the strokes of each hatch pattern inside its region.

How the precision is kept

Points are plan millimeters in 64-bit. Reading multiplies each stored coordinate by the file's length unit; writing goes through the model writer, which rounds coordinates to 6 decimals of the file's unit. Hatch strokes are clipped to the region with a parametric line intersection and an even-odd test at the middle of each piece.

Calculations under this policy

11 calculations in 2 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.

drafting.js BimCraft 0.8.1 64-bit 10 calculations Reviewed

Tests tools/bimcraft-test/views2-test.mjs

Area of a region

64-bit

The signed area of a closed outline, used to refuse a hatch with no area.

area = ½ Σ (xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)

The shoelace sum on the coordinates as given, not moved to a local origin. Far from the plan origin the products are large and nearly cancel; the result is only compared with 100 mm².

  • drafting.js
  • line 54
  • BimCraft 0.8.1
const area2 = (p) => { let a = 0; for (let i = 0; i < p.length; i++) { const q = p[i], r = p[(i + 1) % p.length]; a += q[0] * r[1] - r[0] * q[1]; } return a / 2; };

Points taken from a drawing gesture

64-bit

The points of a new line or region, without repeats.

A point is dropped when it lies within 0.5 mm of the one kept before it (Math.hypot of the differences, absolute). Points that are not finite numbers are dropped. The coordinates themselves are kept unrounded.

  • drafting.js
  • lines 55 to 59
  • BimCraft 0.8.1
const clean = (pts) => {
  const out = [];
  for (const q of pts || []) { if (!Array.isArray(q) || !Number.isFinite(+q[0]) || !Number.isFinite(+q[1])) continue; const p = [+q[0], +q[1]]; const l = out[out.length - 1]; if (!l || Math.hypot(p[0] - l[0], p[1] - l[1]) > 0.5) out.push(p); }
  return out;
};

Points read from the file

64-bit

A drafting item's points in millimeters, from the IFC polyline that stores them.

mm = stored coordinate × file length unit in mm

Each coordinate is multiplied once by the file's unit. A polyline whose last point is within 0.01 mm of its first is read as closed and the repeated point is removed.

  • drafting.js
  • lines 72 to 89
  • BimCraft 0.8.1
const curve = (h) => {
  const pl = h ? w.api.GetLine(w.mid, h, false) : null, pts = [];
  for (const ph of pl?.Points || []) { const p = w.api.GetLine(w.mid, ph.value, false); const c = (p?.Coordinates || []).map((v) => Number(val(v)) * w.unitMm); pts.push([c[0], c[1]]); }
  return pts;
};
const sh = a.Representation?.value ? w.api.GetLine(w.mid, a.Representation.value, false) : null;
for (const h of sh?.Representations || []) {
  const r = w.api.GetLine(w.mid, h.value, false);
  for (const it of r?.Items || []) {
    const item = w.api.GetLine(w.mid, it.value, false);
    if (item?.OuterBoundary) { d.pts = curve(item.OuterBoundary.value); d.holes = (item.InnerBoundaries || []).map((x) => curve(x.value)); }
    else for (const ch of item?.Elements || []) d.pts = curve(ch.value);
  }
}
const P = d.pts;
if (P.length > 2 && Math.hypot(P[0][0] - P[P.length - 1][0], P[0][1] - P[P.length - 1][1]) < 0.01) { P.pop(); d.closed = true; }
if (d.kind === 'hatch') { d.closed = true; d.holes = d.holes.map((h) => (h.length > 2 && Math.hypot(h[0][0] - h[h.length - 1][0], h[0][1] - h[h.length - 1][1]) < 0.01 ? h.slice(0, -1) : h)); }
if (P.length >= 2) out.push(d);

Hatch spacing and angle written to the file

64-bit

The IFC fill style of a pattern: one hatching per line family with its spacing and angle.

spacing = gap × scale in file units; angle = degrees × π ÷ 180 (× 180 ÷ π when the file's angles are degrees)

The spacing is rounded to 6 decimals of the file's length unit and the angle to 9 decimals (round from model-write.js). The style is shared by a name that carries the scale rounded to 3 decimals, so two scales that agree to 3 decimals share one style.

  • drafting.js
  • lines 101 to 115
  • BimCraft 0.8.1
/** The fill area style of a pattern at a scale, shared by name (found in the file, else made). */
function fillStyle(w, pattern, scale) {
  const name = 'CadCraft drafting: ' + pattern + ' x' + round(scale, 3);   // i18n-ignore: a style name kept in the IFC (plain ASCII)
  for (const id of ids(w.api, w.mid, 'IFCFILLAREASTYLE')) { const l = w.api.GetLine(w.mid, id, false); if (l && val(l.Name) === name) return id; }
  const P = PATTERNS.find((p) => p.key === pattern) || PATTERNS[1];
  const black = () => w.make('IFCCOLOURRGB', null, 0, 0, 0);
  const styles = [];
  if (P.solid) styles.push(w.H(w.make('IFCCOLOURRGB', null, 0.35, 0.35, 0.35)));
  for (const f of P.families) {
    const cs = w.v4 ? w.make('IFCCURVESTYLE', null, null, null, w.H(black()), null) : w.make('IFCCURVESTYLE', null, null, null, w.H(black()));
    styles.push(w.H(w.make('IFCFILLAREASTYLEHATCHING', w.H(cs), w.T('IFCPOSITIVELENGTHMEASURE', round(w.u(f.gap * scale))), null, null, round(f.angle * Math.PI / 180 * angleK(w), 9))));
  }
  if (!styles.length) styles.push(w.H(black()));   // insulation: drawn by BimCraft; other programs get a plain black fill style
  return w.v4 ? w.make('IFCFILLAREASTYLE', w.L(name), styles, null) : w.make('IFCFILLAREASTYLE', w.L(name), styles);
}

Is the item big enough

64-bit

Refuses a hatch with fewer than 3 corners or under 100 mm², and a line under 10 mm.

L = Σ √(Δx² + Δy²); refuse when L < 10 mm or |area| < 100 mm²

The length is a sum of Math.hypot over the segments in order. Both limits are absolute values in millimeters.

  • drafting.js
  • lines 126 to 134
  • BimCraft 0.8.1
function check(d) {
  if (d.kind === 'hatch') {
    if (d.pts.length < 3 || Math.abs(area2(d.pts)) < 100) refuse('model.err.dr.area');
    if (!PATTERN_KEYS.includes(d.pattern)) refuse('model.err.dr.pattern');
  } else {
    let L = 0; for (let i = 1; i < d.pts.length; i++) L += Math.hypot(d.pts[i][0] - d.pts[i - 1][0], d.pts[i][1] - d.pts[i - 1][1]);
    if (d.pts.length < 2 || L < 10) refuse('model.err.dr.short');
  }
}

Move drafting items

64-bit

Every point of the picked items moved by one offset.

(x, y) → (x + dx, y + dy)

The points are read from the file, the offset is added in 64-bit and the shape is written again, so each move passes through the writer's rounding once.

  • drafting.js
  • lines 174 to 182
  • BimCraft 0.8.1
export function moveDrafting(guids, dx, dy) {
  return op((w) => {
    if (!Number.isFinite(dx) || !Number.isFinite(dy)) refuse('model.err.length');
    const list = findAll(w, guids);
    const mv = (pts) => pts.map(([x, y]) => [x + dx, y + dy]);
    for (const d of list) writeShape(w, d.id, { ...d, pts: mv(d.pts), holes: d.holes.map(mv) });
    return { id: list[0].id, ids: list.map((d) => d.id) };
  });
}

Is a point inside the region

64-bit

Whether a point lies inside the outline and outside its holes.

even-odd count of edges crossed by a ray toward +x

A plain ray-crossing test with no tolerance. An edge is tested only when its ends lie on opposite sides of the ray, so yj − yi is never zero in the division. A point exactly on an edge can fall either way.

  • drafting.js
  • lines 197 to 205
  • BimCraft 0.8.1
// ------------------------------------------------------------------------------------------------------- the patterns
function inRings(x, y, rings) {
  let inside = false;
  for (const p of rings) for (let i = 0, j = p.length - 1; i < p.length; j = i++) {
    const [xi, yi] = p[i], [xj, yj] = p[j];
    if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside;
  }
  return inside;
}

Clip a stroke to the region

64-bit

The pieces of a straight stroke that lie inside the region.

t = ((c − a) × e) ÷ (d × e), u = ((c − a) × d) ÷ (d × e); keep 0 < t < 1, 0 ≤ u ≤ 1

The divisor is the cross product of the stroke and the edge; below 1e-12 in absolute value the two are treated as parallel. That limit is not scaled to the lengths of the stroke and the edge. Pieces shorter than 1e-9 of the stroke are skipped and the middle of each piece decides inside or outside.

  • drafting.js
  • lines 206 to 227
  • BimCraft 0.8.1
export function clipSegment(rings, a, b) {
  const dx = b[0] - a[0], dy = b[1] - a[1], ts = [0, 1];
  for (const p of rings) for (let i = 0; i < p.length; i++) {
    const c = p[i], d = p[(i + 1) % p.length], ex = d[0] - c[0], ey = d[1] - c[1];
    const den = dx * ey - dy * ex;
    if (Math.abs(den) < 1e-12) continue;
    const t = ((c[0] - a[0]) * ey - (c[1] - a[1]) * ex) / den, u = ((c[0] - a[0]) * dy - (c[1] - a[1]) * dx) / den;
    if (t > 0 && t < 1 && u >= 0 && u <= 1) ts.push(t);
  }
  ts.sort((m, n) => m - n);
  const out = [];
  for (let i = 0; i + 1 < ts.length; i++) {
    const t0 = ts[i], t1 = ts[i + 1];
    if (t1 - t0 < 1e-9) continue;
    const tm = (t0 + t1) / 2;
    if (!inRings(a[0] + dx * tm, a[1] + dy * tm, rings)) continue;
    const last = out[out.length - 1];
    const p0 = [a[0] + dx * t0, a[1] + dy * t0], p1 = [a[0] + dx * t1, a[1] + dy * t1];
    if (last && Math.hypot(last[1][0] - p0[0], last[1][1] - p0[1]) < 1e-6) last[1] = p1; else out.push([p0, p1]);
  }
  return out;
}

Strokes of a hatch pattern

64-bit

The lines, dots or zigzags of a pattern inside a region, at its scale.

line k of a family: n · p = ⌊(n_min − offset) ÷ gap⌋ × gap + k × gap, gap = pattern gap × scale

The first line is placed at a whole multiple of the gap from the plan's origin; the following lines are reached by adding the gap repeatedly. The angle is degrees × π ÷ 180 with Math.PI. When the estimate of strokes passes 30,000 the scale is doubled until it does not, so a very fine pattern is drawn coarser than stored.

  • drafting.js
  • lines 228 to 272
  • BimCraft 0.8.1
/** A pattern's strokes inside a region. scale multiplies its spacing; a scale too fine for the region is made coarser. */
export function hatchSegments(outer, holes = [], pattern = 'diagonal', scale = 1) {
  const P = PATTERNS.find((p) => p.key === pattern);
  const out = [];
  if (!P || P.solid || !outer || outer.length < 3) return out;
  const rings = [outer, ...(holes || [])];
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
  for (const [x, y] of outer) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
  const diag = Math.hypot(x1 - x0, y1 - y0) || 1;
  let k = scaleOf(scale);
  // how many strokes it would take; too many → coarser (the drawing stays readable and the page responsive)
  const est = (kk) => (P.families.reduce((n, f) => n + diag / (f.gap * kk) * (1 + (f.group?.length || 0)), 0) + (P.dots ? (x1 - x0) * (y1 - y0) / (P.dots * kk * 2.5) ** 2 : 0) + (P.zigzag ? (x1 - x0) * (y1 - y0) / (P.zigzag * kk) ** 2 * 2 : 0));
  while (est(k) > MAX_STROKES) k *= 2;
  const push = (a, b) => { for (const [p, q] of clipSegment(rings, a, b)) out.push(p[0], p[1], q[0], q[1]); };
  for (const f of P.families) {
    const r = f.angle * Math.PI / 180, d = [Math.cos(r), Math.sin(r)], n = [-d[1], d[0]], gap = f.gap * k;
    const corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
    const ns = corners.map((c) => c[0] * n[0] + c[1] * n[1]), ds = corners.map((c) => c[0] * d[0] + c[1] * d[1]);
    const nmin = Math.min(...ns), nmax = Math.max(...ns), dmin = Math.min(...ds) - 1, dmax = Math.max(...ds) + 1;
    const offs = [0, ...(f.group || []).map((o) => o * k)];
    for (let c = Math.floor((nmin - offs[offs.length - 1]) / gap) * gap; c <= nmax; c += gap) for (const o of offs) {
      const m = c + o;
      if (m < nmin || m > nmax) continue;
      push([n[0] * m + d[0] * dmin, n[1] * m + d[1] * dmin], [n[0] * m + d[0] * dmax, n[1] * m + d[1] * dmax]);
    }
  }
  if (P.dots) {
    // concrete: dots (very short strokes) on a staggered grid, nudged by a fixed hash so they do not read as a grid
    const s = P.dots * k * 2.5, dot = P.dots * k * 0.08;
    for (let gy = Math.floor(y0 / s); gy * s <= y1; gy++) for (let gx = Math.floor(x0 / s) - 1; gx * s <= x1; gx++) {
      const h = Math.abs(Math.sin(gx * 12.9898 + gy * 78.233) * 43758.5453) % 1, h2 = Math.abs(Math.sin(gx * 39.346 + gy * 11.135) * 24634.6345) % 1;
      const x = gx * s + (gy % 2 ? s / 2 : 0) + (h - 0.5) * s * 0.6, y = gy * s + (h2 - 0.5) * s * 0.6;
      if (inRings(x, y, rings)) out.push(x - dot / 2, y, x + dot / 2, y);
    }
  }
  if (P.zigzag) {
    // insulation: rows of zigzag, each row as high as one zag
    const h = P.zigzag * k, p = h * 0.6;
    for (let ry = Math.floor(y0 / h) * h; ry < y1; ry += h) for (let x = Math.floor(x0 / p) * p; x < x1; x += p) {
      push([x, ry], [x + p / 2, ry + h]);
      push([x + p / 2, ry + h], [x + p, ry]);
    }
  }
  return out;
}

Pattern scale

64-bit

The scale of a hatch pattern as it is kept.

scale = min(50, max(0.1, v)), or 1 when v is not a positive number

The value is clamped, not rounded, here. It is rounded to 3 decimals when written to the CadCraft_Drafting property set.

  • drafting.js
  • line 53
  • BimCraft 0.8.1
const scaleOf = (x) => { const v = Number(x); return Number.isFinite(v) && v > 0 ? Math.min(50, Math.max(0.1, v)) : 1; };
drafting-ui.js BimCraft 0.8.1 64-bit 1 calculation Reviewed

Tests None found by the review

A typed point

64-bit

The next point of a drafting line from typed coordinates, a typed offset, a typed length and angle, or a typed length along the pointer's direction.

polar: (x + L·cos(a·π ÷ 180), y + L·sin(a·π ÷ 180)); length only: last + L × (to − last) ÷ |to − last|

The typed numbers are used as parsed, in 64-bit. The angle is turned to radians with Math.PI, so a typed 90° gives a cosine of about 6e-17, not 0. The direction for a bare length is the unit vector to the pointer, with the divisor replaced by 1 when it is zero.

  • drafting-ui.js
  • lines 154 to 161
  • BimCraft 0.8.1
const last = D.pts[D.pts.length - 1] || null;
let q = null;
if (v.kind === 'abs') q = [v.x, v.y];
else if (!last) { A.host.status(t('model.typed.first'), true); return false; }
else if (v.kind === 'rel') q = [last[0] + v.dx, last[1] + v.dy];
else if (v.kind === 'polar') q = [last[0] + v.len * Math.cos(v.angle * Math.PI / 180), last[1] + v.len * Math.sin(v.angle * Math.PI / 180)];
else { const to = D.at || [last[0] + 1, last[1]], d = Math.hypot(to[0] - last[0], to[1] - last[1]) || 1; q = [last[0] + (to[0] - last[0]) / d * v.len, last[1] + (to[1] - last[1]) / d * v.len]; }
point(q);

In detail

Method
Shoelace area for the region check; segment and edge intersection by the two-line parametric solve; even-odd ray crossing for inside tests. Pattern lines are laid on a grid anchored at the plan's origin so neighboring regions line up.
Number types
64-bit numbers in arrays of [x, y].
Tolerances
Points closer than 0.5 mm to the one before are dropped. On reading, a polyline whose ends are within 0.01 mm is closed; on writing, a closed item's last point within 0.5 mm of its first is dropped. A line under 10 mm and a hatch under 100 mm² are refused. Clipping: a divisor below 1e-12 (absolute) is parallel, pieces shorter than 1e-9 of the stroke are skipped, pieces within 1e-6 mm are joined. Pattern scale is held between 0.1 and 50.
Limits
The stored scale is rounded to 3 decimals and the hatch angle written to the file to 9 decimals. A pattern that would need more than 30,000 strokes is drawn at twice the spacing, repeatedly, until it fits: the drawn spacing is then not the stored one.

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