UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

404 lines (344 loc) 12.9 kB
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; }