Unit anchor for the LEGACY selector: 'coverage' thresholds (older
stores, and explicitly authored coverage_fractions=[...] lists — derived
ladders now use selector: 'screen-area', whose metric is
projectBoxAreaFraction and does not involve this constant): a threshold
of 1.0 is satisfied once the group's projected bbox diagonal reaches
FILL_FACTOR × fittedAxisPx pixels — i.e. HALF of the fitted screen
axis (fittedAxisPx = min(viewport.width, viewport.height)), which a
normal full-frame view already exceeds. Coarser children (smaller
fractions) step in as the object shrinks below that. Lowering the factor
shows finer levels sooner, raising it later.
Why the fitted axis, not the viewport diagonal (#1410). The previous
anchor normalised by hypot(viewport.width, viewport.height), which grows
with width regardless of aspect, while calculateCameraDistance fits the
VERTICAL fov for aspect ≥ 1 (camera distance has NO aspect dependence there)
and the HORIZONTAL fov for aspect < 1. So a wide-but-not-tall canvas grew the
denominator without the framing showing any more of the object, and the raw
ratio collapsed as the canvas widened — a cube's opening-framing metric fell
from 3.43 at 1:1 to 1.31 at 32:9 under the old scheme, and thinner shapes (an
in-plane rod, a flat pancake) dropped BELOW the finest threshold entirely on
an ultrawide monitor (#1361's blur, returning at wide aspects).
fittedAxisPx is exactly the extent calculateCameraDistance fits in
each regime — height for aspect ≥ 1, width for aspect < 1. The fit
uses the larger X/Y extent at the box's nearest face: halfDepth + inPlane/(2·fitRatio·tan(halfFov)) (and divides the second term by aspect in
portrait). The near-face distance is therefore proportional to the fitted
axis in both regimes, so the projected pixel diagonal divided by
fittedAxisPx is EXACTLY invariant across aspect and absolute viewport
size for the default centre fit modelled here. Preserving an authored
off-centre controls target changes which depth face bounds each side of the
projected rectangle. The identity remains exact while the target lies inside
the box's screen-plane footprint and at or behind its near face (target.z <= box.max.z). It degrades when either condition is violated: for an
8×8×100 box, a target 20 units off-axis drifts 23.8%, while a centred target
10 units in front of the near face drifts 50.8%. The test matrix verifies the
centre-fit identity to 9 decimal digits for a cube, pancake, in-plane rod,
UMAP-like box, and a 1×1×100 view-axis rod from 1:4 portrait through 32:9
ultrawide.
Why 0.5 and not 1.0: at 1.0 the finest level only activates once the object
OVERFILLS the fitted axis, reproducing the original #1361 symptom at the
default opening framing. Measured opening-framing diagonalPx / fittedAxisPx across all five shapes and seven aspect ratios in the test
matrix ranges 0.750 (in-plane rod) – 1.061 (cube, pancake, and view-axis
rod). Dividing by 0.5 turns that into a metric of 1.50 – 2.12 — past
the finest threshold of 1.0 with 50% headroom in the worst case. The
factor remains necessary: at 1.0 the in-plane rod would still open below the
finest rung even though the framing itself is now aspect-exact.
Coupled constant. Python's MAX_COVERAGE_FRACTION (the upper bound on
any coverage_fraction, authored or derived — a partition-bound ladder
derives exactly this value; see core/group/lod/group.py) stays 4.0,
re-expressed as SCREEN_FILL_DIAGONAL_RATIO / FILL_FACTOR rather than
1 / FILL_FACTOR: a screen-filling object's projected diagonal is no
longer exactly the fitted axis — that identity only held for the OLD
diagonal normalisation, where a screen-filling box's diagonal trivially
equals the viewport's own diagonal. Under the fitted-axis normalisation it is
instead hypot(aspect, 1) / min(aspect, 1) times the fitted axis —
aspect-DEPENDENT, not a constant — measured 1.41 at 1:1, 1.67 at 4:3, 1.80
at 3:2, 1.89 at 16:10, 2.04 at 16:9, 2.57 at a real 21:9 panel (2560×1080),
3.69 at 32:9. SCREEN_FILL_DIAGONAL_RATIO does NOT pin an average across
that spread — it is anchored specifically at 16:9, the reference aspect
(2.04, rounded to a plain 2), the same reference the FILL_FACTOR
value 0.5 above is calibrated at. Away from 16:9 the identity is
increasingly approximate: 21:9 alone is ~28% off the round value. So
MAX_COVERAGE_FRACTION = 2 / 0.5 = 4.0 is unchanged in VALUE, and the
switch point it anchors (reaching metric 4.0) still needs
diagonalPx ≈ 2 × fittedAxisPx at 16:9, matching what
diagonalPx ≈ viewportDiagonal (metric 4.0 under the OLD scheme) meant
there — but away from 16:9 this is a real, accepted behavioural trade, not
just a units relabelling. Because a screen-filling object's diagonal is
narrower than 2·fittedAxisPx at square-ish aspects and wider at
ultrawide ones, a partition-bound ladder's finest level (which derives its
anchor from MAX_COVERAGE_FRACTION) now needs a tile to grow LARGER on
screen before showing its finest level at square-ish/portrait-ish windows,
and SMALLER at ultrawide ones, than it did before #1410 — measured as the
ratio of the new required projected diagonal to the old one: ×1.41 at
1:1, ×1.20 at 4:3, ×1.06 at 16:10, ~×1.0 at 16:9 (by construction), ×0.78 at
a real 21:9 panel, ×0.54 at 32:9. This is the accepted cost of the fix: the
WHOLE-OBJECT ladder (coverage_fractions, anchored at 1.0) is what a
normal full-frame opening view hits, and that is exactly where #1410's
wide-aspect blur showed up — making that anchor aspect-exact was the goal.
A tiled layer's per-tile anchor was already only a heuristic (each tile's
own on-screen footprint already varies with camera distance and framing,
partition shape, etc.), so it is the right place to absorb the residual
aspect dependence rather than the whole-object case. A Python test
(test_max_coverage_fraction_matches_the_viewer_fill_factor) reads this
file and asserts the MAX_COVERAGE_FRACTION relation holds, and
separately that SCREEN_FILL_DIAGONAL_RATIO itself stays close to the
16:9 geometric value it stands for (so the two constants can't silently
compensate for each other).
View-axis depth fix (#1543). A 1×1×100 cloud previously measured only
~0.024 at the default 16:9 framing because the distance was sized from its
pure-depth dimension plus a hardcoded 20% margin. The exact near-face fit
above raises it to 2.121, so it opens on the finest rung like the other
full-scene shapes. The same fit removes the margin: keeping both would count
depth twice and pull ordinary 3D scenes unnecessarily far back.
Known limitation: resize without a re-fit (out of scope here).updateCameraAspect (utils/camera-utils.ts) only updates
camera.aspect (perspective) / the horizontal frustum extent
(orthographic) on a window resize — it preserves the VERTICAL fov / ortho
extent, and the viewer never re-fits the camera distance afterwards. Both
screen-space pixel extents of a projected box then depend on viewport
HEIGHT alone (not width): with vertical fov and distance unchanged, the
horizontal pixel extent is (world extent / (z·tan(vFov/2))) · (height/2) — width cancels out of it algebraically — so narrowing the
window width with height held fixed leaves the projected diagonal in
pixels completely UNCHANGED while fittedAxisPx (now width, once
width < height) keeps shrinking, inflating the metric by height/width.
Measured (a 100×100×100 cube, default opening framing, 1600×900 baseline
narrowed with height fixed at 900): 1600×900 → 500×900 inflates the metric
×1.80 (the pre-#1410 diagonal normalisation also inflated here, ×1.78 — a
wash), but 1600×900 → 200×900 inflates ×4.50 vs only ×1.99 under the old
normalisation — because the old denominator (hypot(width, height)) is
bounded below by height as width → 0, while the new one
(min(width, height)) is not. This is the flip side of the #1410 fix:
WIDENING a viewport (the actual #1410 symptom) is now exactly stable
(proven above) where it used to decay. NARROWING one is not symmetric: with
diagonalPx held fixed, the new metric only overtakes the old one PAST a
crossover at width = height² / width0 (506px / aspect ≈ 0.56 for this
baseline) — down to that point the new normalisation is actually LESS
inflated than the old one (e.g. 1600×900 → 500×900, aspect 0.56: ×1.80 new
vs ×1.78 old, already a near-wash), and only below it does narrowing
inflate the metric faster than before (1600×900 → 200×900, aspect 0.22:
×4.50 new vs ×1.99 old). Not fixed here: re-fitting the camera on resize is
a separate, larger change (it would also move the FRAMING, not just the
LOD selection) and out of scope for this normalisation fix.
The selector normalises the projected diagonal by this to a dimensionless
coverage metric, so the same thresholds behave (near-)identically at any
aspect ratio and any pixel size of the viewport.
Exported so tests can pin behaviour against the real constant instead of
hard-coding 0.5.
Unit anchor for the LEGACY
selector: 'coverage'thresholds (older stores, and explicitly authoredcoverage_fractions=[...]lists — derived ladders now useselector: 'screen-area', whose metric isprojectBoxAreaFractionand does not involve this constant): a threshold of 1.0 is satisfied once the group's projected bbox diagonal reachesFILL_FACTOR × fittedAxisPxpixels — i.e. HALF of the fitted screen axis (fittedAxisPx = min(viewport.width, viewport.height)), which a normal full-frame view already exceeds. Coarser children (smaller fractions) step in as the object shrinks below that. Lowering the factor shows finer levels sooner, raising it later.Why the fitted axis, not the viewport diagonal (#1410). The previous anchor normalised by
hypot(viewport.width, viewport.height), which grows with width regardless of aspect, whilecalculateCameraDistancefits the VERTICAL fov for aspect ≥ 1 (camera distance has NO aspect dependence there) and the HORIZONTAL fov for aspect < 1. So a wide-but-not-tall canvas grew the denominator without the framing showing any more of the object, and the raw ratio collapsed as the canvas widened — a cube's opening-framing metric fell from 3.43 at 1:1 to 1.31 at 32:9 under the old scheme, and thinner shapes (an in-plane rod, a flat pancake) dropped BELOW the finest threshold entirely on an ultrawide monitor (#1361's blur, returning at wide aspects).fittedAxisPxis exactly the extentcalculateCameraDistancefits in each regime —heightfor aspect ≥ 1,widthfor aspect < 1. The fit uses the larger X/Y extent at the box's nearest face:halfDepth + inPlane/(2·fitRatio·tan(halfFov))(and divides the second term by aspect in portrait). The near-face distance is therefore proportional to the fitted axis in both regimes, so the projected pixel diagonal divided byfittedAxisPxis EXACTLY invariant across aspect and absolute viewport size for the default centre fit modelled here. Preserving an authored off-centre controls target changes which depth face bounds each side of the projected rectangle. The identity remains exact while the target lies inside the box's screen-plane footprint and at or behind its near face (target.z <= box.max.z). It degrades when either condition is violated: for an 8×8×100 box, a target 20 units off-axis drifts 23.8%, while a centred target 10 units in front of the near face drifts 50.8%. The test matrix verifies the centre-fit identity to 9 decimal digits for a cube, pancake, in-plane rod, UMAP-like box, and a 1×1×100 view-axis rod from 1:4 portrait through 32:9 ultrawide.Why 0.5 and not 1.0: at 1.0 the finest level only activates once the object OVERFILLS the fitted axis, reproducing the original #1361 symptom at the default opening framing. Measured opening-framing
diagonalPx / fittedAxisPxacross all five shapes and seven aspect ratios in the test matrix ranges 0.750 (in-plane rod) – 1.061 (cube, pancake, and view-axis rod). Dividing by 0.5 turns that into a metric of 1.50 – 2.12 — past the finest threshold of 1.0 with 50% headroom in the worst case. The factor remains necessary: at 1.0 the in-plane rod would still open below the finest rung even though the framing itself is now aspect-exact.Coupled constant. Python's
MAX_COVERAGE_FRACTION(the upper bound on anycoverage_fraction, authored or derived — a partition-bound ladder derives exactly this value; seecore/group/lod/group.py) stays4.0, re-expressed asSCREEN_FILL_DIAGONAL_RATIO / FILL_FACTORrather than1 / FILL_FACTOR: a screen-filling object's projected diagonal is no longer exactly the fitted axis — that identity only held for the OLD diagonal normalisation, where a screen-filling box's diagonal trivially equals the viewport's own diagonal. Under the fitted-axis normalisation it is insteadhypot(aspect, 1) / min(aspect, 1)times the fitted axis — aspect-DEPENDENT, not a constant — measured 1.41 at 1:1, 1.67 at 4:3, 1.80 at 3:2, 1.89 at 16:10, 2.04 at 16:9, 2.57 at a real 21:9 panel (2560×1080), 3.69 at 32:9.SCREEN_FILL_DIAGONAL_RATIOdoes NOT pin an average across that spread — it is anchored specifically at 16:9, the reference aspect (2.04, rounded to a plain2), the same reference theFILL_FACTORvalue 0.5 above is calibrated at. Away from 16:9 the identity is increasingly approximate: 21:9 alone is ~28% off the round value. SoMAX_COVERAGE_FRACTION = 2 / 0.5 = 4.0is unchanged in VALUE, and the switch point it anchors (reaching metric 4.0) still needsdiagonalPx ≈ 2 × fittedAxisPxat 16:9, matching whatdiagonalPx ≈ viewportDiagonal(metric 4.0 under the OLD scheme) meant there — but away from 16:9 this is a real, accepted behavioural trade, not just a units relabelling. Because a screen-filling object's diagonal is narrower than 2·fittedAxisPx at square-ish aspects and wider at ultrawide ones, a partition-bound ladder's finest level (which derives its anchor fromMAX_COVERAGE_FRACTION) now needs a tile to grow LARGER on screen before showing its finest level at square-ish/portrait-ish windows, and SMALLER at ultrawide ones, than it did before #1410 — measured as the ratio of the new required projected diagonal to the old one: ×1.41 at 1:1, ×1.20 at 4:3, ×1.06 at 16:10, ~×1.0 at 16:9 (by construction), ×0.78 at a real 21:9 panel, ×0.54 at 32:9. This is the accepted cost of the fix: the WHOLE-OBJECT ladder (coverage_fractions, anchored at 1.0) is what a normal full-frame opening view hits, and that is exactly where #1410's wide-aspect blur showed up — making that anchor aspect-exact was the goal. A tiled layer's per-tile anchor was already only a heuristic (each tile's own on-screen footprint already varies with camera distance and framing, partition shape, etc.), so it is the right place to absorb the residual aspect dependence rather than the whole-object case. A Python test (test_max_coverage_fraction_matches_the_viewer_fill_factor) reads this file and asserts theMAX_COVERAGE_FRACTIONrelation holds, and separately thatSCREEN_FILL_DIAGONAL_RATIOitself stays close to the 16:9 geometric value it stands for (so the two constants can't silently compensate for each other).View-axis depth fix (#1543). A 1×1×100 cloud previously measured only ~0.024 at the default 16:9 framing because the distance was sized from its pure-depth dimension plus a hardcoded 20% margin. The exact near-face fit above raises it to 2.121, so it opens on the finest rung like the other full-scene shapes. The same fit removes the margin: keeping both would count depth twice and pull ordinary 3D scenes unnecessarily far back.
Known limitation: resize without a re-fit (out of scope here).
updateCameraAspect(utils/camera-utils.ts) only updatescamera.aspect(perspective) / the horizontal frustum extent (orthographic) on a window resize — it preserves the VERTICAL fov / ortho extent, and the viewer never re-fits the camera distance afterwards. Both screen-space pixel extents of a projected box then depend on viewport HEIGHT alone (not width): with vertical fov and distance unchanged, the horizontal pixel extent is(world extent / (z·tan(vFov/2))) · (height/2)— width cancels out of it algebraically — so narrowing the window width with height held fixed leaves the projected diagonal in pixels completely UNCHANGED whilefittedAxisPx(nowwidth, once width < height) keeps shrinking, inflating the metric byheight/width. Measured (a 100×100×100 cube, default opening framing, 1600×900 baseline narrowed with height fixed at 900): 1600×900 → 500×900 inflates the metric ×1.80 (the pre-#1410 diagonal normalisation also inflated here, ×1.78 — a wash), but 1600×900 → 200×900 inflates ×4.50 vs only ×1.99 under the old normalisation — because the old denominator (hypot(width, height)) is bounded below byheightas width → 0, while the new one (min(width, height)) is not. This is the flip side of the #1410 fix: WIDENING a viewport (the actual #1410 symptom) is now exactly stable (proven above) where it used to decay. NARROWING one is not symmetric: withdiagonalPxheld fixed, the new metric only overtakes the old one PAST a crossover atwidth = height² / width0(506px / aspect ≈ 0.56 for this baseline) — down to that point the new normalisation is actually LESS inflated than the old one (e.g. 1600×900 → 500×900, aspect 0.56: ×1.80 new vs ×1.78 old, already a near-wash), and only below it does narrowing inflate the metric faster than before (1600×900 → 200×900, aspect 0.22: ×4.50 new vs ×1.99 old). Not fixed here: re-fitting the camera on resize is a separate, larger change (it would also move the FRAMING, not just the LOD selection) and out of scope for this normalisation fix.The selector normalises the projected diagonal by this to a dimensionless coverage metric, so the same thresholds behave (near-)identically at any aspect ratio and any pixel size of the viewport.
Exported so tests can pin behaviour against the real constant instead of hard-coding 0.5.