Files
feuerwerk/src/ui/draw.ts
T
adminandClaude Opus 5.5 32a0af6e2c Put the text and boxes of the interface on device pixels
On a screen scaled to 125 % or 150 % the editor looked soft beside
a page the browser draws itself. The layout counts in whole CSS
pixels, and most of those fall between two device pixels there. The
GPU averages both: text loses its edges, and a 1 px line becomes a
pixel and a half of mixed colour. Now text and boxes are rounded to
device pixels, and a line is one device pixel wide.

- ui/pixelGrid.ts holds the rule. crispText() makes a text with
  Pixi's roundPixels, crisp() does the same for a Graphics of
  upright boxes, and hairline() is the width of one device pixel.
- Every text of the interface is made with crispText() instead of
  new Text().
- crisp() only rounds where the scale is not a whole number. At
  100 % and 200 % nothing changes.
- drawControlBox, drawHeaderBar and drawSectionBand draw their
  lines a hairline wide and flush with the edge. The widgets that
  draw lines of their own do the same.
- Two bands share exactly the line they draw, and a column still
  ends on a whole pixel.
- The stage is nudged by 1/32 px where the scale is not whole. At
  150 % every odd pixel is an exact half, and without the nudge one
  band came out a row shorter than its neighbours.
- Curves, icons and everything of the game stay unrounded. Rounding
  their points would bend them.
- The harness takes deviceScaleFactor as an option, and the hooks
  deviceColumn, edgeContrast and pixelGrid read the frame as the
  screen gets it.

Not in here: the subpixel smoothing a browser gives its own text.
Pixi draws a text into a transparent picture first, and there only
greyscale smoothing is possible.

Measured at 150 %, edge contrast before and after: top bar 31.5 to
47.8, the lists on the left 68.6 to 86.4, inspector 27.6 to 38.4.
At 100 % it is 46.5, 90.1 and 37.9, and the picture there is the
same as before pixel for pixel, apart from the fps display.

Tests:
- Gate on melektaus-atx: 206 files, 3114 tests (3104 + 10), exit 0
- Full run on melektaus-atx: 1655 of 1656 checks passed, smokes
  24/19/21. The one that failed was a condition of the new step
  62 itself: it scrolled an inspector that had nothing to scroll.
  The step now makes the window lower for that reading.
- e2e 61,62 after that fix: 14 checks. The full run was not
  repeated, and CI is switched off for now.
- Ten unit tests, nine in tests/ui/pixel-grid.test.ts
- Eight e2e checks in the new step 62, which runs at 150 %
- Eleven unit counter-probes fail tests
- Four browser counter-probes fail their checks. They ran before
  the fix to step 62 and were not repeated for it.
- Screenshots looked at for 125 % and 150 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 08:42:26 +02:00

70 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Graphics } from 'pixi.js';
import { crisp, hairline } from './pixelGrid';
import { Theme } from './Theme';
export interface ControlBoxOptions {
fill: number;
border: number;
radius?: number;
x?: number;
y?: number;
}
/**
* The shared look of every control. The stroke is a hairline inset by half its width, so it lands
* on whole device pixels and stays crisp instead of blurring over two. Only for a `g` that holds
* nothing but boxes – see `crisp`.
*/
export function drawControlBox(g: Graphics, w: number, h: number, options: ControlBoxOptions): void {
const { fill, border, radius = Theme.radius, x = 0, y = 0 } = options;
const line = hairline();
crisp(g).roundRect(x, y, w, h, radius).fill(fill);
g.roundRect(x + line / 2, y + line / 2, w - line, h - line, radius).stroke({ width: line, color: border });
}
/** A bar across the top of something – the toolbar, a tab's header, the breadcrumb: header colour, a line below. */
export function drawHeaderBar(g: Graphics, w: number, h: number): void {
const line = hairline();
crisp(g).clear().rect(0, 0, w, h).fill(Theme.colors.panelHeader);
g.rect(0, h - line, w, line).fill(Theme.colors.border);
}
export interface GridOptions {
/** Where the origin of the drawn space sits on screen, in the same pixels as `w`/`h`. */
originX: number;
originY: number;
/** Spacing in screen pixels (world/graph step times zoom). */
step: number;
w: number;
h: number;
alpha: number;
/** Alpha of the two lines through the origin; omitted = no axes. */
axisAlpha?: number;
}
/**
* White at a low alpha rather than a fixed colour, so the grid reads on whatever background the
* scene sets. `g` is cleared by the caller.
*/
export function drawGrid(g: Graphics, options: GridOptions): void {
const { originX, originY, step, w, h, alpha, axisAlpha } = options;
const startX = ((originX % step) + step) % step;
const startY = ((originY % step) + step) % step;
for (let x = startX; x < w; x += step) g.moveTo(x, 0).lineTo(x, h);
for (let y = startY; y < h; y += step) g.moveTo(0, y).lineTo(w, y);
g.stroke({ width: 1, color: GRID_COLOR, alpha });
if (axisAlpha === undefined) return;
const axis = { width: 1, color: GRID_COLOR, alpha: axisAlpha };
if (originX >= 0 && originX <= w) g.moveTo(originX, 0).lineTo(originX, h).stroke(axis);
if (originY >= 0 && originY <= h) g.moveTo(0, originY).lineTo(w, originY).stroke(axis);
}
const GRID_COLOR = 0xffffff;
/** A rubber-band selection between two corners, in whatever space `g` draws. */
export function drawBand(g: Graphics, a: { x: number; y: number }, b: { x: number; y: number }): void {
g.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y))
.fill({ color: Theme.colors.marquee, alpha: 0.12 })
.stroke({ width: 1, color: Theme.colors.marquee });
}