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.
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.
1. Code map
+| File | Holds |
|---|---|
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:
| Arrangement | It printed | Really covered |
|---|---|---|
| all stacked centrally | 36.0% | 11.9% |
| spread out, no overlap | 36.0% | 31.8% |
| dragged off the canvas | 36.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 canvas | Mean | 95% CI it reported |
|---|---|---|
| nothing — no pictures loaded | 81.5% | [56.6%, 93.2%] |
| four pictures | 82.5% | [57.3%, 93.5%] |
| four, all dragged off | 81.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 file | Measured | Should have been |
|---|---|---|
| opaque pixels | 1,080,000 of 1,080,000 | none |
| the corner pixel | #ccc — a chequer square | fully transparent |
| gold selection stroke | 3,532 pixels | none |
| white corner handles | 144 pixels | none |
| rule-of-thirds guides | drawn across the picture | none |
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:
| Grid | Cell | Four landscape | Three landscape, one portrait |
|---|---|---|---|
| 1 × 4 | 1152 × 207 | 21.2% | 18.8% |
| 2 × 2 | 572 × 422 | 87.9% | 78.3% |
| 3 × 2 | 379 × 422 | 39.8% | 42.2% |
| 4 × 1 | 282 × 852 | 22.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:
| Status | Means | Count |
|---|---|---|
measured | read off the canvas the export will use | 3 |
computed | derived from the layout by arithmetic the entry states | 5 |
choice | this tool’s own default, standing in for a judgement | 3 |
absent | named on the page and not held, with what it would take | 1 |
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.