Skip to main content
Auric Artisan · Documentation

Collage Measurement Developer Reference

Maintain the collage studio: the history that records the state before each change and the image bank that survives undoing to an empty canvas, coverage and overlap read off the canvas the export will use, arrangements laid out and measured rather than predicted from cell areas, and an export path with no editing chrome in it.

Published: June 5, 2026 Updated: June 5, 2026 Category: Reference Author: Chirag Bansal
Back to Documentation Auric Artisan Home

Overview

The collage studio is a self-contained browser runtime. Pictures are read from the local file picker or drop zone with URL.createObjectURL, drawn to a Canvas 2D context, and exported with toBlob. Nothing is uploaded and no network call is made after the page loads.

It is the odd one out among these rebuilds, and the reason is worth stating before the detail: most of the tool was sound. The render path, the affine transforms, drag, corner resize, snap-to-grid, the guides, layer order, session JSON and the Standards and Formulas documentation all did what they said. Three things did not, and a fourth was found only because the rebuild went back and measured the parts that were believed to be fine.

The arithmetic lives in js/tool/cg/cg-core.js, which touches no DOM and is what the test suite runs. The provenance of every figure on the page lives in js/tool/cg/cg-sources.js. The page itself holds no numbers.

Table of contents

  1. Code map
  2. What was found, and how
  3. History that reaches the start
  4. Two measurements, two questions
  5. Laying out and measuring
  6. The export path
  7. The register
  8. Tests, and proving them

1. Code map

+
FileHolds
js/tool/cg/cg-core.js Everything numeric and no DOM: the history, the affine quad, Sutherland–Hodgman clipping, the geometry analysis, the two pixel measurements, the grid, the fitted scale, the arrangement comparison, hit testing and snapping. Installs as window.AACGCore.
js/tool/cg/cg-sources.js The dataset register: twelve entries, each measured, computed, choice or absent, plus the two withdrawn features and what each printed. The page derives its counts and its tab badge from counts() rather than carrying a typed number.
js/tool/collage-maker.js The DOM, the canvas and the events. Owns paint(), drawDisplay(), exportCanvas() and measure().
js/tool/collage-maker-views.js The Layout, Measure, Data, Export and Reference renderers. Holds no arithmetic; the only numbers written into it are historical readings of the engine being replaced.
css/collage-studio-shell.css The workbench shell, ported from css/lut-lab-shell.css, plus the board, the layer list, the arrangement previews and the withdrawal panels.
tests/colorimetry/cg-harness.js Loads the shipped files under a minimal global, and rasterises layers onto a byte grid so the pixel measurements can be tested without a browser.

2. What was found, and how

+

Every finding below was measured by driving the shipped page in a browser, not by reading its source. Two of them were found that way after the source had already been read and judged correct.

Undo could never reach where you started

pushUndo() ran after the mutation, everywhere: sel.cx -= step; pushUndo(); — layers.push(l); pushUndo(); — autoLayout(); …; pushUndo();. The stack therefore held the state you were already in, and the most recent step could never be taken back. Measured: a layer at x = 600, five one-pixel nudges to 605, five undos back to 601. 600 was never on the stack at all.

A deleted layer never came back either, because undo re-applied saved properties onto layers that still existed:

var l = layers.find(x => x.id === d.id);
if (l) Object.assign(l, d);

A removed layer is not found, so nothing is re-created. Three pictures, delete, undo — still two. Clear all had the same shape, which made it permanent.

The coverage figure never looked at where anything was

layers.forEach(l => { if (l.visible)
  totalCover += l.bw * l.scale * l.bh * l.scale; });
pct = totalCover / (canvasW * canvasH) * 100;

A sum of areas with no position in it, so an overlap counted twice and anything off the edge counted in full. Measured on four pictures, with the canvas drawn once with them hidden and once with them shown and the differing pixels counted:

ArrangementIt printedReally covered
all stacked centrally36.0%11.9%
spread out, no overlap36.0%31.8%
dragged off the canvas36.0%0%

One number for three collages, including one with nothing on the canvas. The “Overlap” line had a related fault: it appeared only when that sum exceeded the canvas, so three pictures stacked exactly on top of one another reported no overlap at all — the arrangement in which everything overlaps.

A 95% confidence interval for a canvas with nothing on it

