Building LensCalc: two inputs, never three
LensCalc has been on the tools page since August. The optics post that went with it explained the terms, derived the one equation the whole thing rests on, and showed the thirty lines of arithmetic that branch it three ways — which left a build post with a problem, because the maths is already written up and there is not much of it. So this is about the rest: the half-dozen decisions that turn thirty lines of arithmetic into a tool somebody else can trust, and the tests that keep them decided. It is the same shape as the NetCalc build post, for the same reasons, and I will not repeat those: the maths lives under app/Support/Vision with no framework in it so Pest can call it directly, and the query string is the whole state of the page, so any result is a link.
Exactly two, never three
The relationship is magnification: m = sensor ÷ FOV = f ÷ (WD − f). Three quantities, one equation, so any two determine the third — and the first design decision was what to do when the user supplies some other number of them.
None is easy: the form, no result, no complaint. One is underdetermined and gets a sentence saying which two to fill in. Three is refused, and that is the decision worth explaining, because the tempting alternative is to quietly use two of them and ignore the third. Three supplied values may contradict each other — a 50 mm lens at 400 mm does not give a 70 mm field, whatever the user typed — and a calculator that silently picks two is lying about the one it dropped. The controller counts what arrived, and anything but two is a message rather than a guess:
$supplied = count(array_filter([$focal, $distance, $field],
static fn (?float $v): bool => $v !== null));
if ($supplied === 0) { return [null, null]; } // nothing asked
if ($supplied !== 2) {
return [null, 'Fill in exactly two of focal length, working distance
and field of view — the third is what gets calculated.'];
}
The calculator class enforces the same rule a second time with an exception, so it cannot be called wrongly from anywhere else, and a dataset test feeds it one value, none, and all three. One more refusal sits beside it: a working distance at or inside the focal length. The arithmetic would happily return a negative or infinite field; the physics says no real image forms on the sensor, so the tool says that instead.
The minus f, and the test that guards it
The shorthand everyone learns is FOV = sensor × WD ÷ f. It is the real equation with the focal length dropped from the denominator, and it is close enough at long range and wrong close in: 2.6 % over at forty focal lengths, 6.7 % at sixteen, 14.3 % at eight, a third at four. Machine-vision working distances are routinely under ten focal lengths. The optics post made that argument; what the build post can add is that the argument is a test:
it('is not the FOV = sensor x WD / f shorthand', function () {
$solution = LensCalculator::solve(SensorFormat::find('two-thirds'),
focalLength: 50, workingDistance: 400);
$shorthand = 8.8 * 400 / 50;
expect($shorthand)->toBe(70.4)
->and(round(($shorthand - $solution->fieldWidth) / $solution->fieldWidth * 100, 1))
->toBe(14.3);
});
It asserts that the tool's answer is 14.3 % away from the shorthand's. Somebody tidying the code one day, seeing WD − f and "simplifying" it to WD because that is the formula they remember, breaks a test whose name tells them why. A comment would say the same thing and nobody would read it; a failing test is read by definition. The 1:1 macro case is pinned the same way — at unit magnification the object sits at exactly two focal lengths and the lens needs a full focal length of extension, two identities any optics text states — so the three solve directions are checked against each other and against something outside the code.
Two angles of view, on purpose
A lens datasheet quotes one angle of view, 2·atan(sensor ÷ 2f), and it assumes focus at infinity. Focus a 50 mm lens on something 400 mm away and the lens moves 7.14 mm further from the sensor to do it, so the angle it actually subtends on the target is narrower: 8.81° against the datasheet's 10.06°, for a 2/3-inch sensor. Both numbers are true and they answer different questions — "what does the datasheet say" and "what will the camera see" — and showing only one of them is how a calculator generates support questions. LensCalc prints both, labelled, and a test asserts the nominal angle is always the wider.
The related convention is written on the page rather than hidden in it: working distance is measured to the lens's front principal plane, which is where a thin-lens model puts it, and a real lens separates its principal planes by an amount the manufacturer rarely publishes. So the result is good to a few millimetres — fine for choosing a lens, not for metrology, where you calibrate. Saying so on the tool is cheaper than saying so in every email.
A sensor table that knows no pixel counts
Eleven standard formats ship in the table, from 1/4" to full frame, each as a width and a height in millimetres and nothing else. The inch names are nominal — a 1" sensor is 16 mm on the diagonal, not 25.4, because the name descends from the diameter of the vidicon tube that produced a 16 mm image — and the test for that one is a sentence of history with an assertion under it. What the table deliberately does not carry is resolution. A sensor preset that knew its pixel count would be a camera part number in disguise, and part numbers go stale; a 2/3" format does not. So pixel pitch is derived from the format the user picked and the resolution they typed — 8.8 mm across 2,448 pixels is 3.59 µm — and nothing in the tool can be out of date. A custom width and height are accepted for the format the table lacks, and the aspect label snaps to 4:3, 16:9, 3:2, 5:4 or 1:1 when it is within two hundredths of one, and otherwise prints the ratio: 1.1" is 1.37:1, honestly.
Three pixels, not two
Nyquist says a feature must span two pixels to be detected at all. Detecting is not measuring: an edge that lands on two pixels can be found, and its position is good to about a pixel, which is to say not good. Three pixels across a feature is the usual working minimum for measuring it, and inspection work often wants four or more. The tool's default is therefore three, with Nyquist's two shown beside it as the floor — "smallest feature" and "workable feature" are two rows on the page, and the verdict on a feature size the user enters is computed at whichever sampling they chose. Here is the worked example from the optics post, run through the classes rather than typed, with the sampling half added:
| Quantity | Value | How |
|---|---|---|
| Inputs | 2/3" · 50 mm · 400 mm · 2448 × 2048 px · 0.5 mm feature at 3 px | as a link |
| Magnification | 0.142857 | f ÷ (WD − f) = 50 ÷ 350 |
| Field of view | 61.6 × 46.2 mm | 8.8 ÷ m; height by the sensor's aspect |
| Image distance · extension | 57.14 mm · 7.14 mm | f (1 + m) · f m |
| Angle of view, actual · nominal | 8.81° · 10.06° | 2·atan(FOV ÷ 2WD) · 2·atan(sensor ÷ 2f) |
| Pixel pitch | 3.59 µm | 8.8 mm ÷ 2448 px |
| Scale | 0.02516 mm/px · 39.74 px/mm | 61.6 mm ÷ 2448 px |
| Smallest · workable feature | 0.050 mm · 0.075 mm | scale × 2 · scale × 3 |
| 0.5 mm feature at 3 px | resolvable · 370 px needed of 2,448 | ⌈61.6 ÷ 0.5 × 3⌉ |
Every one of those is a unit-test assertion, and the page test runs the same query string through the controller and reads the verdict off the rendered page. A 0.02 mm feature on the same setup needs 6,161 pixels at Nyquist, which is the test's "not happening" case.
Three small things
The one-line answer lives on the result object, not in the template. Whichever of the three values was solved for is the headline — "61.6 × 46.2 mm", "50.00 mm lens", "400.0 mm away" — and a method on LensSolution decides which, so a unit test can assert all three strings and the Blade file has no branch in it. The default sensor format is non-nullable. SensorFormat::default() returns a format or throws a LogicException, never null, because the preset table is code and a missing default is a bug in the code rather than a condition a caller should handle; every caller that must have a sensor gets one without unwrapping an optional. The equations in the optics post are SVG, typeset by hand, inlined so they pick up the page's fonts — and the advance widths were measured in a browser rather than estimated, because the first attempt at estimating them produced overlapping glyphs and a minus sign that vanished. That minus sign was the one in WD − f. There is a joke in there about the shorthand, and I have decided not to make it.
What that bought
A tool that refuses to guess, that cannot be simplified into the wrong formula without a test saying so, that prints both of the angles people argue about, and that has no part numbers in it to go stale. The optics post is the maths; this is the discipline around it, and the discipline is most of the work. If you want the whole worked example on the page, here it is — change any number and the rest follows.