@reekon-tools/boldr-utils 1.10.0 → 1.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  import { create, all } from 'mathjs';
2
2
  import { ColumnType } from '../types/firestore.js';
3
- import { equationForField, findField } from './schema.js';
3
+ import { bindingUnitForBase, equationForField, fieldDimension, findField, } from './schema.js';
4
4
  import { conversionCellNumber, findConversionRow } from './conversionTable.js';
5
- import { fromCanonical, toCanonical } from './units.js';
5
+ import { deriveResultUnitForBase } from './expressionUnits.js';
6
+ import { fromCanonical, isEquationBase, toCanonical, } from './units.js';
6
7
  // Trig in calculator equations works in DEGREES: angle fields are stored in
7
8
  // degrees canonically, and construction authors write `H = W * tan(A)`
8
9
  // expecting A in degrees. mathjs has no `angle` config option (evaluateFormula
@@ -107,6 +108,46 @@ export const numericFieldValue = (field, value, columnId) => {
107
108
  return null;
108
109
  }
109
110
  };
111
+ // ---------------------------------------------------------------------------
112
+ // Scope units.
113
+ //
114
+ // A base-anchored equation derives every unit from its base rather than
115
+ // reading `variableUnits` / `resultUnit` off the document. Those fields are
116
+ // still written (older app builds evaluate from them), but they are a cache of
117
+ // this derivation — and trusting a cache over its source is exactly how a
118
+ // stale entry silently rescales a live answer. So the evaluator recomputes.
119
+ //
120
+ // Equations with no base keep the legacy reading, byte for byte: saved
121
+ // calculator instances freeze their definition, so pre-base equations must go
122
+ // on evaluating exactly as they did the day they were saved.
123
+ // ---------------------------------------------------------------------------
124
+ /** The unit one variable's canonical value is re-expressed in before scoping. */
125
+ const scopeUnitFor = (definition, equation, variable, fieldId) => {
126
+ if (!isEquationBase(equation.base)) {
127
+ return equation.variableUnits?.[variable] ?? null;
128
+ }
129
+ const field = findField(definition, fieldId);
130
+ if (!field)
131
+ return null;
132
+ return bindingUnitForBase(equation.base, field, equation.variableColumnIds?.[variable]);
133
+ };
134
+ /** The unit the expression's raw result carries, or null when it needs none. */
135
+ const resultUnitFor = (definition, equation) => {
136
+ if (!isEquationBase(equation.base))
137
+ return equation.resultUnit ?? null;
138
+ const target = findField(definition, equation.targetFieldId);
139
+ // A number target takes the raw value — the whole point of evaluating in
140
+ // display units is that `area_in_ft² / 33` yields squares, not µm².
141
+ if (!target || fieldDimension(target) === 'none')
142
+ return null;
143
+ const derived = deriveResultUnitForBase(equation.base, definition.fields, equation);
144
+ if (derived.kind === 'unit')
145
+ return derived.unit;
146
+ if (derived.kind === 'none')
147
+ return null;
148
+ // Inference declined; the author's stored unit is the only information left.
149
+ return equation.resultUnit ?? null;
150
+ };
110
151
  const evaluateEquationInContext = (equation, ctx) => {
111
152
  const cached = ctx.cache.get(equation.id);
112
153
  if (cached)
@@ -131,9 +172,9 @@ const evaluateEquationInContext = (equation, ctx) => {
131
172
  }
132
173
  return resolved;
133
174
  }