runBootstrap() read no data. It chose a column count uniformly at random, multiplied padding and gap by a random factor between 0.5 and 1.5, evaluated a closed-form cell area and reported the 2.5th and 97.5th percentiles as a “95% CI”.

On the canvasMean95% CI it reported
nothing — no pictures loaded81.5%[56.6%, 93.2%]
four pictures82.5%[57.3%, 93.5%]
four, all dragged off81.6%[57.0%, 93.1%]

The same interval three times, because the collage was never an input. Widening the randomisation range would have widened the “confidence”, which is not something a confidence interval can do. Withdrawn, and the Efron (1979) citation with it: a real method named beside a routine that does not perform it lends that routine the method’s authority.

The export carried the editing chrome

This one was found late, while rebuilding the parts believed to be sound. render() drew the pictures, the rule-of-thirds guides, the selection rectangle, the four corner handles and the chequerboard that means “nothing here” onto one canvas — and exportPNG() handed that same canvas to toBlob(). Measured, one picture on a 1200×900 canvas with transparent background ticked:

In the fileMeasuredShould have been
opaque pixels1,080,000 of 1,080,000none
the corner pixel#ccc — a chequer squarefully transparent
gold selection stroke3,532 pixelsnone
white corner handles144 pixelsnone
rule-of-thirds guidesdrawn across the picturenone

The 144 is four 6×6 handles exactly. The argument this finding makes is the one about method: the source had been read and the export judged correct. It was measuring it that found otherwise.

3. History that reaches the start

+

createHistory() takes the state as an argument rather than reading it, so a caller cannot commit the wrong one by accident:

function record(fn) {
  var before = snapshot(state, layers);   // what it WAS
  fn();                                   // do the thing
  history.commit(before);                 // record what it was
  refresh();
}

Nothing else in js/tool/collage-maker.js touches the history, with one deliberate exception: a pointer drag takes its snapshot in onDown and commits it in onUp, so a drag is one step rather than one per pointer event.

The second half is that a snapshot must be complete. restore() replaces the layer list rather than assigning properties onto whatever still exists, which is what lets a deletion be undone.

The image bank

An Image cannot be serialised, so snapshot() holds every layer property except img and restore() carries the picture across from the layers that are still live. Undo far enough and there are none left live to carry from — the browser still holds the decoded image, but the page has lost its only reference to it.

imageBank is that reference: an id-keyed map written in addImage() and never pruned, passed to restore() in place of the live list. Measured before it existed: undo to the empty canvas, redo back to four pictures, and the export came out 100% transparent. If you change how layers are created, write to the bank there too.

4. Two measurements, two questions

+

analyseGeometry() is exact and needs no canvas: where the pictures are placed, how much hangs off the edge, and how much of them overlaps, from quads clipped to the canvas rectangle by Sutherland–Hodgman. coverageFromPixels() takes rendered bytes and says what was actually painted — which is the one that notices a transparent PNG. The page reports both and the register says which is which.

Why coverage can be null

placed − pairwise is the covered area exactly while no three layers meet at one point. Once they do it is not merely imprecise, it is wrong: four identical pictures stacked give placed = 1 canvas and pairwise = 1.5, so the subtraction lands on zero while a quarter of the canvas is covered. Reporting that as coverage would be the same class of defect this rebuild exists to remove, so coveredArea is null when it cannot be exact and coveredExact says so. The page’s headline figure comes from the pixels, which are right in every arrangement.

Measuring at a bounded resolution

measure() renders the export twice — once with no layers, once with all of them — plus once per layer for the overlap masks. That is n + 2 full-canvas passes, so the measurement canvas is capped at MEASURE_CAP = 1,440,000 pixels. Four of the six aspect presets are at or under that and measure at export resolution; the two tall ones scale down, and the readout says which by printing either measured at full size or the size it used. The reading JSON carries the same statement in measured.at.

5. Laying out and measuring

+

compareArrangements() lays each candidate out and calls analyseGeometry() on the result, rather than predicting from cell areas. This is the whole difference between it and the batch table it replaces: a grid cell is not filled by a picture that does not share its shape, and the old formula had no picture aspect ratio in it at all.

Measured, four pictures — three landscape 4:3 and one portrait 3:4 — on a 1200×900 canvas with 24 px padding and an 8 px gap:

GridCellFour landscapeThree landscape, one portrait
1 × 41152 × 20721.2%18.8%
2 × 2572 × 42287.9%78.3%
3 × 2379 × 42239.8%42.2%
4 × 1282 × 85222.1%26.4%

