Resources Numerical accuracy Calculation policy

Scanned drawings: size, scale, align, crop and trace

Places a scanned picture, scales and turns it from picked points, crops it, and turns its traced ink into lines, arcs and circles in drawing coordinates.

How the precision is kept

Placement arithmetic is 64-bit: a pixel is mapped to the drawing through the picture's corner and pixel vectors and the reference's matrix. What sets the accuracy is the picture: traced geometry is as good as the pixel size and the trace tolerance, 1.5 pixels unless changed. The first size of a scan comes from the file's stated dots per inch as millimeters per pixel.

Open item. Convert the first size of a scan from millimeters per pixel into the drawing's own unit.

Calculations under this policy

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

scan.js CadCraft 0.8.2 64-bit 3 calculations Reviewed

Tests bin/test-raster.mjs

Pixels to drawing coordinates and back

64-bit

Maps a pixel of a placed picture to the drawing, a drawing point back to a pixel, and gives the size of one pixel.

block = p + u·px + v·(h − py); drawing = M·block; pixel size = |M·u|

Pixel y counts down from the top row, so the height is taken off first. The way back divides by the reference matrix's determinant (below 1e-300 it returns pixel 0, 0) and by the determinant of the two pixel vectors, which is not checked.

  • scan.js
  • lines 35 to 54
  • CadCraft 0.8.2
/** Everything the commands need to turn pixels into drawing coordinates for one picture reference. */
export function scanGeom(doc, ref) {
  if (!ref || ref.type !== 'insert') return null;
  const block = doc.blocks.get(ref.block), frame = pictureFrame(block);
  if (!frame || frame.ole) return null;
  const pic = frame.pic, m = insertMatrix(ref, block), W = (x, y) => mApply(m, { x, y });
  const bp = (px, py) => ({ x: pic.x + pic.ux * px + pic.vx * (pic.h - py), y: pic.y + pic.uy * px + pic.vy * (pic.h - py) });
  const det = pic.ux * pic.vy - pic.uy * pic.vx, dm = m[0] * m[3] - m[1] * m[2];
  const toDraw = (px, py) => { const b = bp(px, py); return W(b.x, b.y); };
  const inv = (x, y) => {   // drawing → block → pixel
    const dd = m[0] * m[3] - m[1] * m[2];
    if (Math.abs(dd) < 1e-300) return { x: 0, y: 0 };
    const bx = (m[3] * (x - m[4]) - m[2] * (y - m[5])) / dd, by = (-m[1] * (x - m[4]) + m[0] * (y - m[5])) / dd;
    const dx = bx - pic.x, dy = by - pic.y;
    const a = (dx * pic.vy - dy * pic.vx) / det, b = (pic.ux * dy - pic.uy * dx) / det;   // along the width, up (pixels)
    return { x: a, y: pic.h - b };
  };
  const pixel = Math.hypot(m[0] * pic.ux + m[2] * pic.uy, m[1] * pic.ux + m[3] * pic.uy);
  return { ref, block, frame, pic, rec: findRecord(doc, pic.file), toDraw, toPixel: inv, pixel, mirrored: (det * dm) < 0 };
}

Arc through three points

64-bit

The counterclockwise arc through a start, a middle and an end point.

d = 2(aₓ(b_y − c_y) + bₓ(c_y − a_y) + cₓ(a_y − b_y)); center from |a|², |b|², |c|² ÷ d; r = |a − center|

The center comes from absolute drawing coordinates and their squares, so three points close together and far from the origin subtract large nearly equal numbers. Points are called collinear when |d| < 1e-12, an absolute value that does not scale with the coordinates. The radius is measured to the first point only.

  • scan.js
  • lines 144 to 155
  • CadCraft 0.8.2
/** The counterclockwise arc through three points (y up). */
export function arcThrough(p1, p2, p3) {
  const ax = p1.x, ay = p1.y, bx = p2.x, by = p2.y, cx = p3.x, cy = p3.y;
  const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
  if (Math.abs(d) < 1e-12) return null;
  const ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
  const uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
  const r = Math.hypot(ax - ux, ay - uy), TAU = Math.PI * 2;
  const cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx);   // > 0: the three points run counterclockwise
  const ang = (p) => ((Math.atan2(p.y - uy, p.x - ux) % TAU) + TAU) % TAU;
  return cross > 0 ? { cx: ux, cy: uy, r, a0: ang(p1), a1: ang(p3) } : { cx: ux, cy: uy, r, a0: ang(p3), a1: ang(p1) };
}

Traced lines and arcs as drawing objects

64-bit