134
- // Field values resolve canonically; a variable unit re-expresses the
135
- // value in the author's chosen display unit before it enters the scope.
136
- const unit = equation.variableUnits?.[variable];
175
+ // Field values resolve canonically; the scope unit re-expresses each one
176
+ // in the equation's own terms before it enters the expression.
177
+ const unit = scopeUnitFor(ctx.definition, equation, variable, fieldId);
137
178
  try {
138
179
  scope[variable] =
139
180
  unit != null ? fromCanonical(resolved.value, unit) : resolved.value;
@@ -163,11 +204,12 @@ const evaluateEquationInContext = (equation, ctx) => {
163
204
  else {
164
205
  // A result unit means the expression produced a display-unit value;
165
206
  // convert back so the returned value is canonical like everything else.
207
+ const resultUnit = resultUnitFor(ctx.definition, equation);
166
208
  try {
167
209
  result = {
168
210
  ok: true,
169
- value: equation.resultUnit != null
170
- ? toCanonical(evaluated.value, equation.resultUnit)
211
+ value: resultUnit != null
212
+ ? toCanonical(evaluated.value, resultUnit)
171
213
  : evaluated.value,
172
214
  };
173
215
  }
@@ -1,6 +1,6 @@
1
1
  import { type MathNode } from 'mathjs';
2
2
  import type { CalculatorEquation, CalculatorField } from './schema.js';
3
- import { type CalculatorUnit, type FieldDimension } from './units.js';
3
+ import { type CalculatorUnit, type EquationBase, type FieldDimension } from './units.js';
4
4
  /** Constants mathjs resolves without a scope entry. */
5
5
  export declare const MATHJS_CONSTANTS: Set<string>;
6
6
  export type ParseResult = {
@@ -39,6 +39,20 @@ export interface InferredExpressionUnit {
39
39
  unit: CalculatorUnit | null;
40
40
  /** Distinct base units among the variables carrying that dimension. */
41
41
  bases: string[];
42
+ /**
43
+ * Every distinct length-family base the variables contributed, whether or
44
+ * not the exponents survived to the result.
45
+ *
46
+ * Separate from `bases` because `bases` describes the RESULT, and a result
47
+ * can be dimensionless while its inputs still disagree: `(A_in * B_in) /
48
+ * C_ft²` cancels to a plain count, so `bases` is empty and `dimension` is
49
+ * 'none' — and the 144× scale error that ratio carries is invisible there.
50
+ * Tracked per axis, since a length and an angle in one expression is normal
51
+ * (trig consumes the angle) while two length bases never is.
52
+ */
53
+ lengthBases: string[];
54
+ /** Every distinct angle base the variables contributed ('deg' / 'rad'). */
55
+ angleBases: string[];
42
56
  }
43
57
  export type ExpressionUnitInference = ({
44
58
  ok: true;
@@ -71,6 +85,86 @@ export declare const unitScaleRatio: (declared: CalculatorUnit, actual: Calculat
71
85
  * existing equivalent unit is preserved so `in_frac` isn't churned to `in`.
72
86
  */
73
87
  export declare const deriveResultUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableColumnIds" | "variableUnits" | "resultUnit">) => CalculatorUnit | null;
88
+ /** The scope unit for every variable of a base-anchored equation. */
89
+ export declare const deriveVariableUnits: (base: EquationBase, fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "variableToFieldId" | "variableColumnIds">) => Record<string, CalculatorUnit>;
90
+ export type DerivedResultUnit =
91
+ /** The raw value carries this unit and must be converted back through it. */
92
+ {
93
+ kind: 'unit';
94
+ unit: CalculatorUnit;
95
+ }
96
+ /**
97
+ * Inference succeeded and the raw value needs NO conversion: dimensionless,
98
+ * or an angle (degrees are already canonical).
99
+ */
100
+ | {
101
+ kind: 'none';
102
+ }
103
+ /**
104
+ * Inference declined — an unmodelled function, a non-integer power, a
105
+ * dimension this vocabulary can't name. The base says nothing useful here,
106
+ * so callers fall back to whatever unit the equation already carried. This
107
+ * is the escape hatch the authoring UI's manual result-unit picker fills.
108
+ */
109
+ | {
110
+ kind: 'unknown';
111
+ };
112
+ /** The unit a base-anchored expression's raw value carries. */
113
+ export declare const deriveResultUnitForBase: (base: EquationBase, fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableColumnIds">) => DerivedResultUnit;
114
+ /** Where a migrated equation's base came from — reported by the migration. */
115
+ export type EquationBaseSource =
116
+ /** A bound conversion column's declared unit. */
117
+ 'conversion-column'
118
+ /** An existing authored `variableUnits` entry. */
119
+ | 'variable-unit'
120
+ /** A bound measurement field's display unit. */
121
+ | 'field-default'
122
+ /** Nothing length-valued to anchor to; the base is inert. */
123
+ | 'fallback'
124
+ /** A variable is in a unit family no base can express — see `blockedBy`. */
125
+ | 'unanchorable';
126
+ export interface DerivedEquationBase {
127
+ /** Null when the equation cannot be anchored without changing its meaning. */
128
+ base: EquationBase | null;
129
+ source: EquationBaseSource;
130
+ /**
131
+ * Distinct bases seen at the winning tier. More than one means the source
132
+ * disagreed with itself and `base` is the most common of them — the case a
133
+ * migration should surface rather than apply silently.
134
+ */
135
+ candidates: EquationBase[];
136
+ /**
137
+ * Units that blocked anchoring: cubic yards, liters, gallons — real units a
138
+ * field may hold, but outside the five families a base can name.
139
+ */
140
+ blockedBy?: CalculatorUnit[];
141
+ }
142
+ /** Inert default when an equation has no length-valued binding at all. */
143
+ export declare const DEFAULT_EQUATION_BASE: EquationBase;
144
+ /**
145
+ * The base a pre-`base` equation should adopt, in descending order of what the
146
+ * source can be trusted to mean:
147
+ *
148
+ * 1. A bound conversion COLUMN's unit. A cell reading "5.33" is literal text
149
+ * a human typed; its unit is the only thing that says what the number is,
150
+ * so it is the one declaration in the equation that cannot be re-expressed
151
+ * without reinterpreting authored data.
152
+ * 2. An existing `variableUnits` entry — the author's own stated intent,
153
+ * even if it disagreed with its neighbours.
154
+ * 3. A bound measurement field's display unit. Values are stored canonically,
155
+ * so this only reflects a preference, but it is the author's preference.
156
+ * 4. Nothing length-valued in the equation, so the base cannot matter.
157
+ *
158
+ * Declines entirely (`base: null`) when a variable is ALREADY authored in a
159
+ * unit no base can name — cubic yards, liters, gallons. Re-expressing those is
160
+ * numerically lossless but semantically not: a gravel estimator multiplying a
161
+ * cu_yd volume by a tons-per-cubic-yard constant is correct only while the
162
+ * volume stays in cubic yards, and that constant is a bare number with nothing
163
+ * to declare its units. Silently rebasing it to cu_in would make the answer
164
+ * 46,656× too large. Better to leave such an equation on the pre-base path,
165
+ * where the mixed-base check still watches it, than to guess.
166
+ */
167
+ export declare const deriveEquationBase: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "variableToFieldId" | "variableColumnIds" | "variableUnits">) => DerivedEquationBase;
74
168
  /**
75
169
  * Bring an equation's field-derived annotations — variable column bindings,
76
170
  * variable units, result unit — back in line with the fields it references.
@@ -1,8 +1,8 @@
1
1
  import { create, all } from 'mathjs';
2
- import { ColumnType, Units } from '../types/firestore.js';
3
- import { bindingDefaultUnit, bindingDimension, fieldDimension, } from './schema.js';
2
+ import { ColumnType } from '../types/firestore.js';
3
+ import { bindingDefaultUnit, bindingDimension, bindingUnitForBase, equationBindings, fieldDimension, } from './schema.js';
4
4
  import { findConversionColumn } from './conversionTable.js';
5
- import { CALCULATOR_UNIT_INFO, toCanonical, unitDimension, } from './units.js';
5
+ import { UNIT_BY_BASE, equationBaseOfUnit, isEquationBase, toCanonical, unitBase, unitDimension, unitForBase, } from './units.js';
6
6
  const math = create(all);
7
7
  // ---------------------------------------------------------------------------
8
8
  // Expression unit inference.
@@ -44,23 +44,41 @@ export const MATHJS_CONSTANTS = new Set([
44
44
  'NaN',
45
45
  'null',
46
46
  ]);
47
+ // Parse cache, mirroring evaluate.ts's compile cache and sized to match. Unit
48
+ // inference now runs on the evaluation path (an equation's scope units are
49
+ // derived from its base, not read off the document), so the same handful of
50
+ // expressions get parsed on every keystroke of the editor preview.
51
+ //
52
+ // The cached node is handed to callers directly. Every consumer in this
53
+ // package only reads it — `traverse` in expressionSymbols, the structural walk
54
+ // below — so nothing mutates the shared tree.
55
+ const parseCache = new Map();
56
+ const PARSE_CACHE_MAX = 200;
47
57
  /** Shared mathjs parse — the one place expression text becomes a tree. */
48
58
  export const parseExpression = (expression) => {
59
+ const hit = parseCache.get(expression);
60
+ if (hit)
61
+ return hit;
62
+ let result;
49
63
  try {
50
- return { ok: true, node: math.parse(expression) };
64
+ result = { ok: true, node: math.parse(expression) };
51
65
  }
52
66
  catch (err) {
53
- return {
67
+ result = {
54
68
  ok: false,
55
69
  error: err instanceof Error ? err.message : String(err),
56
70
  };
57
71
  }
72
+ if (parseCache.size >= PARSE_CACHE_MAX)
73
+ parseCache.clear();
74
+ parseCache.set(expression, result);
75
+ return result;
58
76
  };
59
77
  // The math.js base token a unit reduces to: 'sq_ft' -> 'ft', 'in_frac' -> 'in',
60
78
  // 'liter' -> 'L'. Two units share a base exactly when they are the same
61
79
  // physical unit at different exponents, which is what makes a result unit
62
80
  // nameable.
63
- const baseUnitOf = (unit) => (CALCULATOR_UNIT_INFO[unit]?.mathUnit ?? '').split('^')[0];
81
+ const baseUnitOf = unitBase;
64
82
  /**
65
83
  * Bases whose values are ALREADY canonical, so an equation in them needs no
66
84
  * `resultUnit` at all (the pre-unit-annotation form: a canonical scope in
@@ -69,17 +87,6 @@ const baseUnitOf = (unit) => (CALCULATOR_UNIT_INFO[unit]?.mathUnit ?? '').split(
69
87
  export const CANONICAL_BASES = new Set(['um', 'deg']);
70
88
  const CANONICAL_LENGTH_BASE = 'um';
71
89
  const CANONICAL_ANGLE_BASE = 'deg';
72
- // Base + length exponent -> the unit that names it. Yards have no length
73
- // entry (the Units enum has no yard) and liters/gallons no length base at all,
74
- // so both simply fail to name a unit rather than guessing.
75
- const UNIT_BY_BASE = {
76
- mm: { 1: Units.Millimeters, 2: 'sq_mm', 3: 'cu_mm' },
77
- cm: { 1: Units.Centimeters, 2: 'sq_cm', 3: 'cu_cm' },
78
- m: { 1: Units.Meters, 2: 'sq_m', 3: 'cu_m' },
79
- in: { 1: Units.Inches, 2: 'sq_in', 3: 'cu_in' },
80
- ft: { 1: Units.Feet, 2: 'sq_ft', 3: 'cu_ft' },
81
- yd: { 2: 'sq_yd', 3: 'cu_yd' },
82
- };
83
90
  const SCALAR = { len: 0, ang: 0 };
84
91
  const isScalar = (e) => e.len === 0 && e.ang === 0;
85
92
  const sameExponents = (a, b) => a.len === b.len && a.ang === b.ang;
@@ -301,28 +308,35 @@ export const inferExpressionUnit = (fields, equation) => {
301
308
  if (!exponents) {
302
309
  return { ok: false, reason: failure ?? 'unsupported-operation' };
303
310
  }
311
+ // Every success path reports both axes; `bases` narrows to whichever axis
312
+ // the RESULT lives on.
313
+ const axes = { lengthBases: [...lengthBases], angleBases: [...angleBases] };
304
314
  if (exponents.ang !== 0) {
305
315
  // Angles don't combine with lengths into anything nameable (a length·deg
306
316
  // has no unit here), and neither does deg².
307
317
  if (exponents.len !== 0 || exponents.ang !== 1) {
308
318
  return { ok: false, reason: 'unnameable-dimension' };
309
319
  }
310
- const bases = [...angleBases];
320
+ const bases = axes.angleBases;
311
321
  return {
312
322
  ok: true,
313
323
  dimension: 'angle',
314
324
  unit: bases.length === 1 ? bases[0] : null,
315
325
  bases,
326
+ ...axes,
316
327
  };
317
328
  }
318
329
  if (exponents.len === 0) {
319
- return { ok: true, dimension: 'none', unit: null, bases: [] };
330
+ // Dimensionless. `bases` is empty because the RESULT names no unit — but
331
+ // the per-axis lists still carry what went in, which is the only record
332
+ // that a cancelling ratio mixed inches with feet.
333
+ return { ok: true, dimension: 'none', unit: null, bases: [], ...axes };
320
334
  }
321
335
  if (exponents.len < 1 || exponents.len > 3) {
322
336
  return { ok: false, reason: 'unnameable-dimension' };
323
337
  }
324
338
  const dimension = exponents.len === 1 ? 'length' : exponents.len === 2 ? 'area' : 'volume';
325
- const bases = [...lengthBases];
339
+ const bases = axes.lengthBases;
326
340
  return {
327
341
  ok: true,
328
342
  dimension,
@@ -330,6 +344,7 @@ export const inferExpressionUnit = (fields, equation) => {
330
344
  ? (UNIT_BY_BASE[bases[0]]?.[exponents.len] ?? null)
331
345
  : null,
332
346
  bases,
347
+ ...axes,
333
348
  };
334
349
  };
335
350
  /**
@@ -365,6 +380,151 @@ export const deriveResultUnit = (fields, equation) => {
365
380
  ? current
366
381
  : inferred.unit;
367
382
  };
383
+ // ---------------------------------------------------------------------------
384
+ // Base-anchored derivation.
385
+ //
386
+ // With `CalculatorEquation.base` set, units stop being authored and become a
387
+ // function of (base, binding dimension). `deriveVariableUnits` answers "what
388
+ // does each variable arrive as", `deriveResultUnitForBase` answers "what does
389
+ // the expression hand back", and `deriveEquationBase` picks a base for an
390
+ // equation that predates the field.
391
+ // ---------------------------------------------------------------------------
392
+ /** The scope unit for every variable of a base-anchored equation. */
393
+ export const deriveVariableUnits = (base, fields, equation) => {
394
+ const fieldById = new Map(fields.map((f) => [f.id, f]));
395
+ const units = {};
396
+ for (const { variable, fieldId, columnId } of equationBindings(equation)) {
397
+ const field = fieldById.get(fieldId);
398
+ if (!field)
399
+ continue;
400
+ const unit = bindingUnitForBase(base, field, columnId);
401
+ if (unit != null)
402
+ units[variable] = unit;
403
+ }
404
+ return units;
405
+ };
406
+ /** The unit a base-anchored expression's raw value carries. */
407
+ export const deriveResultUnitForBase = (base, fields, equation) => {
408
+ const inferred = inferExpressionUnit(fields, {
409
+ ...equation,
410
+ variableUnits: deriveVariableUnits(base, fields, equation),
411
+ });
412
+ if (!inferred.ok)
413
+ return { kind: 'unknown' };
414
+ if (inferred.dimension === 'none' || inferred.dimension === 'angle') {
415
+ return { kind: 'none' };
416
+ }
417
+ const unit = unitForBase(base, inferred.dimension);
418
+ return unit ? { kind: 'unit', unit } : { kind: 'unknown' };
419
+ };
420
+ /** Inert default when an equation has no length-valued binding at all. */
421
+ export const DEFAULT_EQUATION_BASE = 'in';
422
+ const LENGTH_DIMENSIONS = new Set(['length', 'area', 'volume']);
423
+ // Most frequent base wins; ties break toward the first one seen, so the result
424
+ // is stable across runs (variable order is the document's own key order).
425
+ const mostCommon = (bases) => {
426
+ if (bases.length === 0)
427
+ return null;
428
+ const counts = new Map();
429
+ for (const b of bases)
430
+ counts.set(b, (counts.get(b) ?? 0) + 1);
431
+ let best = bases[0];
432
+ for (const b of bases) {
433
+ if ((counts.get(b) ?? 0) > (counts.get(best) ?? 0))
434
+ best = b;
435
+ }
436
+ return best;
437
+ };
438
+ /**
439
+ * The base a pre-`base` equation should adopt, in descending order of what the
440
+ * source can be trusted to mean:
441
+ *
442
+ * 1. A bound conversion COLUMN's unit. A cell reading "5.33" is literal text
443
+ * a human typed; its unit is the only thing that says what the number is,
444
+ * so it is the one declaration in the equation that cannot be re-expressed
445
+ * without reinterpreting authored data.
446
+ * 2. An existing `variableUnits` entry — the author's own stated intent,
447
+ * even if it disagreed with its neighbours.
448
+ * 3. A bound measurement field's display unit. Values are stored canonically,
449
+ * so this only reflects a preference, but it is the author's preference.
450
+ * 4. Nothing length-valued in the equation, so the base cannot matter.
451
+ *
452
+ * Declines entirely (`base: null`) when a variable is ALREADY authored in a
453
+ * unit no base can name — cubic yards, liters, gallons. Re-expressing those is
454
+ * numerically lossless but semantically not: a gravel estimator multiplying a
455
+ * cu_yd volume by a tons-per-cubic-yard constant is correct only while the
456
+ * volume stays in cubic yards, and that constant is a bare number with nothing
457
+ * to declare its units. Silently rebasing it to cu_in would make the answer
458
+ * 46,656× too large. Better to leave such an equation on the pre-base path,
459
+ * where the mixed-base check still watches it, than to guess.
460
+ */
461
+ export const deriveEquationBase = (fields, equation) => {
462
+ const fieldById = new Map(fields.map((f) => [f.id, f]));
463
+ const fromColumns = [];
464
+ const fromVariableUnits = [];
465
+ const fromFieldDefaults = [];
466
+ const blockedBy = [];
467
+ for (const { variable, fieldId, columnId } of equationBindings(equation)) {
468
+ const field = fieldById.get(fieldId);
469
+ if (!field)
470
+ continue;
471
+ // Only length-family bindings can disagree about scale; angles are their
472
+ // own axis and dimensionless bindings carry nothing.
473
+ if (!LENGTH_DIMENSIONS.has(bindingDimension(field, columnId)))
474
+ continue;
475
+ // bindingDefaultUnit resolves an absent/unknown column id to the first
476
+ // column, matching what the evaluator actually reads.
477
+ const columnUnit = field.kind === ColumnType.ConversionTable
478
+ ? bindingDefaultUnit(field, columnId)
479
+ : null;
480
+ const declared = equation.variableUnits?.[variable];
481
+ // The unit this variable is evaluating in TODAY. That is what a base has
482
+ // to be able to reproduce; a field's display default is only a preference.
483
+ const effective = declared ?? columnUnit;
484
+ if (effective != null && equationBaseOfUnit(effective) == null) {
485
+ blockedBy.push(effective);
486
+ continue;
487
+ }
488
+ if (columnUnit) {
489
+ const base = equationBaseOfUnit(columnUnit);
490
+ if (base)
491
+ fromColumns.push(base);
492
+ }
493
+ if (declared) {
494
+ const base = equationBaseOfUnit(declared);
495
+ if (base)
496
+ fromVariableUnits.push(base);
497
+ }
498
+ if (field.kind === ColumnType.Measurement) {
499
+ const base = equationBaseOfUnit(field.unit.defaultUnit);
500
+ if (base)
501
+ fromFieldDefaults.push(base);
502
+ }
503
+ }
504
+ if (blockedBy.length > 0) {
505
+ return {
506
+ base: null,
507
+ source: 'unanchorable',
508
+ candidates: [],
509
+ blockedBy: [...new Set(blockedBy)],
510
+ };
511
+ }
512
+ const tiers = [
513
+ ['conversion-column', fromColumns],
514
+ ['variable-unit', fromVariableUnits],
515
+ ['field-default', fromFieldDefaults],
516
+ ];
517
+ for (const [source, candidates] of tiers) {
518
+ const base = mostCommon(candidates);
519
+ if (base)
520
+ return { base, source, candidates: [...new Set(candidates)] };
521
+ }
522
+ return {
523
+ base: DEFAULT_EQUATION_BASE,
524
+ source: 'fallback',
525
+ candidates: [],
526
+ };
527
+ };
368
528
  // Rebuild an equation without a key, since Firestore rejects `undefined`.
369
529
  const withoutResultUnit = (equation) => {
370
530
  const { resultUnit: _dropped, ...rest } = equation;
@@ -375,6 +535,66 @@ const withoutVariableColumnIds = (equation) => {
375
535
  const { variableColumnIds: _dropped, ...rest } = equation;
376
536
  return rest;
377
537
  };
538
+ // Rebuild an equation without a key, since Firestore rejects `undefined`.
539
+ const withoutVariableUnits = (equation) => {
540
+ const { variableUnits: _dropped, ...rest } = equation;
541
+ return rest;
542
+ };
543
+ /**
544
+ * The pre-base repair, kept verbatim for equations that cannot adopt a base.
545
+ * It only realigns a unit whose DIMENSION no longer matches its binding — it
546
+ * has no opinion about bases, which is precisely why it is safe here: an
547
+ * equation holding cubic yards keeps holding cubic yards.
548
+ */
549
+ const reconcileLegacyUnits = (fields, fieldById, equation) => {
550
+ let repaired = equation;
551
+ if (repaired.variableUnits) {
552
+ const variableUnits = {};
553
+ let unitsChanged = false;
554
+ for (const [variable, unit] of Object.entries(repaired.variableUnits)) {
555
+ const field = fieldById.get(repaired.variableToFieldId[variable] ?? '');
556
+ if (!field) {
557
+ unitsChanged = true; // variable or field is gone
558
+ continue;
559
+ }
560
+ const columnId = repaired.variableColumnIds?.[variable];
561
+ if (unitDimension(unit) === bindingDimension(field, columnId)) {
562
+ variableUnits[variable] = unit;
563
+ continue;
564
+ }
565
+ const replacement = bindingDefaultUnit(field, columnId);
566
+ unitsChanged = true;
567
+ if (replacement)
568
+ variableUnits[variable] = replacement;
569
+ }
570
+ if (unitsChanged) {
571
+ repaired =
572
+ Object.keys(variableUnits).length > 0
573
+ ? { ...repaired, variableUnits }
574
+ : withoutVariableUnits(repaired);
575
+ }
576
+ }
577
+ const target = fieldById.get(repaired.targetFieldId);
578
+ const targetDimension = target ? fieldDimension(target) : 'none';
579
+ if (targetDimension === 'none') {
580
+ if (repaired.resultUnit != null)
581
+ repaired = withoutResultUnit(repaired);
582
+ return repaired;
583
+ }
584
+ const derived = deriveResultUnit(fields, repaired);
585
+ if (derived != null) {
586
+ if (repaired.resultUnit !== derived) {
587
+ repaired = { ...repaired, resultUnit: derived };
588
+ }
589
+ }
590
+ else if (repaired.resultUnit != null &&
591
+ unitDimension(repaired.resultUnit) !== targetDimension) {
592
+ // Not derivable AND not even the right dimension: drop it rather than
593
+ // keep converting a length factor into an area field.
594
+ repaired = withoutResultUnit(repaired);
595
+ }
596
+ return repaired;
597
+ };
378
598
  /**
379
599
  * Bring an equation's field-derived annotations — variable column bindings,
380
600
  * variable units, result unit — back in line with the fields it references.
@@ -411,59 +631,61 @@ export const reconcileEquationUnits = (fields, equations) => {
411
631
  : withoutVariableColumnIds(repaired);
412
632
  }
413
633
  }
414
- // 2. Variable units must match the dimension of the binding they annotate.
415
- // Read against `repaired`, not `equation`: step 1 may have moved a
416
- // variable back to the first column, whose dimension is what matters now.
417
- if (repaired.variableUnits) {
418
- const variableUnits = {};
419
- let unitsChanged = false;
420
- for (const [variable, unit] of Object.entries(repaired.variableUnits)) {
421
- const field = fieldById.get(repaired.variableToFieldId[variable] ?? '');
422
- if (!field) {
423
- unitsChanged = true; // variable or field is gone
424
- continue;
425
- }
426
- const columnId = repaired.variableColumnIds?.[variable];
427
- if (unitDimension(unit) === bindingDimension(field, columnId)) {
428
- variableUnits[variable] = unit;
429
- continue;
430
- }
431
- const replacement = bindingDefaultUnit(field, columnId);
432
- unitsChanged = true;
433
- if (replacement)
434
- variableUnits[variable] = replacement;
435
- }
436
- if (unitsChanged) {
437
- repaired =
438
- Object.keys(variableUnits).length > 0
439
- ? { ...repaired, variableUnits }
440
- : (() => {
441
- const { variableUnits: _dropped, ...rest } = repaired;
442
- return rest;
443
- })();
444
- }
634
+ // 2. Adopt a base. An equation authored before bases existed gets one
635
+ // inferred from what it already says (deriveEquationBase), so opening
636
+ // an old calculator and touching a field migrates it in place. An
637
+ // unrecognized stored base is treated as absent rather than trusted.
638
+ // Read against `repaired`: step 1 may have moved a variable back to the
639
+ // first column, whose unit is what the base should follow.
640
+ //
641
+ // An EXPLICIT base always wins — converting a cu_yd column into ft³ is
642
+ // well defined, and an author who asked for it meant it. Only the
643
+ // inference declines, and only for an equation already sitting in a
644
+ // unit family no base can name (see deriveEquationBase).
645
+ const base = isEquationBase(repaired.base)
646
+ ? repaired.base
647
+ : deriveEquationBase(fields, repaired).base;
648
+ if (base == null) {
649
+ // Unanchorable: leave it exactly as the pre-base code would have.
650
+ repaired = reconcileLegacyUnits(fields, fieldById, repaired);
651
+ if (repaired !== equation)
652
+ changed = true;
653
+ return repaired;
654
+ }
655
+ if (repaired.base !== base)
656
+ repaired = { ...repaired, base };
657
+ // 3. Variable units are fully determined by the base and each binding's
658
+ // dimension, so they are not stored at all — the evaluator derives them.
659
+ // Anything left over from the pre-base form is dead weight that a reader
660
+ // would reasonably mistake for the source of truth.
661
+ if (repaired.variableUnits != null) {
662
+ repaired = withoutVariableUnits(repaired);
445
663
  }
446
- // 3. The result unit follows from the repaired variable units.
664
+ // 4. The result unit is derived the same way, with ONE exception worth
665
+ // persisting: an expression whose units inference cannot model at all
666
+ // (an unmodelled function, a dimension this vocabulary can't name). The
667
+ // base says nothing useful there, so the author picks, and that choice
668
+ // is real authored data rather than a cache.
669
+ //
670
+ // Number targets take the raw value either way — that is what makes
671
+ // `area_in_ft² / 33` read as 33 ft², not 33 µm².
447
672
  const target = fieldById.get(equation.targetFieldId);
448
673
  const targetDimension = target ? fieldDimension(target) : 'none';
449
- if (targetDimension === 'none') {
450
- if (repaired.resultUnit != null)
451
- repaired = withoutResultUnit(repaired);
452
- }
453
- else {
454
- const derived = deriveResultUnit(fields, repaired);
455
- if (derived != null) {
456
- if (repaired.resultUnit !== derived) {
457
- repaired = { ...repaired, resultUnit: derived };
458
- }
459
- }
460
- else if (repaired.resultUnit != null &&
674
+ const derived = targetDimension === 'none'
675
+ ? { kind: 'none' }
676
+ : deriveResultUnitForBase(base, fields, repaired);
677
+ if (derived.kind === 'unknown') {
678
+ // Keep the author's pick, unless it isn't even the target's dimension —
679
+ // that would convert a length factor into an area field.
680
+ if (repaired.resultUnit != null &&
461
681
  unitDimension(repaired.resultUnit) !== targetDimension) {
462
- // Not derivable AND not even the right dimension: drop it rather than
463
- // keep converting a length factor into an area field.
464
682
  repaired = withoutResultUnit(repaired);
465
683
  }
466
684
  }
685
+ else if (repaired.resultUnit != null) {
686
+ // Derivable, so a stored unit is either redundant or wrong. Drop it.
687
+ repaired = withoutResultUnit(repaired);
688
+ }
467
689
  if (repaired !== equation)
468
690
  changed = true;
469
691
  return repaired;
@@ -1,5 +1,5 @@
1
1
  import { ColumnType, type ColumnConfig, type ConversionTableColumnData, type InstructionsColumnData, type SelectColumnData, type DecimalTolerance, type FractionalTolerance } from '../types/firestore.js';
2
- import type { AngleUnit, CalculatorUnit, FieldDimension, MeasurementDimension } from './units.js';
2
+ import type { AngleUnit, CalculatorUnit, EquationBase, FieldDimension, MeasurementDimension } from './units.js';
3
3
  import type { CalculatorCategoryId } from './categories.js';
4
4
  /**
5
5
  * Version of the definition format itself. Bumped only on breaking schema
@@ -122,15 +122,45 @@ export interface CalculatorEquation {
122
122
  */
123
123
  variableColumnIds?: Record<string, string>;
124
124
  /**
125
- * Variable letter -> display unit its value enters the scope in. A mapped
126
- * variable without an entry stays canonical (µm-scale) the pre-1.9
127
- * behavior. Unit dimension must match the mapped field's dimension.
125
+ * The ONE length base this expression evaluates in ('ft', 'in', 'mm', …) —
126
+ * and, when present, the only unit information the equation stores. Each
127
+ * variable's scope unit is this base at the variable's own exponent (ft,
128
+ * ft², ft³); the result unit is this base at the result's exponent. Both are
129
+ * computed on demand, never written down.
130
+ *
131
+ * This exists because per-variable units could disagree. Authoring
132
+ * `(A_in * B_in) / C_ft²` passed every dimensional check — length·length ÷
133
+ * area cancels to a valid dimensionless count — while computing 144× the
134
+ * truth, because nothing reconciled inches against feet. A single base makes
135
+ * that unrepresentable rather than merely detectable.
136
+ *
137
+ * The base is per EQUATION, not per calculator: values are stored
138
+ * canonically, so two equations may work in different bases and still feed
139
+ * each other — each converts in and out at its own edge.
140
+ *
141
+ * Absent means the pre-base form below. Still read, because saved instances
142
+ * freeze their definition and older docs migrate lazily, but never written
143
+ * for new equations.
144
+ */
145
+ base?: EquationBase;
146
+ /**
147
+ * LEGACY (pre-`base`). Variable letter -> display unit its value enters the
148
+ * scope in; a mapped variable with no entry stays canonical (µm-scale).
149
+ *
150
+ * Ignored entirely when `base` is set, and stripped by
151
+ * reconcileEquationUnits, since the base determines every one of these — a
152
+ * stored copy could only go stale and be mistaken for the source of truth.
128
153
  */
129
154
  variableUnits?: Record<string, CalculatorUnit>;
130
155
  /**
131
156
  * Display unit the expression's RESULT is expressed in; converted back to
132
157
  * canonical before storage/chaining. Only meaningful for measurement/angle
133
158
  * targets — number targets take the result raw. Absent = canonical.
159
+ *
160
+ * With `base` set this survives in ONE case: an expression whose units
161
+ * inference cannot model (an unmodelled function, an unnameable dimension).
162
+ * The base yields no answer there, so the author picks one and it is real
163
+ * authored data. Everywhere else it is derived and not stored.
134
164
  */
135
165
  resultUnit?: CalculatorUnit;
136
166
  }
@@ -212,13 +242,24 @@ export declare const bindingDimension: (field: CalculatorField, columnId?: strin
212
242
  */
213
243
  export declare const fieldDefaultUnit: (field: CalculatorField) => CalculatorUnit | null;
214
244
  /**
215
- * The unit a BOUND value is expressed in, or null when it carries none. For a
216
- * conversion column this is the column's declared unit and is NOT a per-equation
217
- * choice: the cell text was typed in that unit, so re-expressing the variable in
218
- * another one would only invite the scale errors expressionUnits.ts exists to
219
- * catch. The authoring UI shows it read-only for that reason.
245
+ * The unit a BOUND value is DECLARED in the conversion column's unit for a
246
+ * table binding (the unit the cell text was typed in), the field's display
247
+ * unit otherwise. This is what the value means at rest, not what it means
248
+ * inside an equation; `bindingUnitForBase` converts it to the latter.
220
249
  */
221
250
  export declare const bindingDefaultUnit: (field: CalculatorField, columnId?: string) => CalculatorUnit | null;
251
+ /**
252
+ * The unit a bound value enters a base-anchored equation's scope in — the
253
+ * base's member at the binding's own exponent, so a length arrives in `ft` and
254
+ * an area off the same equation in `ft²`.
255
+ *
256
+ * Never an author choice, and deliberately independent of how the value is
257
+ * declared or displayed: a column typed in ft² feeding an inch-based equation
258
+ * converts to in², which is a lossless re-expression of the same quantity, not
259
+ * a reinterpretation of the author's cell text. Returns null for dimensionless
260
+ * bindings, which enter the scope raw.
261
+ */
262
+ export declare const bindingUnitForBase: (base: EquationBase, field: CalculatorField, columnId?: string) => CalculatorUnit | null;
222
263
  /** Fields whose value participates in equations as a number. */
223
264
  export declare const isNumericFieldKind: (kind: CalculatorFieldKind) => kind is ColumnType.Number | ColumnType.Measurement | ColumnType.Angle | ColumnType.ConversionTable;
224
265
  /** Field kinds an equation may target (compute into). */
@@ -1,5 +1,5 @@
1
1
  import { ColumnType, Units, } from '../types/firestore.js';
2
- import { unitsForDimension } from './units.js';
2
+ import { unitForBase, unitsForDimension } from './units.js';
3
3
  import { conversionColumnDimension, resolveConversionColumn, } from './conversionTable.js';
4
4
  // ---------------------------------------------------------------------------
5
5
  // The Construction Calculator definition — the shared artifact the web
@@ -101,15 +101,39 @@ export const fieldDefaultUnit = (field) => {
101
101
  }
102
102
  };
103
103
  /**
104
- * The unit a BOUND value is expressed in, or null when it carries none. For a
105
- * conversion column this is the column's declared unit and is NOT a per-equation
106
- * choice: the cell text was typed in that unit, so re-expressing the variable in
107
- * another one would only invite the scale errors expressionUnits.ts exists to
108
- * catch. The authoring UI shows it read-only for that reason.
104
+ * The unit a BOUND value is DECLARED in the conversion column's unit for a
105
+ * table binding (the unit the cell text was typed in), the field's display
106
+ * unit otherwise. This is what the value means at rest, not what it means
107
+ * inside an equation; `bindingUnitForBase` converts it to the latter.
109
108
  */
110
109
  export const bindingDefaultUnit = (field, columnId) => field.kind === ColumnType.ConversionTable
111
110
  ? (resolveConversionColumn(field.columnData, columnId).unit ?? null)
112
111
  : fieldDefaultUnit(field);
112
+ /**
113
+ * The unit a bound value enters a base-anchored equation's scope in — the
114
+ * base's member at the binding's own exponent, so a length arrives in `ft` and
115
+ * an area off the same equation in `ft²`.
116
+ *
117
+ * Never an author choice, and deliberately independent of how the value is
118
+ * declared or displayed: a column typed in ft² feeding an inch-based equation
119
+ * converts to in², which is a lossless re-expression of the same quantity, not
120
+ * a reinterpretation of the author's cell text. Returns null for dimensionless
121
+ * bindings, which enter the scope raw.
122
+ */
123
+ export const bindingUnitForBase = (base, field, columnId) => {
124
+ const dimension = bindingDimension(field, columnId);
125
+ switch (dimension) {
126
+ case 'none':
127
+ return null;
128
+ // Trig in equations runs in DEGREES (see evaluate.ts), so angles enter the
129
+ // scope as degrees whatever the base — and degrees are already canonical,
130
+ // making this conversion a no-op that exists for uniformity.
131
+ case 'angle':
132
+ return 'deg';
133
+ default:
134
+ return unitForBase(base, dimension);
135
+ }
136
+ };
113
137
  /** Fields whose value participates in equations as a number. */
114
138
  export const isNumericFieldKind = (kind) => kind === ColumnType.Number ||
115
139
  kind === ColumnType.Measurement ||
@@ -28,6 +28,33 @@ export declare const CALCULATOR_UNIT_INFO: Record<CalculatorUnit, CalculatorUnit
28
28
  /** The math.js token for a dimension's canonical unit. '' for dimensionless. */
29
29
  export declare const canonicalMathUnit: (dimension: FieldDimension) => string;
30
30
  export declare const unitDimension: (unit: CalculatorUnit) => FieldDimension;
31
+ /** The math.js base token a unit reduces to: 'sq_ft' -> 'ft', 'in_frac' -> 'in'. */
32
+ export declare const unitBase: (unit: CalculatorUnit) => string;
33
+ /**
34
+ * Base + length exponent -> the unit that names it. Yards have no length entry
35
+ * (the `Units` enum has no yard) and liters/gallons no length base at all, so
36
+ * both simply fail to name a unit rather than guessing.
37
+ */
38
+ export declare const UNIT_BY_BASE: Record<string, Partial<Record<number, CalculatorUnit>>>;
39
+ /**
40
+ * A base an equation may evaluate in. Only bases that name a unit at EVERY
41
+ * length exponent qualify — an equation in yards could not express a plain
42
+ * yard length, and liters/gallons aren't a length family at all. Both remain
43
+ * perfectly usable as field/column units; they just get converted into the
44
+ * equation's base like anything else.
45
+ */
46
+ export type EquationBase = 'mm' | 'cm' | 'm' | 'in' | 'ft';
47
+ export declare const EQUATION_BASES: EquationBase[];
48
+ export declare const isEquationBase: (value: unknown) => value is EquationBase;
49
+ /** Length factors per dimension — an area is two lengths, a volume three. */
50
+ export declare const LENGTH_EXPONENT: Record<MeasurementDimension, 1 | 2 | 3>;
51
+ /** The unit naming `base` at a measurement dimension, e.g. ('ft','area') -> 'sq_ft'. */
52
+ export declare const unitForBase: (base: EquationBase, dimension: MeasurementDimension) => CalculatorUnit | null;
53
+ /**
54
+ * The equation base a unit belongs to, or null for units outside the five
55
+ * anchorable families (yd², liters, gallons, degrees, radians).
56
+ */
57
+ export declare const equationBaseOfUnit: (unit: CalculatorUnit) => EquationBase | null;
31
58
  export declare const unitsForDimension: (dimension: FieldDimension) => CalculatorUnit[];
32
59
  /**
33
60
  * A unit's token and its disambiguating qualifier as separate pieces. The
@@ -68,6 +68,53 @@ export const canonicalMathUnit = (dimension) => {
68
68
  }
69
69
  };
70
70
  export const unitDimension = (unit) => CALCULATOR_UNIT_INFO[unit]?.dimension ?? 'none';
71
+ // ---------------------------------------------------------------------------
72
+ // Equation bases
73
+ //
74
+ // An equation evaluates in ONE length base (see CalculatorEquation.base). The
75
+ // base names a family — `ft` covers ft, ft², ft³ — and each variable's scope
76
+ // unit is that family's member at the variable's own exponent. This is what
77
+ // makes `(A * B) / C` meaningful: A and B arrive in ft, C in ft², and the
78
+ // ratio is a true dimensionless count rather than a number that happens to
79
+ // divide.
80
+ //
81
+ // Angles are a SEPARATE axis and never participate: the evaluator's trig runs
82
+ // in degrees, so angle values always enter the scope as degrees regardless of
83
+ // the base.
84
+ // ---------------------------------------------------------------------------
85
+ /** The math.js base token a unit reduces to: 'sq_ft' -> 'ft', 'in_frac' -> 'in'. */
86
+ export const unitBase = (unit) => (CALCULATOR_UNIT_INFO[unit]?.mathUnit ?? '').split('^')[0];
87
+ /**
88
+ * Base + length exponent -> the unit that names it. Yards have no length entry
89
+ * (the `Units` enum has no yard) and liters/gallons no length base at all, so
90
+ * both simply fail to name a unit rather than guessing.
91
+ */
92
+ export const UNIT_BY_BASE = {
93
+ mm: { 1: Units.Millimeters, 2: 'sq_mm', 3: 'cu_mm' },
94
+ cm: { 1: Units.Centimeters, 2: 'sq_cm', 3: 'cu_cm' },
95
+ m: { 1: Units.Meters, 2: 'sq_m', 3: 'cu_m' },
96
+ in: { 1: Units.Inches, 2: 'sq_in', 3: 'cu_in' },
97
+ ft: { 1: Units.Feet, 2: 'sq_ft', 3: 'cu_ft' },
98
+ yd: { 2: 'sq_yd', 3: 'cu_yd' },
99
+ };
100
+ export const EQUATION_BASES = ['mm', 'cm', 'm', 'in', 'ft'];
101
+ export const isEquationBase = (value) => typeof value === 'string' && EQUATION_BASES.includes(value);
102
+ /** Length factors per dimension — an area is two lengths, a volume three. */
103
+ export const LENGTH_EXPONENT = {
104
+ length: 1,
105
+ area: 2,
106
+ volume: 3,
107
+ };
108
+ /** The unit naming `base` at a measurement dimension, e.g. ('ft','area') -> 'sq_ft'. */
109
+ export const unitForBase = (base, dimension) => UNIT_BY_BASE[base]?.[LENGTH_EXPONENT[dimension]] ?? null;
110
+ /**
111
+ * The equation base a unit belongs to, or null for units outside the five
112
+ * anchorable families (yd², liters, gallons, degrees, radians).
113
+ */
114
+ export const equationBaseOfUnit = (unit) => {
115
+ const base = unitBase(unit);
116
+ return isEquationBase(base) ? base : null;
117
+ };
71
118
  export const unitsForDimension = (dimension) => Object.keys(CALCULATOR_UNIT_INFO).filter((u) => CALCULATOR_UNIT_INFO[u].dimension === dimension);
72
119
  /**
73
120
  * A unit's token and its disambiguating qualifier as separate pieces. The
@@ -164,6 +164,13 @@ export declare const calculatorDefinitionSchema: z.ZodObject<{
164
164
  expression: z.ZodString;
165
165
  variableToFieldId: z.ZodRecord<z.ZodString, z.ZodString>;
166
166
  variableColumnIds: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
167
+ base: z.ZodOptional<z.ZodEnum<{
168
+ in: "in";
169
+ ft: "ft";
170
+ m: "m";
171
+ mm: "mm";
172
+ cm: "cm";
173
+ }>>;
167
174
  variableUnits: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
168
175
  resultUnit: z.ZodOptional<z.ZodString>;
169
176
  }, z.core.$strip>>;
@@ -2,8 +2,8 @@ import { z } from 'zod';
2
2
  import { ColumnType, DecimalTolerance, FractionalTolerance, } from '../types/firestore.js';
3
3
  import { CALCULATOR_SCHEMA_VERSION, bindingDimension, equationForField, fieldDimension, isEquationTargetKind, isNumericFieldKind, } from './schema.js';
4
4
  import { conversionBindingLabel, conversionCellValue, conversionColumnLabel, conversionColumns, findConversionColumn, hasMultipleConversionColumns, } from './conversionTable.js';
5
- import { CALCULATOR_UNIT_INFO, unitDimension, } from './units.js';
6
- import { MATHJS_CONSTANTS, inferExpressionUnit, isEquivalentUnit, parseExpression, producesCanonicalValue, unitScaleRatio, } from './expressionUnits.js';
5
+ import { CALCULATOR_UNIT_INFO, EQUATION_BASES, isEquationBase, unitDimension, } from './units.js';
6
+ import { MATHJS_CONSTANTS, deriveVariableUnits, inferExpressionUnit, isEquivalentUnit, parseExpression, producesCanonicalValue, unitScaleRatio, } from './expressionUnits.js';
7
7
  import { isCalculatorCategoryId } from './categories.js';
8
8
  // ---------------------------------------------------------------------------
9
9
  // Definition validation: zod for structure, plus the cross-field invariants
@@ -106,6 +106,7 @@ const calculatorEquationSchema = z.object({
106
106
  expression: z.string().min(1),
107
107
  variableToFieldId: z.record(z.string(), z.string()),
108
108
  variableColumnIds: z.record(z.string(), z.string()).optional(),
109
+ base: z.enum(EQUATION_BASES).optional(),
109
110
  variableUnits: z.record(z.string(), calculatorUnitSchema).optional(),
110
111
  resultUnit: calculatorUnitSchema.optional(),
111
112
  });
@@ -402,7 +403,14 @@ export const validateCalculatorDefinition = (input) => {
402
403
  // The unit the EXPRESSION produces, which is what `resultUnit` claims to
403
404
  // be. Hoisted out of the `target` branch because the number-target check
404
405
  // further down needs it too.
405
- const inferred = inferExpressionUnit(def.fields, eq);
406
+ //
407
+ // Inferred from what the equation will ACTUALLY evaluate with: for a
408
+ // base-anchored equation that is the derived units, not whatever the
409
+ // document stores. Otherwise a stale cache would make every check below
410
+ // reason about units the evaluator is going to ignore.
411
+ const inferred = inferExpressionUnit(def.fields, isEquationBase(eq.base)
412
+ ? { ...eq, variableUnits: deriveVariableUnits(eq.base, def.fields, eq) }
413
+ : eq);
406
414
  if (target) {
407
415
  const dim = fieldDimension(target);
408
416
  if (eq.resultUnit != null) {
@@ -427,6 +435,11 @@ export const validateCalculatorDefinition = (input) => {
427
435
  if (inferred.dimension !== dim) {
428
436
  push('expression-dimension-mismatch', `${path}.expression`, `Expression produces ${describeDimension(inferred.dimension)} but "${target.name}" is ${describeDimension(dim)} — check the variable units and the expression`);
429
437
  }
438
+ else if (isEquationBase(eq.base)) {
439
+ // Derived from the base at evaluation time, so it can be neither
440
+ // missing nor mis-scaled — the two checks below exist only for the
441
+ // pre-base form, where the result unit was an unverified assertion.
442
+ }
430
443
  else if (eq.resultUnit == null) {
431
444
  if (!producesCanonicalValue(inferred)) {
432
445
  push('missing-result-unit', `${path}.resultUnit`, `Expression produces a value in ${inferred.unit ? `"${inferred.unit}"` : inferred.bases.join('/')} but no result unit is set, so it will be stored as a canonical value. Set the result unit${inferred.unit ? ` to "${inferred.unit}"` : ''}.`);
@@ -444,8 +457,24 @@ export const validateCalculatorDefinition = (input) => {
444
457
  push('result-unit-scale-mismatch', `${path}.resultUnit`, `Expression produces "${inferred.unit}" but the result unit is "${eq.resultUnit}" — computed values will be ${off}. Use "${inferred.unit}", or change the variable units to match.`);
445
458
  }
446
459
  }
447
- if (inferred.ok && inferred.bases.length > 1) {
448
- push('mixed-base-units', `${path}.variableUnits`, `Variables mix ${inferred.bases.join(' and ')} in one expression, so the result has no single unit. Put them all in the same unit.`, 'warning');
460
+ // Base-anchored equations cannot mix bases every unit comes from the
461
+ // one base so this only ever fires on the pre-base authored form.
462
+ //
463
+ // Checked PER AXIS. A length and an angle in one expression is ordinary
464
+ // (trig consumes the angle and hands back a ratio); two length bases
465
+ // never is. And read off `lengthBases`/`angleBases` rather than `bases`,
466
+ // because `bases` describes the result: `(A_in * B_in) / C_ft²` cancels
467
+ // to a dimensionless count, leaving `bases` empty and the 144× scale
468
+ // error it carries completely invisible here.
469
+ if (inferred.ok && !isEquationBase(eq.base)) {
470
+ for (const [axis, axisBases] of [
471
+ ['length', inferred.lengthBases],
472
+ ['angle', inferred.angleBases],
473
+ ]) {
474
+ if (axisBases.length < 2)
475
+ continue;
476
+ push('mixed-base-units', `${path}.variableUnits`, `Variables mix ${axisBases.join(' and ')} in one expression, so ${axis === 'length' ? 'lengths' : 'angles'} of different scales are being combined as if they were the same — the result is silently off by a fixed factor. Put them all in one unit, or set the equation's base.`);
477
+ }
449
478
  }
450
479
  if (!inferred.ok && inferred.reason === 'inconsistent-terms') {
451
480
  push('expression-mixed-dimensions', `${path}.expression`, 'Expression adds or compares terms of different dimensions (a length and an area, say) — one of them is probably the wrong field', 'warning');
@@ -468,7 +497,13 @@ export const validateCalculatorDefinition = (input) => {
468
497
  // authoring where a µm-scale value in a unitless field is intended, and
469
498
  // nothing gates on `ok`: mobile only console.warns in __DEV__, and the
470
499
  // web editor shows the banner without blocking save.
500
+ //
501
+ // Cannot happen once an equation has a base: every dimensioned binding
502
+ // gets a unit derived from it, so there is no way for a canonical value to
503
+ // reach the scope. Skipped rather than left to read the absent
504
+ // `variableUnits` and report every variable as unitless.
471
505
  if (target?.kind === ColumnType.Number &&
506
+ !isEquationBase(eq.base) &&
472
507
  (!inferred.ok || inferred.dimension !== 'none')) {
473
508
  for (const [variable, fieldId] of Object.entries(eq.variableToFieldId)) {
474
509
  const mapped = fieldById.get(fieldId);
@@ -24,6 +24,27 @@ export const roundToT1Values = (value, u) => {
24
24
  return Math.round(value / 500) * 500;
25
25
  }
26
26
  };
27
+ /**
28
+ * Lowest terms. A fraction is read off a tape, so the denominator has to be
29
+ * the smallest one that names the value: 2/16 is written 1/8, never "6 2/16".
30
+ * The tolerance sets the PRECISION the value is rounded to, not the
31
+ * denominator it prints with.
32
+ */
33
+ const reduceFraction = (numerator, denominator) => {
34
+ let a = numerator;
35
+ let b = denominator;
36
+ while (b !== 0) {
37
+ [a, b] = [b, a % b];
38
+ }
39
+ const divisor = a || 1;
40
+ return [numerator / divisor, denominator / divisor];
41
+ };
42
+ /**
43
+ * Re-apply the sign to a composite string built from a magnitude. Guards the
44
+ * `-0` case: a value that rounds to nothing at the current tolerance reads as
45
+ * plain zero, not as a negative.
46
+ */
47
+ const signPrefix = (signed, body) => signed < 0 && /[1-9]/.test(body) ? `-${body}` : body;
27
48
  export const convertMicrometers = (micrometers, unit, fractionalTolerance, decimalTolerance) => {
28
49
  if (micrometers == null || isNaN(micrometers)) {
29
50
  return { value: 'NaN', unit: '' };
@@ -39,18 +60,22 @@ export const convertMicrometers = (micrometers, unit, fractionalTolerance, decim
39
60
  displayUnit = 'in';
40
61
  if (unit === Units.FractionalInches) {
41
62
  const denominator = parseInt(fractionalTolerance, 10);
42
- const whole = Math.floor(inches);
43
- const fractional = inches - whole;
44
- const numerator = Math.round(fractional * denominator);
63
+ // Split the MAGNITUDE, then re-apply the sign: Math.floor walks away
64
+ // from zero on a negative, so -6 1/8 came out as `-7 7/8`.
65
+ const magnitude = Math.abs(inches);
66
+ const whole = Math.floor(magnitude);
67
+ const numerator = Math.round((magnitude - whole) * denominator);
45
68
  if (numerator === denominator) {
46
69
  value = `${whole + 1}`;
47
70
  }
71
+ else if (numerator === 0) {
72
+ value = `${whole}`;
73
+ }
48
74
  else {
49
- value =
50
- numerator === 0
51
- ? `${whole}`
52
- : `${whole} ${numerator}/${denominator}`;
75
+ const [num, den] = reduceFraction(numerator, denominator);
76
+ value = `${whole} ${num}/${den}`;
53
77
  }
78
+ value = signPrefix(inches, value);
54
79
  }
55
80
  else {
56
81
  value = inches.toFixed(decimalTolerance.length - 2);
@@ -61,28 +86,45 @@ export const convertMicrometers = (micrometers, unit, fractionalTolerance, decim
61
86
  case Units.FeetInchesFractional:
62
87
  case Units.FeetInchesDecimal: {
63
88
  const feet = converted.toNumber('ft');
64
- const wholeFeet = Math.floor(feet);
65
- const fractionalFeet = feet - wholeFeet;
89
+ // Both COMPOSITE feet-inches units split the magnitude and re-apply the
90
+ // sign (see the fractional-inch branch) — otherwise -5' 6" printed as
91
+ // `-6' 6"`. Plain decimal feet keeps the signed value: toFixed renders
92
+ // that correctly on its own.
93
+ const composite = unit === Units.FeetInchesFractional ||
94
+ unit === Units.FeetInchesDecimal;
95
+ const magnitudeFeet = composite ? Math.abs(feet) : feet;
96
+ const wholeFeet = Math.floor(magnitudeFeet);
97
+ const fractionalFeet = magnitudeFeet - wholeFeet;
66
98
  displayUnit = 'ft';
67
99
  if (unit === Units.FeetInchesFractional) {
68
100
  const inches = fractionalFeet * 12;
69
- const wholeInches = Math.floor(inches);
70
- const fractional = inches - wholeInches;
71
101
  const denominator = parseInt(fractionalTolerance, 10);
72
- const numerator = Math.round(fractional * denominator);
102
+ let wholeInches = Math.floor(inches);
103
+ let numerator = Math.round((inches - wholeInches) * denominator);
104
+ // Carry, twice: a fraction that rounds up to a whole inch becomes an
105
+ // inch, and a twelfth inch becomes a foot. Without the second carry
106
+ // 5.9999 ft printed as `5' 12"`.
73
107
  if (numerator === denominator) {
74
- value = `${wholeFeet}' ${wholeInches + 1}"`;
108
+ numerator = 0;
109
+ wholeInches += 1;
110
+ }
111
+ let displayFeet = wholeFeet;
112
+ if (wholeInches === 12) {
113
+ wholeInches = 0;
114
+ displayFeet += 1;
115
+ }
116
+ if (numerator === 0) {
117
+ value = `${displayFeet}' ${wholeInches}"`;
75
118
  }
76
119
  else {
77
- value =
78
- numerator === 0
79
- ? `${wholeFeet}' ${wholeInches}"`
80
- : `${wholeFeet}' ${wholeInches} ${numerator}/${denominator}"`;
120
+ const [num, den] = reduceFraction(numerator, denominator);
121
+ value = `${displayFeet}' ${wholeInches} ${num}/${den}"`;
81
122
  }
123
+ value = signPrefix(feet, value);
82
124
  }
83
125
  else if (unit === Units.FeetInchesDecimal) {
84
126
  const inches = (fractionalFeet * 12).toFixed(decimalTolerance.length - 2);
85
- value = `${wholeFeet}' ${inches}"`;
127
+ value = signPrefix(feet, `${wholeFeet}' ${inches}"`);
86
128
  }
87
129
  else {
88
130
  value = feet.toFixed(decimalTolerance.length - 2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.10.0",
3
+ "version": "1.10.2",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",