Round number fields to the precision they display

`decimals` only reached `formatNumber`, so it changed the label and nothing else: typing 640.5 into
a field with `decimals: 0` stored 640.5 and displayed 641. The two existing users got away with it
because rounding happened outside the widget (`normalizeFrameRect`, the slice dialog), which their
comments had to point out.

`NumberField.clampValue` now rounds before it clamps, and every path that sets a value goes through
it (typing, scrubbing, arrow keys, the constructor), so the stored number matches the shown one and
`min`/`max` stay exact. The rounding is `roundTo` in math.ts, which `snapToStep` already did inline.

With that safe, camera `View width`/`View height` and text `Font size` become integers – they were
showing 640.000 and 32.000. Everything else that looked integral is not: positions, sprite and
collider sizes and zoom are all scrubbed, and `z` is a summed coordinate (walk.ts), not a layer.

Verified remotely against the RTX 4070 host: 270 checks, all passed (step 10 gained a check that the
field text has no decimal point and survives a reopen). `npm run check` green, 407 tests.
This commit is contained in:
root committed 2026-09-09 10:11:06 +02:00
1 parent 6cf47fd2ae
commit 1b16a55ffc
8 files changed
+80 -19

No files matched your search

+7 -5
View File
@@ -5,8 +5,9 @@ import type { PropertyBag, PropertyValue } from '../model/Properties';
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
/**
* `decimals` belongs only on properties that are integral by meaning (counts, indices), not on
* geometry: dragging produces fractions, and a field that hid them would commit the rounded value.
* `decimals` belongs only on properties that are integral by meaning (counts, sizes in whole
* pixels), not on geometry: dragging produces fractions, and the field rounds the value away
* rather than hiding it.
*/
| {
key: string;
@@ -58,8 +59,9 @@ export function isCommonProp(key: string): boolean {
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
camera: [
{ key: 'width', label: 'View width', kind: 'number', default: 640, min: 16, step: 1 },
{ key: 'height', label: 'View height', kind: 'number', default: 360, min: 16, step: 1 },
// A view is a pixel raster, so half a pixel of it means nothing.
{ key: 'width', label: 'View width', kind: 'number', default: 640, min: 16, step: 1, decimals: 0 },
{ key: 'height', label: 'View height', kind: 'number', default: 360, min: 16, step: 1, decimals: 0 },
{ key: 'zoom', label: 'Zoom', kind: 'number', default: 1, min: 0.1, max: 10, step: 0.1 },
],
sprite: [
@@ -104,7 +106,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
container: [],
text: [
{ key: 'text', label: 'Text', kind: 'text', default: 'Text' },
{ key: 'fontSize', label: 'Font size', kind: 'number', default: 32, min: 1, step: 1 },
{ key: 'fontSize', label: 'Font size', kind: 'number', default: 32, min: 1, step: 1, decimals: 0 },
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
{
key: 'align',
-1
View File
@@ -76,7 +76,6 @@ function rectRow(
): void {
const row = createFieldRow(
host,
// `decimals: 0` is safe here: `normalizeFrameRect` rounds every value that reaches the model.
{ key, label, kind: 'number', default: 0, step: 1, decimals: 0 },
value,
rectHandlers(host, assetId, frameId, key),
-1
View File
@@ -84,7 +84,6 @@ export class SliceDialog extends Popup {
const field = new NumberField(this.options[key], {
min,
step: 1,
// Counts and pixel offsets, rounded on change, so 0 decimals hides nothing.
decimals: 0,
onChange: (value) => {
this.options[key] = Math.round(Number(value));
+7 -3
View File
@@ -21,14 +21,18 @@ export function formatNumber(value: number, decimals = NUMBER_DECIMALS): string
return text === '-' + zero ? zero : text;
}
/** Rounds to `decimals` places, which also removes binary-float noise (0.1 × 3 = 0.30000000000000004). */
export function roundTo(value: number, decimals = NUMBER_DECIMALS): number {
return Number.isFinite(value) ? Number(value.toFixed(decimals)) : value;
}
/**
* Rounds `value` onto the grid of `stepSize`, so scrubbing lands on clean values even though
* pointer deltas are fractional. The `toFixed` pass removes binary-float noise (0.1 × 3 =
* 0.30000000000000004).
* pointer deltas are fractional.
*/
export function snapToStep(value: number, stepSize: number): number {
if (!(stepSize > 0) || !Number.isFinite(value)) return value;
return Number((Math.round(value / stepSize) * stepSize).toFixed(NUMBER_DECIMALS));
return roundTo(Math.round(value / stepSize) * stepSize);
}
/**
+8 -5
View File
@@ -1,5 +1,5 @@
import type { FederatedPointerEvent } from 'pixi.js';
import { clamp, formatNumber, NUMBER_DECIMALS, snapToStep } from '../math';
import { clamp, formatNumber, NUMBER_DECIMALS, roundTo, snapToStep } from '../math';
import { ui } from '../UIContext';
import { TextField, type TextFieldOptions } from './TextField';
@@ -9,8 +9,8 @@ export interface NumberFieldOptions extends Omit<TextFieldOptions, 'onInput' | '
/** Increment per scrubbed pixel and per arrow key, and the grid a scrub snaps to. */
step?: number;
/**
* Decimal places the text shows (default `NUMBER_DECIMALS`). Lower it only where the stored
* value has that precision too, or an edit commits the rounded number back into the project.
* Decimal places the value is kept *and* shown at (default `NUMBER_DECIMALS`) – the field rounds
* to it, so lowering it drops precision rather than hiding it.
*/
decimals?: number;
/** Fires continuously while scrubbing or typing a valid number. */
@@ -128,11 +128,14 @@ export class NumberField extends TextField {
return super.onBridgeKey(event);
}
/** Clamps to min/max; NaN/Infinity keep the current value (0 before the first assignment). */
/**
* Rounds to `decimals`, then clamps so the bounds stay exact; NaN/Infinity keep the current value
* (0 before the first assignment). Every path that sets the value goes through here.
*/
private clampValue(value: number): number {
// `?? 0` is not dead despite the type: the constructor calls this before assigning `numeric`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!Number.isFinite(value)) return this.numeric ?? 0;
return clamp(value, this.min, this.max);
return clamp(roundTo(value, this.decimals), this.min, this.max);
}
}