Turns a trace result in pixels into lines, arcs and circles in drawing coordinates.

circle r = r(px) × pixel size; arc through start, middle and end

A circle's radius is the traced radius times the size of one pixel along the picture's width, which assumes square pixels and no shear. Arcs are rebuilt from three mapped points; a sweep within 1e-6 rad of a full turn becomes a circle.

  • scan.js
  • lines 157 to 178
  • CadCraft 0.8.2
/** A trace result (pixels) → drawing objects. Lines and arcs on `layer`; with `leftovers`, the boxes left as raster on `leftLayer`. */
export function traceEntities(g, result, { layer, leftLayer = null, leftovers = false } = {}) {
  const out = [], P = (x, y) => g.toDraw(x, y);
  for (const l of result.lines) { const a = P(l.x1, l.y1), b = P(l.x2, l.y2); out.push({ type: 'line', layer, color: null, x1: a.x, y1: a.y, x2: b.x, y2: b.y }); }
  for (const a of result.arcs) {
    const TAU = Math.PI * 2;
    const c = P(a.cx, a.cy), r = a.r * g.pixel;
    if (a.a1 - a.a0 >= TAU - 1e-6) { out.push({ type: 'circle', layer, color: null, cx: c.x, cy: c.y, r }); continue; }
    const m = (a.a0 + a.a1) / 2;
    const s = P(a.sx, a.sy), e = P(a.ex, a.ey), md = P(a.cx + a.r * Math.cos(m), a.cy + a.r * Math.sin(m));
    const arc = arcThrough(s, md, e);
    if (arc) out.push({ type: 'arc', layer, color: null, cx: arc.cx, cy: arc.cy, r: arc.r, a0: arc.a0, a1: arc.a1 });
  }
  const left = [];
  if (leftovers && leftLayer) {
    for (const b of [...result.left.text, ...result.left.hatch]) {
      const ps = [P(b.x0, b.y0), P(b.x1, b.y0), P(b.x1, b.y1), P(b.x0, b.y1)];
      left.push({ type: 'poly', layer: leftLayer, color: null, closed: true, pts: ps.map((p) => ({ x: p.x, y: p.y, b: 0 })) });
    }
  }
  return { entities: out, left };
}
scan-cmd.js CadCraft 0.8.2 64-bit 6 calculations Reviewed

Tests bin/test-raster.mjs

First size of an attached scan

64-bit

The size of one pixel when a scan is attached.

per pixel = 25.4 ÷ dpi when the file states it and the drawing has units, else 1; typed width ÷ pixel width

25.4 ÷ dpi is millimeters per pixel, and it is used as drawing units per pixel for any drawing that has units, so the offered size is true only when the unit is the millimeter. Only the horizontal dpi is read. A width typed at the prompt replaces it.

  • scan-cmd.js
  • lines 72 to 77
  • CadCraft 0.8.2
