An orthographic camera has no field of view, so 'make it fit' stops being a lens decision and becomes one line of arithmetic.
A perspective camera always leaves you a way out. The frame is wrong, so you widen the field of view, or you back the camera off, and somewhere between those two dials is a combination that puts the subject where you wanted it. An orthographic projection has neither. Parallel lines stay parallel, distance does not change size, and moving the camera further away changes precisely nothing about how big anything appears.
What's left is a single scalar. zoom on a three.js orthographic camera is pixels per world unit, and it's the only thing standing between a room measured in metres and a viewport measured in CSS pixels. So the framing has to be written as a contract. Here is a box of world units that must survive inside the canvas, whatever shape the canvas turns out to be, and the fit is whichever zoom honours it on both axes at once.
One angle, and only one
True isometric is two exact angles: azimuth 45° and elevation atan(1/√2), about 35.264°, and that second number is the one elevation at which the three world axes project to equal lengths on screen. A unit along x, y and z all measure the same on the page, which is what lets the diorama read as a drawing of a room rather than a photograph of one.
Two degrees off and nothing looks broken. It just quietly stops being isometric. A rack unit 0.3 tall no longer measures the same as 0.3 of desk depth, and the eye files the whole thing as a mildly odd perspective render. Which is why the config stores the expression and not the value: Math.atan(1 / Math.SQRT2) says why the number is what it is. 0.6155 would only say that somebody once measured something.
/** True isometric framing. Camera azimuth 45deg, elevation atan(1/sqrt2). */
export const ISO = {
azimuth: Math.PI / 4,
elevation: Math.atan(1 / Math.SQRT2), // ~35.264 deg
distance: 40,
// centred on the room so the WHOLE diorama fits inside its box with margin —
// it must never bleed off its edges or overlap the hero copy. y is lifted to
// the projected centroid so floor and wall-tops share the vertical padding.
// 3.5, not 3.2: the room's TRUE projected centroid. At 3.2 the vertical
// margin was asymmetric — 0.43 units above the wall tops against 0.92 below
// the floor — and since focusPitch grows the silhouette from 17.15 to 18.27,
// the back wall's top left the frame under an upward pitch before the lateral
// drift was even visible.
target: [0, 3.5, 0] as const,
// world units that must fit the box (width / height). The room projects to
// ~19.8 x ~17.1 units; these carry a padding margin so nothing touches an
// edge. Larger box => bigger room, same composition (4K just sharper).
fitW: 21,
fitH: 18.5,
/** what the fit eases toward on a long box — see CameraRig's aspect blend.
* Widening the fit alone would only make the room smaller; the azimuth turns
* with it so the extra width is filled by the room presenting more of itself
* rather than by empty floor. */
fitWUltra: 24.5,
azimuthUltra: 0.72,
// phones show the room in a bottom band — centre on the room and frame the
// WHOLE diorama (incl. the sofa) so nothing important clips off the band
targetCompact: [0.4, 2.1, 0.5] as const,
fitWCompact: 20,
// 19.5, not 17.5. The phone band's mask ramps from transparent to opaque over
// its first 16% (~64px), and at 17.5 the room's top edge seated about 33px
// into that ramp — so the wall tops were rendering at roughly half alpha and
// reading as a fade rather than as a room.
fitHCompact: 19.5,
// ---- parallax ----
// two strengths: a small idle drift while the cursor is out over the hero
// copy, and a bigger focus swing when it is actually on the diorama.
// angles only, nothing zooms, and letting go relaxes straight back
parallaxYaw: 0.05,
parallaxPitch: 0.03,
focusYaw: 0.26,
focusPitch: 0.1,
/** how far the camera's centre slides toward the pointed-at side, in world
* units, along the camera's own screen-right axis */
// 0.6, not 1.35. The room's silhouette projects to 19.80 wide against a
// fitW of 21, so there are 0.6 units of horizontal margin — and 1.35 of slide
// pushed the far edge 0.75 units PAST the canvas edge at full pointer
// deflection, roughly 35 clipped pixels at 1080p. It is also simply a lot of
// movement for a diorama whose whole premise is that it sits still in its box.
focusDrift: 0.6,
/** the same, vertically — smaller, because the room is much shorter than it
* is wide and the wall tops are close to the top of the frame already */
focusDriftY: 0.55,
} as const;The box is authored, not measured
The room is a 14 × 14 floor with 7-unit walls. Turn it 45° and its screen width is the floor's own diagonal, 14√2 ≈ 19.8 units. Its screen height is that diagonal foreshortened by sin(elevation), about 11.4, plus the wall height stood back up by cos(elevation), about 5.7. Which is exactly where the config comment's ~19.8 × ~17.1 comes from. Both numbers fall straight out of the angle, and neither is measured at runtime.
Against that, fitW and fitH are 21 and 18.5. Roughly 6% of horizontal margin and 8% of vertical, and the asymmetry is deliberate: the wall tops sit close to the top edge and the hero copy stacks over the same canvas, so the vertical needs more slack. Couldn't I compute a bounding box instead? Sure, and it would be more accurate and considerably worse, because it would retighten every time a prop moved and the framing would drift along with the furniture.
y is the room's projected centroid, so floor and wall tops share the vertical padding. At 3.2 the margin over the walls was under half the margin below the floordistance at 40 that is 28 units in front of the target and 30 behind, around a room about 11 either side. It was -60 → 140 once, and over 200 units a 16-bit depth buffer is coarse enough that the floor slab shimmered through its concretemin, and what max would cost you
Each axis proposes a zoom of its own: width / fitW and height / fitH. Taking the minimum means the more restrictive axis binds and the other is left with slack, so the box touches the frame in one direction and floats in the other. That slack is the only reason the room never bleeds off an edge.
// the fit. zoom is pixels per world unit: each axis proposes one,
// and min takes the more restrictive
const zx = vw / box.fitW;
const zy = vh / box.fitH;
const zoom = p.useMax ? Math.max(zx, zy) : Math.min(zx, zy);
// the padded fit box
const fw = box.fitW * zoom;
const fh = box.fitH * zoom;
ctx.setLineDash([5, 5]);
ctx.strokeStyle = "rgba(10,10,10,.28)";
ctx.lineWidth = 1;
ctx.strokeRect(vx + (vw - fw) / 2, vy + (vh - fh) / 2, fw, fh);
ctx.setLineDash([]);max is the tempting mistake, because it fills the frame, and a filled frame looks decisive in a screenshot. On a portrait window (a phone held upright, or a browser dragged narrow) the height ratio is the larger of the two, so max scales the room until it fills vertically and the left and right walls run off the sides. The diorama's entire argument is that it's a cutaway room. Lose a wall to the frame edge and you lose the read.
Picking one axis outright is worse than either, because it's only ever right in one orientation. Bind to width and a wide window crops the wall tops. Bind to height and a tall one crops the walls. min is the single rule that survives both without having to know which one it's in.
The same box on a laptop and a 4K panel
The fit gets recomputed every frame from the canvas's current pixel size, which is why the rig carries no resize handling of its own. size comes straight off the renderer's store, the next frame's baseZoom follows it, and the room grows continuously as you drag the browser edge. Two divisions and a Math.min per frame. There's nothing to cache and nothing to go stale.
const aspect = size.height > 0 ? size.width / size.height : 1;
const wide = compact ? 0 : clamp01((aspect - 1.25) / 0.85);
const fw = compact ? ISO.fitWCompact : ISO.fitW + wide * (ISO.fitWUltra - ISO.fitW);
const fh = compact ? ISO.fitHCompact : ISO.fitH;
const baseZoom = Math.min(size.width / fw, size.height / fh);
if (!curTarget.current) curTarget.current = defaultTarget.clone();
if (curZoom.current === 0) curZoom.current = baseZoom; // first frame: no zoom-in from nothing
if (!seeded.current) {
// the springs start exactly where the camera already is, at zero velocity,
// so the first frame after mount cannot be a move
seeded.current = true;
const s = springs.current;
s.tx.x = curTarget.current.x;
s.ty.x = curTarget.current.y;
s.tz.x = curTarget.current.z;
s.zoom.x = Math.log(curZoom.current);
}
let goalT: THREE.Vector3;
let goalZoom: number;
let goalAz: number;
let goalEl: number;
if (focus) {
goalT = focus.target;
goalZoom = baseZoom * focus.zoom;
goalAz = focus.az;
goalEl = focus.el;
} else {
goalT = defaultTarget;
goalZoom = baseZoom;
goalAz = ISO.azimuth + wide * (ISO.azimuthUltra - ISO.azimuth);
goalEl = ISO.elevation;
}goalZoom = baseZoom * focus.zoom is what keeps the push-ins portable. The monitor's close-up is 6.0, the rack's 3.75, the whiteboard's 3.15. All multipliers, never absolute zooms. A push-in therefore crops the same fraction of the frame on a phone as on a 4K panel, because it never leaves the units the fit established.
The same property is why a larger display doesn't reframe anything. Twice the pixels at the same shape means twice the zoom, which means the same 21 × 18.5 units of room drawn twice as large. A 4K panel gets this composition with more samples in it. The phone genuinely is a different one: a narrower, taller box on a lower centre, so the whole room, sofa included, fits inside its band.
On a desktop, shape is the one thing that does move the framing. Above an aspect of about 1.25, which in practice means an ultrawide monitor or a window dragged short, wide starts turning the room: the azimuth eases from 45° toward about 41°, and gets there at an aspect of 2.1, so a long box sees a little more of the room's long side. It's a blend rather than a breakpoint, so dragging a window across the threshold is a slow turn and never a jump. It does bend the rule from the top of this page, though. At 41° the x and z axes no longer project to quite the same length, so on a very long screen the room sits a few degrees short of true isometric.
