DEV Community

anders lee
anders lee

Posted on

Isometric and hexagon grids for print: three line families, one lattice and a 3 that bites

Square graph paper is two for loops. Isometric and hexagon paper look almost as simple, and my
first version of each was wrong in a way that only showed up once I printed it. Here is what I
ended up doing for a printable paper generator, with the bugs included.

All coordinates are PDF points (1/72 inch), so a "side of 0.25 inch" is 18 pt.

Isometric paper: three families of parallel lines

Isometric paper is a tiling of equilateral triangles with side s. It's made of three sets of
parallel lines: one vertical, and two at ±30° from the horizontal (or, the way I find easier to
think about, lines whose normals point at 0°, 60° and 120°).

Within each family the lines are s·√3/2 apart. That's the height of a triangle, not its side.
Using s as the spacing is the first classic mistake: the triangles come out stretched and nothing
meets.

My first attempt drew the verticals starting from the left margin and the diagonals starting from
the bottom-left corner. It looked fine on screen. On paper, the verticals ran slightly beside the
points where the diagonals crossed, by a different amount on each page size. Two families were
aligned to the margin and the third wasn't.

The fix is to make every family pass through one shared lattice point (the centre of the page)
and describe each line as n · p = k · gap, where n is the unit normal:

function isometric(area, s, draw) {
  const gap = (s * Math.sqrt(3)) / 2;
  const cx = (area.x0 + area.x1) / 2, cy = (area.y0 + area.y1) / 2;
  // work relative to the centre so k = 0 goes through (cx, cy) in every family
  const r = { x0: area.x0 - cx, y0: area.y0 - cy, x1: area.x1 - cx, y1: area.y1 - cy };
  const kMax = Math.ceil(Math.hypot(r.x1 - r.x0, r.y1 - r.y0) / 2 / gap);
  for (const deg of [0, 60, 120]) {
    const nx = Math.cos((deg * Math.PI) / 180), ny = Math.sin((deg * Math.PI) / 180);
    for (let k = -kMax; k <= kMax; k++) {
      const seg = clipLine(nx, ny, k * gap, r);
      if (seg) draw(seg[0] + cx, seg[1] + cy, seg[2] + cx, seg[3] + cy);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Because every family has a line through the centre, every triple intersection lands exactly on a
lattice point, on every page size.

Clipping instead of a clip path

The lines are infinite, so they have to be cut at the margin. SVG and PDF both support clip paths,
but I wanted real segment endpoints: the same drawing feeds SVG, PDF and PNG renderers, and PNG
exports for e-ink tablets shouldn't depend on how a viewer handles clipping. This is
Liang–Barsky clipping for a line given by its normal:

/** Clip the line {p : n·p = k} to rectangle r; returns [x1, y1, x2, y2] or null. */
function clipLine(nx, ny, k, r) {
  const px = nx * k, py = ny * k; // point on the line closest to the origin
  const dx = -ny, dy = nx;        // direction along the line
  let t0 = -Infinity, t1 = Infinity;
  for (const [p, q] of [[-dx, px - r.x0], [dx, r.x1 - px], [-dy, py - r.y0], [dy, r.y1 - py]]) {
    if (Math.abs(p) < 1e-12) { if (q < 0) return null; continue; } // parallel and outside
    const t = q / p;
    if (p < 0) t0 = Math.max(t0, t); else t1 = Math.min(t1, t);
  }
  return t0 < t1 ? [px + t0 * dx, py + t0 * dy, px + t1 * dx, py + t1 * dy] : null;
}
Enter fullscreen mode Exit fullscreen mode

kMax comes from the half-diagonal of the area, so the loops cover every line that could touch
it. clipLine returns null for the rest. On a Letter page with 1/4 inch triangles that leaves
163 segments, so the PDF stays tiny.

Isometric dots: a lattice, not three line families

Isometric dot paper has dots at the corners of the same triangles. It's tempting to compute the
line intersections, but it's easier to treat it as a lattice with two basis vectors:
e1 = (0, s) (straight up) and e2 = (s·√3/2, s/2). Every other column is shifted by half a
step:

const gx = (s * Math.sqrt(3)) / 2;
for (let i = -iMax; i <= iMax; i++) {
  const x = cx + i * gx;
  const yOff = (Math.abs(i) % 2) * (s / 2); // odd columns shift half a triangle
  for (let j = -jMax; j <= jMax; j++) dot(x, cy + j * s + yOff);
}
Enter fullscreen mode Exit fullscreen mode

Note Math.abs(i) % 2. In JavaScript -1 % 2 is -1, not 1, so without abs the columns left
of centre shift the wrong way and you get a lattice that is fine on the right half of the page and
broken on the left.

Hexagon paper: draw each edge once

Flat-topped hexagons with side s tile in columns 1.5·s apart, with every other column shifted
down by half a hexagon height (s·√3/2). Generating each hexagon's six edges is easy. The problem
is that neighbouring hexagons share edges, so a naive loop draws almost every line twice.

In a PDF viewer the anti-aliased edges of a doubled line get blended twice, so the interior of the
page looks heavier than the outer ring, and the file is twice the size it needs to be. The fix is to key each edge by its two endpoints, rounded and ordered:

const seen = new Set();
const key = (ax, ay, bx, by) => {
  const p = `${ax.toFixed(2)},${ay.toFixed(2)}`, q = `${bx.toFixed(2)},${by.toFixed(2)}`;
  return p < q ? p + "|" + q : q + "|" + p; // same key whichever way round
};
for (const [ax, ay, bx, by] of hexEdges()) {
  const id = key(ax, ay, bx, by);
  if (seen.has(id)) continue;
  seen.add(id);
  draw(ax, ay, bx, by);
}
Enter fullscreen mode Exit fullscreen mode

Rounding to 0.01 pt matters. The same corner computed from two different hexagon centres can
differ in the 13th decimal place, and then the strings don't match.

"1 inch hexes" is ambiguous

Tabletop players ask for "1 inch hex paper", and they almost always mean 1 inch across the
flats
(the distance a miniature's base has to fit), not a 1 inch side. For flat-topped hexes,
across-the-flats is s·√3, so a 1 inch hex has a side of 1/√3 ≈ 0.577 inch. Across the corners
it's 2s ≈ 1.155 inch. I give the side length in the generator and spell out the conversion next
to it, because getting this wrong makes every hex 73% too big.

Fitting whole cells

The last detail is the same as for square paper: fit whole cells inside the margin and centre the
block, instead of starting at the top-left corner and letting the last column be cut off. For
hexes that means cols = floor((width − 0.5·s) / (1.5·s)), because a row of n flat-topped
hexes is 1.5·s·n + 0.5·s wide. Centring the block leaves an equal margin on both sides, which
is what people notice first when they hold a sheet up.

You can see all three grids (and download them as exact-size PDFs) at
graphpaperpdf.net.

Top comments (0)