const perPx = img.dpi?.x ? 25.4 / img.dpi.x : 1;
const unit = c.doc.meta?.units ? perPx : 1;
const px = unit;
if (first === null) {
  const width = await c.distance(t('pic.prompt.width', { w: rec.w, h: rec.h }), { base: at, default: rec.w * px });
  first = typeof width === 'number' && width > 0 ? width / rec.w : px;

Scale from two points and a real distance

64-bit

Rescales a scan so two picked points are a stated distance apart.

f = wanted ÷ |b − a|; insertion′ = a + f·(insertion − a); sx′ = sx·f

Points closer than 1e-9 drawing units, an absolute limit, are refused. One division for f; the reference is scaled about the first picked point so that point stays put. The distances printed are rounded for the message only.

  • scan-cmd.js
  • lines 100 to 105
  • CadCraft 0.8.2
const cur = Math.hypot(b.x - a.x, b.y - a.y);
if (!(cur > 1e-9)) { c.print(t('scan.scale.same')); return; }
const want = await c.distance(t('scan.prompt.scale3', { d: fmt(cur, 4) }), { base: a });
if (typeof want !== 'number' || !(want > 0)) return;
const f = want / cur, r = g.ref;
c.doc.change('SCANSCALE', () => c.doc.update(r.id, { ...r, x: a.x + f * (r.x - a.x), y: a.y + f * (r.y - a.y), sx: r.sx * f, sy: r.sy * f }));

Align by two pairs of points

64-bit

Moves, turns and optionally scales a scan so two points on it land on two points of the drawing.

f = |d₂ − d₁| ÷ |s₂ − s₁|; θ = atan2(d₂ − d₁) − atan2(s₂ − s₁); insertion′ = d₁ + f·R(θ)·(insertion − s₁)

The turn is the difference of two atan2 results, then one cosine and one sine. The first pair lands exactly; the second lands exactly only when scaling is on. The new rotation is wrapped with one added turn.

  • scan-cmd.js
  • lines 118 to 123
  • CadCraft 0.8.2
const sl = Math.hypot(s2.x - s1.x, s2.y - s1.y), dl = Math.hypot(d2.x - d1.x, d2.y - d1.y);
if (!(sl > 1e-9) || !(dl > 1e-9)) { c.print(t('scan.scale.same')); return; }
const f = doScale ? dl / sl : 1, th = Math.atan2(d2.y - d1.y, d2.x - d1.x) - Math.atan2(s2.y - s1.y, s2.x - s1.x);
const co = Math.cos(th), si = Math.sin(th), r = g.ref;
const dx = r.x - s1.x, dy = r.y - s1.y;
c.doc.change('SCANALIGN', () => c.doc.update(r.id, { ...r, x: d1.x + f * (co * dx - si * dy), y: d1.y + f * (si * dx + co * dy), sx: r.sx * f, sy: r.sy * f, rot: ((r.rot || 0) + th + TAU) % TAU }));

Straightening a skewed scan

64-bit

Turns the picture back by the skew angle that was found or typed.

An angle under 0.005° is not applied. The angle found by the raster library is shown to 2 decimals but applied unrounded; the picture's pixels are resampled by the library's rotateImage.

  • scan-cmd.js
  • lines 141 to 142
  • CadCraft 0.8.2
if (Math.abs(deg) < 0.005) { c.print(t('scan.skew.none')); return; }
const out = rotateImage(img, -deg, { fill: 255 });   // lines that rise by `deg` are turned back down by `deg`

Crop window in pixels

64-bit

The whole-pixel window of a crop and the new corner of the cropped picture.

x₀ = ⌊min⌋, y₀ = ⌊min⌋, x₁ = ⌈max⌉, y₁ = ⌈max⌉; corner = p + u·x₀ + v·(h − y₁)

The picked window is widened outward to whole pixels and held inside the picture. The new corner is built from whole pixel counts, so the pixels that remain keep their place and size.

  • scan-cmd.js
  • lines 193 to 200
  • CadCraft 0.8.2
const [x0, y0, x1, y1] = windowBox(g, a, b).map((v, i) => (i < 2 ? Math.max(0, Math.floor(v)) : Math.ceil(v)));
const img = await guarded(c, () => decodePicture(g.rec, { decodeJpeg: c.host.decodeJpeg }));
if (!img) return;
const X1 = Math.min(img.w, x1), Y1 = Math.min(img.h, y1);
if (X1 - x0 < 2 || Y1 - y0 < 2) { c.print(t('scan.crop.empty')); return; }
const out = crop(img, x0, y0, X1, Y1);
// the cropped picture's lower-left corner, in the block's coordinates; one pixel keeps its size
const p = g.pic, bx = p.x + p.ux * x0 + p.vx * (p.h - Y1), by = p.y + p.uy * x0 + p.vy * (p.h - Y1);

Trace tolerance

64-bit

The tolerance of a trace, typed in drawing units and used in pixels.

tolerance (px) = typed ÷ pixel size, held to 0.5 … 20

One division by the pixel size; the result is held between 0.5 and 20 pixels, and 1.5 pixels is used when nothing is typed. The trace is run with this many pixels.

  • scan-cmd.js
  • line 252
  • CadCraft 0.8.2
if (o === 'T') { const v = await c.distance(t('scan.prompt.tol'), { default: tolUnits() }); if (typeof v === 'number' && v > 0) tolPx = Math.max(0.5, Math.min(20, v / g.pixel)); }

In detail

Method
Affine pixel-to-drawing mapping and its inverse; scale and rotation from two picked pairs; circle through three points for traced arcs; tracing, thresholding, deskew and rotation by the shared raster library.
Number types
64-bit numbers for geometry; pictures are 8-bit arrays.
Tolerances
Picked points closer than 1e-9 drawing units (absolute) are refused. Skew under 0.005° is not applied. Trace tolerance 0.5 to 20 pixels. Collinear test |d| < 1e-12 (absolute). A traced sweep within 1e-6 rad of a full turn is a circle. Inverse returns pixel (0, 0) when the reference determinant is under 1e-300.
Limits
The first size uses 25.4 ÷ dpi as drawing units per pixel whatever the drawing's unit is, so it is true size only in a millimeter drawing. Circle radii assume square pixels.

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