The portrait picture costs coverage in the grids with landscape cells and helps in the grids with portrait ones. That the direction reverses with the cell shape is the argument for measuring: no formula over cell areas can produce it, because the pictures are not in that formula. The test suite asserts both directions.

Ties are ties

When no picture is larger than its cell, the fitted scale is capped at 1 in every arrangement and they all cover the same fraction. The table marks no winner in that case and the note says why, rather than highlighting one row out of a dead heat.

Refusal

Padding is the margin outside the block of cells and gap the space between them; both come out of the same canvas, so a large gap on a small canvas drives a cell negative. gridLayout() returns { ok: false, reason } with the numbers that caused it, and the arrangement row scores 0 rather than a figure it never measured.

6. The export path

+

The rule, and the reason the fourth finding cannot recur: paint() draws the content and nothing else. Guides, the selection rectangle, the corner handles and the chequerboard are drawn by drawDisplay() afterwards, onto the screen canvas only. The chequerboard is not even drawn — it is a CSS background on .cg-canvas-wrap, behind a canvas that is genuinely transparent.

function exportCanvas(scale) {
  var c = document.createElement("canvas");
  c.width  = Math.round(state.canvasW * scale);
  c.height = Math.round(state.canvasH * scale);
  paint(c.getContext("2d"), c.width, c.height, scale);
  return c;   // no chrome has ever touched this context
}

measure() calls paint() too. That is what lets the page claim its figures are of the file you are about to download: they are read off the same function that writes it. If you add anything to the drawing, decide first whether it belongs in paint() or in drawDisplay(), and remember that anything you put in the first is also something the coverage figure will count.

Verified after the change, same probe as the finding: with four pictures loaded and one selected, the exported canvas holds 0 pixels of the gold selection stroke, 0 pure-white handle pixels, and 92.89% genuinely transparent — the remaining 7.11% being exactly the four pictures.

A session file records the layout and not the pictures. That is stated on the Export tab rather than discovered on the day it matters, and loading one applies the saved geometry to the pictures already open, by position.

7. The register

+

A collage tool is not a measuring instrument and most of what it shows is a choice, so the four statuses are not quite the ones the colour benches use:

StatusMeansCount
measuredread off the canvas the export will use3
computedderived from the layout by arithmetic the entry states5
choicethis tool’s own default, standing in for a judgement3
absentnamed on the page and not held, with what it would take1

reportable(id) is false for the last two. The page derives its counts, its filter chips and its tab badge from counts(), so a thirteenth entry needs no markup change — and the test suite asserts the totals, so it cannot drift.

The one absence is physical print size. A collage exported at 1200×900 is 1200×900 pixels and nothing else; nothing in the core converts to a physical unit, and a test asserts that no exported name looks like one. What it would take is a stated output density and a size field, and the arithmetic is then one division.

The two withdrawn features are in WITHDRAWN rather than in the register under any status, because a stand-in stands in for something and those stood in for nothing. Each carries what it printed.

8. Tests, and proving them

+
node --test "tests/colorimetry/cg-*.test.js"

48 tests across two files. cg-reference.test.js covers the arithmetic; cg-sources.test.js checks the register against the code it describes, including that no entry advertises a confidence interval and that the core exports no bootstrap.

cg-harness.js rasterises layers onto a byte grid with a point-in-rotated-rect test per pixel — slow, exact, and independent of the geometry code it is used to check — so coverageFromPixels and overlapFromMasks can be tested without a browser.

The suite was proved by defect injection

73 deliberate defects were injected into the core and the register, each one run against the whole suite. The first pass caught 59 and 14 survived — among them dropping the alpha term from the pixel comparison, which is the case the register calls the whole point of measuring pixels. Twelve tests were added to close the real gaps, two defective mutants were fixed, and the second pass caught 73 of 73 with no survivors.

The mutation script reports an anchor it can no longer find as an error rather than as a survivor. A mutation run that silently patches nothing reports a perfect score while testing nothing.

Two of the gaps are worth knowing about if you change the geometry. ccw() may never fire on the shipped paths, because layerQuad and canvasRect both wind counter-clockwise already — it is the clip polygon’s winding that decides which side counts as inside, and only a direct test exercises it. And an area assertion alone cannot see a crossing-point error when the shape is symmetric, so the clipping test asserts the vertex positions.