perf(ui): F8-12 — pixel-space planet sizing + single-copy label/outline layers (#55)
* Planet size formula moves to pixel-space: `pointRadiusBasePx = 2 + 2 * cbrt(size / SIZE_NORMALIZER)`. The on-screen disc now reads ~4-7 px at the reference zoom regardless of how large the world rectangle is — the previous `world-units` formulation blew up on small maps and made Source-class planets swallow their neighbours. * Labels + outlines live in the origin copy only. The 9× replication across torus copies was the dominant cost on a 100+ planet map (Pixi.Text creation + Graphics rebuilds on every zoom step); the origin-copy layout is what the camera-wrap listener guarantees the user actually sees. * `setPlanetLabels` and `setPlanetOutlines` skip Pixi-object rebuilds when the input fingerprint is unchanged — toggle flips and selection changes now keep the existing Text / Graphics instances alive and only repaint the affected pieces. * `renderer.md` updated to the new contract. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -32,21 +32,24 @@ describe("displayPointRadiusWorld — pixel-space (pointRadiusPx)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("displayPointRadiusWorld — softened by zoom (pointRadiusWorld)", () => {
|
||||
test("at scale=scaleRef the visible radius equals the base radius", () => {
|
||||
describe("displayPointRadiusWorld — softened by zoom (pointRadiusBasePx)", () => {
|
||||
test("at scale=scaleRef the on-screen pixel size equals the base", () => {
|
||||
const radius = displayPointRadiusWorld(
|
||||
{ pointRadiusWorld: 6 },
|
||||
{ pointRadiusBasePx: 6 },
|
||||
0.2,
|
||||
0.2,
|
||||
);
|
||||
expect(radius).toBeCloseTo(6);
|
||||
// world units → 6 (base px) / 0.2 (scale) = 30
|
||||
expect(radius).toBeCloseTo(30);
|
||||
// confirm pixel-space: world * scale ≈ 6.
|
||||
expect(radius * 0.2).toBeCloseTo(6);
|
||||
});
|
||||
|
||||
test("zooming in grows the radius sub-linearly", () => {
|
||||
const r1 = displayPointRadiusWorld({ pointRadiusWorld: 6 }, 0.2, 0.2);
|
||||
const r10 = displayPointRadiusWorld({ pointRadiusWorld: 6 }, 2.0, 0.2);
|
||||
// On-screen pixel size grows by scale^α (α = 0.33) instead of
|
||||
// linearly: 10x zoom → ~10^0.33 ≈ 2.15x growth.
|
||||
test("zooming in grows the on-screen pixel size sub-linearly", () => {
|
||||
const r1 = displayPointRadiusWorld({ pointRadiusBasePx: 6 }, 0.2, 0.2);
|
||||
const r10 = displayPointRadiusWorld({ pointRadiusBasePx: 6 }, 2.0, 0.2);
|
||||
// On-screen pixel size grows by scale^α (α = 0.33): 10x zoom
|
||||
// → 10^0.33 ≈ 2.15x growth.
|
||||
const onScreenAt1 = r1 * 0.2;
|
||||
const onScreenAt10 = r10 * 2.0;
|
||||
expect(onScreenAt10 / onScreenAt1).toBeCloseTo(
|
||||
@@ -55,14 +58,16 @@ describe("displayPointRadiusWorld — softened by zoom (pointRadiusWorld)", () =
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores pointRadiusPx when pointRadiusWorld is set", () => {
|
||||
test("ignores pointRadiusPx when pointRadiusBasePx is set", () => {
|
||||
const r = displayPointRadiusWorld(
|
||||
{ pointRadiusPx: 99, pointRadiusWorld: 4 },
|
||||
{ pointRadiusPx: 99, pointRadiusBasePx: 4 },
|
||||
0.4,
|
||||
0.2,
|
||||
);
|
||||
// World radius is the base softened by (0.4/0.2)^(α-1).
|
||||
expect(r).toBeCloseTo(4 * Math.pow(2, PLANET_SIZE_ZOOM_ALPHA - 1), 4);
|
||||
// On-screen pixel size: 4 * (0.4 / 0.2)^α = 4 * 2^0.33
|
||||
// In world units: (4 * 2^0.33) / 0.4.
|
||||
const expected = (4 * Math.pow(2, PLANET_SIZE_ZOOM_ALPHA)) / 0.4;
|
||||
expect(r).toBeCloseTo(expected, 4);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -280,20 +280,19 @@ describe("hitTest — empty results and scale", () => {
|
||||
expect(ids(w, "torus", cam05, cursorOver(516, 500, cam05))).toBe(null);
|
||||
});
|
||||
|
||||
test("pointRadiusWorld scales softly with zoom (F8-12 / #31)", () => {
|
||||
// world 1000×1000, viewport 200×200 → scaleRef = 0.2 (every
|
||||
// world unit becomes 0.2 px on screen at the "whole world fits"
|
||||
// zoom). PLANET_SIZE_ZOOM_ALPHA is 0.33: r_display =
|
||||
// r_base * (scale / scaleRef)^(α - 1).
|
||||
test("pointRadiusBasePx scales softly with zoom (F8-12 / #31)", () => {
|
||||
// world 1000×1000, viewport 200×200 → scaleRef = 0.2. At
|
||||
// scale=0.5 the on-screen pixel size is
|
||||
// basePx * (scale/scaleRef)^α
|
||||
// → 6 * (0.5/0.2)^0.33 ≈ 6 * 1.354 ≈ 8.13 px. In world units
|
||||
// that becomes ≈ 16.27, plus slop 4/0.5 = 8 → threshold ≈ 24.27.
|
||||
const cam05 = camAt(500, 500, 0.5);
|
||||
const wBase = new World(1000, 1000, [
|
||||
point(1, 500, 500, { style: { pointRadiusWorld: 6 } }),
|
||||
point(1, 500, 500, { style: { pointRadiusBasePx: 6 } }),
|
||||
]);
|
||||
// At scale=0.5 the softening factor is (0.5/0.2)^(0.33-1) ≈ 0.554.
|
||||
// Visible radius ≈ 3.32 world units, slop 8, threshold ≈ 11.32.
|
||||
expect(ids(wBase, "torus", cam05, cursorOver(510, 500, cam05))).toBe(1);
|
||||
// Cursor 12 world units away exceeds the threshold.
|
||||
expect(ids(wBase, "torus", cam05, cursorOver(512, 500, cam05))).toBe(null);
|
||||
expect(ids(wBase, "torus", cam05, cursorOver(520, 500, cam05))).toBe(1);
|
||||
// Cursor 26 world units away exceeds the threshold (~24.27).
|
||||
expect(ids(wBase, "torus", cam05, cursorOver(526, 500, cam05))).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user