@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
404 lines (344 loc) • 12.9 kB
JavaScript
import { UnitDimension, UNIT_DIMENSION_COUNT } from "./UnitDimension.js";
import { UNIT_DIMENSION_MAPPING } from "./UNIT_DIMENSION_MAPPING.js";
/**
* Order in which dimensions are visited when assembling a base-unit string. Chosen to match the
* conventional way SI compound units are written (mass appears before length in "kg·m/s^2",
* "kg·m^2/s^2", etc.).
*
* @type {number[]}
*/
const RENDER_ORDER = [
UnitDimension.Mass,
UnitDimension.Length,
UnitDimension.Time,
UnitDimension.ElectricCurrent,
UnitDimension.Temperature,
UnitDimension.AmountOfSubstance,
UnitDimension.LuminousIntensity
];
/**
* Format an exponent as either an empty suffix (for ^1) or a `^n` suffix.
* @param {number} exponent
* @returns {string}
*/
function format_exponent_suffix(exponent) {
if (exponent === 1) {
return '';
}
return `^${exponent}`;
}
/**
* Append a `symbol[^k]` term to either the numerator or denominator buffer.
*
* @param {string} numerator
* @param {string} denominator
* @param {string} symbol
* @param {number} power Non-zero power.
* @returns {[string, string]} New (numerator, denominator) pair.
*/
function append_term(numerator, denominator, symbol, power) {
if (power > 0) {
const term = symbol + format_exponent_suffix(power);
return [numerator === '' ? term : numerator + '·' + term, denominator];
}
const term = symbol + format_exponent_suffix(-power);
return [numerator, denominator === '' ? term : denominator + '·' + term];
}
/**
* Count how many components of an exponent vector are non-zero — used as the "term cardinality"
* objective during decomposition.
*
* @param {Float64Array} exponents
* @returns {number}
*/
function count_nonzero(exponents) {
let count = 0;
for (let i = 0; i < UNIT_DIMENSION_COUNT; i++) {
if (exponents[i] !== 0) {
count++;
}
}
return count;
}
/**
* Count how many components of `(remaining - k * unit)` are non-zero. Hot path during
* decomposition; written without allocating a temporary vector.
*
* @param {Float64Array} remaining
* @param {Float64Array} unit
* @param {number} k
* @returns {number}
*/
function count_nonzero_after_subtract(remaining, unit, k) {
let count = 0;
for (let i = 0; i < UNIT_DIMENSION_COUNT; i++) {
if (remaining[i] - k * unit[i] !== 0) {
count++;
}
}
return count;
}
/**
* Subtract `k * unit` from `remaining` in place.
*
* @param {Float64Array} remaining
* @param {Float64Array} unit
* @param {number} k
*/
function subtract_scaled(remaining, unit, k) {
for (let i = 0; i < UNIT_DIMENSION_COUNT; i++) {
remaining[i] -= k * unit[i];
}
}
/**
* Maximum |k| considered when looking for the integer power of a named unit that best reduces
* the remaining matrix. Chosen large enough to handle squares / cubes of common units while
* keeping the inner loop bounded.
*
* @type {number}
*/
const MAX_POWER_SEARCH = 4;
/**
* Whether the given exponent vector has 2 or more non-zero components — i.e. whether the
* named unit it belongs to expresses a genuine compound dimension (Newton, Volt, ...) rather
* than just a renaming of a single base dimension.
*
* @param {Float64Array} exponents
* @returns {boolean}
*/
function is_compound(exponents) {
let count = 0;
for (let i = 0; i < UNIT_DIMENSION_COUNT; i++) {
if (exponents[i] !== 0) {
count++;
if (count >= 2) {
return true;
}
}
}
return false;
}
/**
* Compute `k` such that every component of `matrix` equals `k * named_unit`. Returns `null`
* when no such scalar exists, or when `named_unit` is itself dimensionless.
*
* @param {Float64Array} matrix
* @param {Float64Array} named_unit
* @returns {number|null}
*/
function compute_scalar_power(matrix, named_unit) {
let k = null;
for (let i = 0; i < UNIT_DIMENSION_COUNT; i++) {
const ni = named_unit[i];
const mi = matrix[i];
if (ni === 0) {
if (mi !== 0) {
return null;
}
continue;
}
const candidate = mi / ni;
if (k === null) {
k = candidate;
} else if (k !== candidate) {
return null;
}
}
return k;
}
/**
* Look for a registry entry that is a non-zero **integer** scalar multiple of `matrix` — i.e.
* an exact match (including positive/negative integer powers like `V`, `V^2`, `1/V`). Fractional
* scalars are rejected; that prevents nonsensical renders like `Gy^0.5` for a speed matrix.
*
* Returns the registry index and matching power, or `index === -1` when no integer scalar
* match exists.
*
* @param {Float64Array} matrix
* @param {NamedUnit[]} named_units
* @returns {{ index: number, power: number }}
*/
function find_scalar_match(matrix, named_units) {
for (let i = 0; i < named_units.length; i++) {
const power = compute_scalar_power(matrix, named_units[i].unit.exponents);
if (power !== null && power !== 0 && Number.isInteger(power)) {
return { index: i, power };
}
}
return { index: -1, power: 0 };
}
/**
* Pick the (named-unit-index, power) pair that minimises the non-zero count of
* `remaining - power * named_unit.unit`, considering only **compound** registry entries
* (those with at least two non-zero exponents).
*
* Single-dimension entries (e.g. METER, SECOND) are skipped: they would just substitute their
* symbol for the corresponding base symbol while reordering terms by registry order rather
* than the canonical RENDER_ORDER. The base-residual pass handles those cleanly.
*
* Ties are broken by registry order (first match wins).
*
* @param {Float64Array} remaining
* @param {NamedUnit[]} named_units
* @param {number} baseline Current non-zero count of `remaining`.
* @returns {{ index: number, power: number, nonzero: number }}
*/
function find_best_compound_unit(remaining, named_units, baseline) {
let best_index = -1;
let best_power = 0;
let best_nonzero = baseline;
for (let i = 0; i < named_units.length; i++) {
const unit_exp = named_units[i].unit.exponents;
if (!is_compound(unit_exp)) {
continue;
}
for (let k = -MAX_POWER_SEARCH; k <= MAX_POWER_SEARCH; k++) {
if (k === 0) {
continue;
}
const after = count_nonzero_after_subtract(remaining, unit_exp, k);
if (after < best_nonzero) {
best_nonzero = after;
best_index = i;
best_power = k;
}
}
}
return { index: best_index, power: best_power, nonzero: best_nonzero };
}
/**
* Greedily decompose `unit_matrix` against the **compound** entries of the registry, appending
* recognised named-unit terms into the numerator/denominator buffers. The leftover residual
* exponents are returned for later rendering as base-dimension terms.
*
* Each iteration picks the (named unit, integer power) pair that strictly reduces the term
* count: `total terms = #picked + nonzero(residual)`. The algorithm stops once no further
* pick yields a strict improvement.
*
* Note: this function does NOT handle the exact-match case (`matrix == k * named_unit`) — that
* is detected separately upstream and short-circuits to a single-term render so callers can
* benefit from named single-dim units like Hz.
*
* @param {UnitMatrix} unit_matrix
* @param {NamedUnit[]} named_units
* @returns {{ numerator: string, denominator: string, residual: Float64Array }}
*/
function decompose_compound(unit_matrix, named_units) {
const residual = new Float64Array(UNIT_DIMENSION_COUNT);
residual.set(unit_matrix.exponents);
let numerator = '';
let denominator = '';
while (true) {
const baseline = count_nonzero(residual);
if (baseline === 0) {
break;
}
const best = find_best_compound_unit(residual, named_units, baseline);
// Strict improvement: total terms decrease only when the new non-zero count is at
// least 2 below the baseline (a 1-step reduction is offset by adding a named term,
// for net zero change). This avoids cosmetic substitutions like `m/s -> Hz·m`.
if (best.index === -1 || best.nonzero > baseline - 2) {
break;
}
const named = named_units[best.index];
subtract_scaled(residual, named.unit.exponents, best.power);
const result = append_term(numerator, denominator, named.symbol, best.power);
numerator = result[0];
denominator = result[1];
}
return { numerator, denominator, residual };
}
/**
* Append every non-zero residual base-dimension exponent to the numerator/denominator buffers,
* using `dimension_units[i].symbol` for each dimension `i`.
*
* @param {string} numerator
* @param {string} denominator
* @param {Float64Array} residual
* @param {NamedUnit[]} dimension_units One entry per base dimension, in {@link UnitDimension} order.
* @returns {[string, string]}
*/
function append_base_residual(numerator, denominator, residual, dimension_units) {
let n = numerator;
let d = denominator;
for (let i = 0; i < RENDER_ORDER.length; i++) {
const dim = RENDER_ORDER[i];
const e = residual[dim];
if (e === 0) {
continue;
}
const result = append_term(n, d, dimension_units[dim].symbol, e);
n = result[0];
d = result[1];
}
return [n, d];
}
/**
* Convert a {@link UnitMatrix} into a human-readable string such as `m/s`, `kg·m/s^2` or `Hz`.
*
* Output rules:
* - dimensions with positive exponent appear in the numerator, joined by `·`
* - dimensions with negative exponent appear in the denominator, joined by `·`
* - exponents of magnitude 1 are written without a suffix; otherwise `^n` is appended
* - dimensionless matrices return an empty string
* - if every term lives in the denominator, the numerator is rendered as `1`
*
* Optional named-unit registry (`named_units` argument):
* - if the matrix is a non-zero scalar multiple of any registry entry, that entry is used
* directly: `Hz` for `1/s`, `V` for the volt matrix, `V^2` for its square, `1/V` for its
* inverse.
* - otherwise the matrix is greedily decomposed against compound registry entries (named
* units with at least two non-zero exponents). The algorithm tries to use the fewest terms
* possible — `V/m^2` is rendered as `V/m^2` (two terms) rather than `kg/(s^3·A)` (four
* terms). Single-dimension entries (`METER`, `SECOND`, ...) are skipped here so the
* leftover base residual renders in canonical `Mass·Length·Time·...` order.
* - registry order acts as a tiebreaker — the first match wins among options with the same
* term-count reduction.
*
* @param {UnitMatrix} unit_matrix
* @param {NamedUnit[]} [named_units] Optional registry of compound-unit symbols.
* @param {NamedUnit[]} [dimension_units=UNIT_DIMENSION_MAPPING] One {@link NamedUnit} per base dimension, indexed by {@link UnitDimension}. Only the `.symbol` field is read.
* @returns {string}
*/
export function unit_matrix_to_string(
unit_matrix,
named_units = undefined,
dimension_units = UNIT_DIMENSION_MAPPING
) {
if (unit_matrix.is_dimensionless()) {
return '';
}
let numerator = '';
let denominator = '';
let residual;
if (named_units !== undefined && named_units.length > 0) {
// Phase 1: exact scalar match short-circuits to a single named term.
const scalar = find_scalar_match(unit_matrix.exponents, named_units);
if (scalar.index !== -1) {
const named = named_units[scalar.index];
const result = append_term('', '', named.symbol, scalar.power);
if (result[1] === '') {
return result[0];
}
return '1/' + result[1];
}
// Phase 2: greedy decomposition against compound entries; leftover residual renders
// via the base-residual pass below.
const decomposed = decompose_compound(unit_matrix, named_units);
numerator = decomposed.numerator;
denominator = decomposed.denominator;
residual = decomposed.residual;
} else {
residual = unit_matrix.exponents;
}
const result = append_base_residual(numerator, denominator, residual, dimension_units);
numerator = result[0];
denominator = result[1];
if (denominator === '') {
return numerator;
}
if (numerator === '') {
return '1/' + denominator;
}
return numerator + '/' + denominator;
}