@reekon-tools/boldr-utils 1.8.4 → 1.9.0

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.
Files changed (81) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +41 -10
  2. package/dist/annotation/canvas/measurementGeometry.js +8 -8
  3. package/dist/annotation/canvas/stampLayout.js +59 -54
  4. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +4 -1
  5. package/dist/annotation/canvas/useAnnotationCanvasState.js +88 -38
  6. package/dist/calculator/evaluate.js +38 -4
  7. package/dist/calculator/expressionUnits.d.ts +84 -0
  8. package/dist/calculator/expressionUnits.js +439 -0
  9. package/dist/calculator/index.d.ts +1 -0
  10. package/dist/calculator/index.js +1 -0
  11. package/dist/calculator/schema.d.ts +18 -0
  12. package/dist/calculator/schema.js +26 -5
  13. package/dist/calculator/validate.d.ts +12 -2
  14. package/dist/calculator/validate.js +116 -43
  15. package/dist/theme/index.d.ts +3 -0
  16. package/dist/theme/index.js +3 -0
  17. package/dist/theme/preset.d.ts +120 -0
  18. package/dist/theme/preset.js +25 -0
  19. package/dist/theme/semantic.d.ts +136 -0
  20. package/dist/theme/semantic.js +116 -0
  21. package/package.json +1 -1
  22. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  23. package/dist/canvas/AnnotationCanvas.js +0 -10
  24. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  25. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  26. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  27. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  28. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  29. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  30. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  31. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  32. package/dist/canvas/Tool.d.ts +0 -38
  33. package/dist/canvas/Tool.js +0 -1
  34. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  35. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  36. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  37. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  38. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  39. package/dist/canvas/elements/ShapeElement.js +0 -62
  40. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  41. package/dist/canvas/elements/StrokeElement.js +0 -18
  42. package/dist/canvas/measurementPicker.d.ts +0 -10
  43. package/dist/canvas/measurementPicker.js +0 -1
  44. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  45. package/dist/canvas/measurementStampOverlay.js +0 -1
  46. package/dist/canvas/pointerAdapter.d.ts +0 -3
  47. package/dist/canvas/pointerAdapter.js +0 -19
  48. package/dist/canvas/stampLayout.d.ts +0 -5
  49. package/dist/canvas/stampLayout.js +0 -14
  50. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  51. package/dist/canvas/tools/measurementStampTool.js +0 -37
  52. package/dist/canvas/tools/panTool.d.ts +0 -5
  53. package/dist/canvas/tools/panTool.js +0 -25
  54. package/dist/canvas/tools/penTool.d.ts +0 -13
  55. package/dist/canvas/tools/penTool.js +0 -68
  56. package/dist/canvas/tools/selectTool.d.ts +0 -2
  57. package/dist/canvas/tools/selectTool.js +0 -182
  58. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  59. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  60. package/dist/canvas/viewport.d.ts +0 -16
  61. package/dist/canvas/viewport.js +0 -54
  62. package/dist/data/AnnotationDataContext.d.ts +0 -8
  63. package/dist/data/AnnotationDataContext.js +0 -11
  64. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  65. package/dist/data/AnnotationDataProvider.js +0 -4
  66. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  67. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  68. package/dist/data/canvasPersistence.d.ts +0 -3
  69. package/dist/data/canvasPersistence.js +0 -26
  70. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  71. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  72. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  73. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  74. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  75. package/dist/data/hooks/useAnnotationList.js +0 -26
  76. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  77. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  78. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  79. package/dist/hooks/useParseMeasurement.js +0 -14
  80. package/dist/utils/evaluateFormula.d.ts +0 -20
  81. package/dist/utils/evaluateFormula.js +0 -31
@@ -0,0 +1,84 @@
1
+ import { type MathNode } from 'mathjs';
2
+ import type { CalculatorEquation, CalculatorField } from './schema.js';
3
+ import { type CalculatorUnit, type FieldDimension } from './units.js';
4
+ /** Constants mathjs resolves without a scope entry. */
5
+ export declare const MATHJS_CONSTANTS: Set<string>;
6
+ export type ParseResult = {
7
+ ok: true;
8
+ node: MathNode;
9
+ } | {
10
+ ok: false;
11
+ error: string;
12
+ };
13
+ /** Shared mathjs parse — the one place expression text becomes a tree. */
14
+ export declare const parseExpression: (expression: string) => ParseResult;
15
+ /**
16
+ * Bases whose values are ALREADY canonical, so an equation in them needs no
17
+ * `resultUnit` at all (the pre-unit-annotation form: a canonical scope in
18
+ * µm / µm² / µm³ / degrees).
19
+ */
20
+ export declare const CANONICAL_BASES: Set<string>;
21
+ export type InferenceFailureReason =
22
+ /** The expression does not parse. */
23
+ 'unparseable'
24
+ /** A symbol is neither a mapped variable nor a mathjs constant. */
25
+ | 'unknown-symbol'
26
+ /** A function or operator whose unit behaviour we don't model. */
27
+ | 'unsupported-operation'
28
+ /** Dimensioned terms added or compared against a different dimension. */
29
+ | 'inconsistent-terms'
30
+ /** A real exponent, but one this unit vocabulary can't name (µm⁴, 1/ft). */
31
+ | 'unnameable-dimension';
32
+ export interface InferredExpressionUnit {
33
+ /** The dimension of the expression's raw value. */
34
+ dimension: FieldDimension;
35
+ /**
36
+ * The unit the raw value carries, or null when no single unit names it
37
+ * (mixed bases, or a canonical/yard/liter base with no entry).
38
+ */
39
+ unit: CalculatorUnit | null;
40
+ /** Distinct base units among the variables carrying that dimension. */
41
+ bases: string[];
42
+ }
43
+ export type ExpressionUnitInference = ({
44
+ ok: true;
45
+ } & InferredExpressionUnit) | {
46
+ ok: false;
47
+ reason: InferenceFailureReason;
48
+ };
49
+ /**
50
+ * The dimension and unit of an equation's raw expression value — what the
51
+ * number means BEFORE `resultUnit` is applied.
52
+ */
53
+ export declare const inferExpressionUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableUnits">) => ExpressionUnitInference;
54
+ /**
55
+ * True when an expression's raw value is already canonical, so omitting
56
+ * `resultUnit` is correct rather than a 25400× mistake. Only the
57
+ * no-variable-units form (and dimensionless results) qualifies.
58
+ */
59
+ export declare const producesCanonicalValue: (inferred: InferredExpressionUnit) => boolean;
60
+ /** Same physical unit, ignoring display form: `in` ≡ `in_frac`, `ft` ≡ `ft_in_frac`. */
61
+ export declare const isEquivalentUnit: (a: CalculatorUnit, b: CalculatorUnit) => boolean;
62
+ /**
63
+ * How far off a value is when it carries `actual` but was declared as
64
+ * `declared` — 144 for in² labelled ft². Used to make validation messages
65
+ * say what the author will actually see.
66
+ */
67
+ export declare const unitScaleRatio: (declared: CalculatorUnit, actual: CalculatorUnit) => number;
68
+ /**
69
+ * The `resultUnit` an equation should carry, or null when it should be omitted
70
+ * (dimensionless or already-canonical result) or cannot be derived. An
71
+ * existing equivalent unit is preserved so `in_frac` isn't churned to `in`.
72
+ */
73
+ export declare const deriveResultUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableUnits" | "resultUnit">) => CalculatorUnit | null;
74
+ /**
75
+ * Bring an equation's unit annotations back in line with the fields it
76
+ * references. Field edits (a dimension switch, a kind change, a deletion) are
77
+ * written independently of equations, so a stale `variableUnits` entry or
78
+ * `resultUnit` would otherwise keep converting against a unit the field no
79
+ * longer uses — the failure mode this whole module exists to prevent.
80
+ */
81
+ export declare const reconcileEquationUnits: (fields: readonly CalculatorField[], equations: readonly CalculatorEquation[]) => {
82
+ equations: CalculatorEquation[];
83
+ changed: boolean;
84
+ };
@@ -0,0 +1,439 @@
1
+ import { create, all } from 'mathjs';
2
+ import { Units } from '../types/firestore.js';
3
+ import { fieldDefaultUnit, fieldDimension } from './schema.js';
4
+ import { CALCULATOR_UNIT_INFO, toCanonical, unitDimension, } from './units.js';
5
+ const math = create(all);
6
+ // ---------------------------------------------------------------------------
7
+ // Expression unit inference.
8
+ //
9
+ // An equation's `variableUnits` / `resultUnit` (schema.ts) evaluate it in the
10
+ // author's display units: each variable is converted OUT of canonical before
11
+ // entering the scope, and the raw result is converted back FROM `resultUnit`.
12
+ // Nothing in the evaluator checks that `resultUnit` is the unit the expression
13
+ // actually produces — it is an assertion, not a conversion. Assert wrongly and
14
+ // the answer is silently off by a scale factor: three inch-valued variables
15
+ // multiplied yield in³, so declaring the result `cu_ft` overstates it 1728×,
16
+ // with every unit label on screen still reading correctly.
17
+ //
18
+ // This module recovers the missing information by propagating unit exponents
19
+ // through the expression tree. Each variable contributes its dimension's
20
+ // exponent (length 1, area 2, volume 3), `*` adds them, `/` subtracts, `sqrt`
21
+ // halves, and the result's exponent names the dimension. Pair that with the
22
+ // base unit the variables share and the correct `resultUnit` is fully
23
+ // determined — which is why the authoring UI derives it rather than asking,
24
+ // and why validation can flag the scale errors it otherwise cannot see.
25
+ //
26
+ // Inference is deliberately conservative. Anything it cannot reason about —
27
+ // unknown functions, non-integer powers, symbols with no mapping — returns
28
+ // `ok: false` so callers fall back to prior behaviour instead of reporting a
29
+ // false positive on valid math.
30
+ // ---------------------------------------------------------------------------
31
+ /** Constants mathjs resolves without a scope entry. */
32
+ export const MATHJS_CONSTANTS = new Set([
33
+ 'pi',
34
+ 'PI',
35
+ 'e',
36
+ 'E',
37
+ 'tau',
38
+ 'phi',
39
+ 'i',
40
+ 'true',
41
+ 'false',
42
+ 'Infinity',
43
+ 'NaN',
44
+ 'null',
45
+ ]);
46
+ /** Shared mathjs parse — the one place expression text becomes a tree. */
47
+ export const parseExpression = (expression) => {
48
+ try {
49
+ return { ok: true, node: math.parse(expression) };
50
+ }
51
+ catch (err) {
52
+ return {
53
+ ok: false,
54
+ error: err instanceof Error ? err.message : String(err),
55
+ };
56
+ }
57
+ };
58
+ // The math.js base token a unit reduces to: 'sq_ft' -> 'ft', 'in_frac' -> 'in',
59
+ // 'liter' -> 'L'. Two units share a base exactly when they are the same
60
+ // physical unit at different exponents, which is what makes a result unit
61
+ // nameable.
62
+ const baseUnitOf = (unit) => (CALCULATOR_UNIT_INFO[unit]?.mathUnit ?? '').split('^')[0];
63
+ /**
64
+ * Bases whose values are ALREADY canonical, so an equation in them needs no
65
+ * `resultUnit` at all (the pre-unit-annotation form: a canonical scope in
66
+ * µm / µm² / µm³ / degrees).
67
+ */
68
+ export const CANONICAL_BASES = new Set(['um', 'deg']);
69
+ const CANONICAL_LENGTH_BASE = 'um';
70
+ const CANONICAL_ANGLE_BASE = 'deg';
71
+ // Base + length exponent -> the unit that names it. Yards have no length
72
+ // entry (the Units enum has no yard) and liters/gallons no length base at all,
73
+ // so both simply fail to name a unit rather than guessing.
74
+ const UNIT_BY_BASE = {
75
+ mm: { 1: Units.Millimeters, 2: 'sq_mm', 3: 'cu_mm' },
76
+ cm: { 1: Units.Centimeters, 2: 'sq_cm', 3: 'cu_cm' },
77
+ m: { 1: Units.Meters, 2: 'sq_m', 3: 'cu_m' },
78
+ in: { 1: Units.Inches, 2: 'sq_in', 3: 'cu_in' },
79
+ ft: { 1: Units.Feet, 2: 'sq_ft', 3: 'cu_ft' },
80
+ yd: { 2: 'sq_yd', 3: 'cu_yd' },
81
+ };
82
+ const SCALAR = { len: 0, ang: 0 };
83
+ const isScalar = (e) => e.len === 0 && e.ang === 0;
84
+ const sameExponents = (a, b) => a.len === b.len && a.ang === b.ang;
85
+ const EXPONENTS_BY_DIMENSION = {
86
+ none: SCALAR,
87
+ length: { len: 1, ang: 0 },
88
+ area: { len: 2, ang: 0 },
89
+ volume: { len: 3, ang: 0 },
90
+ angle: { len: 0, ang: 1 },
91
+ };
92
+ // Functions that return their arguments' dimension (scalar arguments — a digit
93
+ // count, a clamp bound — are ignored).
94
+ const DIMENSION_PRESERVING_FNS = new Set([
95
+ 'abs',
96
+ 'min',
97
+ 'max',
98
+ 'mean',
99
+ 'median',
100
+ 'sum',
101
+ 'round',
102
+ 'floor',
103
+ 'ceil',
104
+ 'fix',
105
+ 'mod',
106
+ ]);
107
+ // Trig works in DEGREES here (evaluate.ts overrides mathjs), so an inverse
108
+ // function's result is an angle in the canonical angle unit.
109
+ const DIRECT_TRIG_FNS = new Set(['sin', 'cos', 'tan', 'sec', 'csc', 'cot']);
110
+ const INVERSE_TRIG_FNS = new Set(['asin', 'acos', 'atan', 'atan2']);
111
+ // Functions that require and return plain numbers.
112
+ const SCALAR_FNS = new Set(['log', 'log10', 'log2', 'exp', 'sign']);
113
+ /**
114
+ * The dimension and unit of an equation's raw expression value — what the
115
+ * number means BEFORE `resultUnit` is applied.
116
+ */
117
+ export const inferExpressionUnit = (fields, equation) => {
118
+ const parsed = parseExpression(equation.expression);
119
+ if (!parsed.ok)
120
+ return { ok: false, reason: 'unparseable' };
121
+ const fieldById = new Map(fields.map((f) => [f.id, f]));
122
+ const lengthBases = new Set();
123
+ const angleBases = new Set();
124
+ let failure = null;
125
+ const fail = (reason) => {
126
+ failure ?? (failure = reason);
127
+ return null;
128
+ };
129
+ const noteBases = (exponents, base) => {
130
+ if (exponents.len !== 0)
131
+ lengthBases.add(base);
132
+ if (exponents.ang !== 0)
133
+ angleBases.add(base);
134
+ };
135
+ const resolveSymbol = (name) => {
136
+ const unit = equation.variableUnits?.[name];
137
+ if (unit != null) {
138
+ const exponents = EXPONENTS_BY_DIMENSION[unitDimension(unit)];
139
+ noteBases(exponents, baseUnitOf(unit));
140
+ return exponents;
141
+ }
142
+ const fieldId = equation.variableToFieldId[name];
143
+ const field = fieldId != null ? fieldById.get(fieldId) : undefined;
144
+ if (field) {
145
+ // No variable unit: the value enters the scope canonically (units.ts).
146
+ const dimension = fieldDimension(field);
147
+ const exponents = EXPONENTS_BY_DIMENSION[dimension];
148
+ noteBases(exponents, dimension === 'angle' ? CANONICAL_ANGLE_BASE : CANONICAL_LENGTH_BASE);
149
+ return exponents;
150
+ }
151
+ // Unmapped: fine if mathjs resolves it, otherwise validation's
152
+ // unmapped-symbol check owns the error and we just decline to infer.
153
+ return MATHJS_CONSTANTS.has(name) ? SCALAR : fail('unknown-symbol');
154
+ };
155
+ // Scale a term's exponents by a constant power, e.g. `^2` or `sqrt`. Only
156
+ // whole-number results are nameable: sqrt of an area is a length, sqrt of a
157
+ // length is not anything we can express.
158
+ const scaled = (exponents, factor) => {
159
+ const len = exponents.len * factor;
160
+ const ang = exponents.ang * factor;
161
+ return Number.isInteger(len) && Number.isInteger(ang)
162
+ ? { len, ang }
163
+ : fail('unnameable-dimension');
164
+ };
165
+ const constantValue = (node) => {
166
+ if (node.type === 'ParenthesisNode' && node.content) {
167
+ return constantValue(node.content);
168
+ }
169
+ if (node.type === 'ConstantNode' && typeof node.value === 'number') {
170
+ return node.value;
171
+ }
172
+ if (node.type === 'OperatorNode' && node.fn === 'unaryMinus') {
173
+ const inner = node.args?.[0] ? constantValue(node.args[0]) : null;
174
+ return inner == null ? null : -inner;
175
+ }
176
+ return null;
177
+ };
178
+ const walk = (node) => {
179
+ switch (node.type) {
180
+ case 'ConstantNode':
181
+ return SCALAR;
182
+ case 'ParenthesisNode':
183
+ return node.content
184
+ ? walk(node.content)
185
+ : fail('unsupported-operation');
186
+ case 'SymbolNode':
187
+ return node.name ? resolveSymbol(node.name) : fail('unknown-symbol');
188
+ case 'OperatorNode':
189
+ return walkOperator(node);
190
+ case 'FunctionNode':
191
+ return walkFunction(node);
192
+ default:
193
+ // ConditionalNode, AssignmentNode, ranges, matrices, ...
194
+ return fail('unsupported-operation');
195
+ }
196
+ };
197
+ const walkArgs = (node) => {
198
+ const args = node.args ?? [];
199
+ const out = [];
200
+ for (const arg of args) {
201
+ const exponents = walk(arg);
202
+ if (!exponents)
203
+ return null;
204
+ out.push(exponents);
205
+ }
206
+ return out;
207
+ };
208
+ const walkOperator = (node) => {
209
+ const args = walkArgs(node);
210
+ if (!args)
211
+ return null;
212
+ switch (node.fn) {
213
+ case 'multiply':
214
+ return args.reduce((acc, e) => ({ len: acc.len + e.len, ang: acc.ang + e.ang }), SCALAR);
215
+ case 'divide':
216
+ return args.length === 2
217
+ ? {
218
+ len: args[0].len - args[1].len,
219
+ ang: args[0].ang - args[1].ang,
220
+ }
221
+ : fail('unsupported-operation');
222
+ case 'add':
223
+ case 'subtract': {
224
+ // A bare literal added to a dimensioned term adopts its unit — that is
225
+ // the whole point of evaluating in display units ("wall + 2 inches").
226
+ // Two DIFFERENT dimensions added is an author error.
227
+ const dimensioned = args.filter((e) => !isScalar(e));
228
+ if (dimensioned.length === 0)
229
+ return SCALAR;
230
+ return dimensioned.every((e) => sameExponents(e, dimensioned[0]))
231
+ ? dimensioned[0]
232
+ : fail('inconsistent-terms');
233
+ }
234
+ case 'unaryMinus':
235
+ case 'unaryPlus':
236
+ return args[0] ?? fail('unsupported-operation');
237
+ case 'pow': {
238
+ if (args.length !== 2)
239
+ return fail('unsupported-operation');
240
+ if (isScalar(args[0]))
241
+ return SCALAR;
242
+ const exponent = node.args?.[1] ? constantValue(node.args[1]) : null;
243
+ return exponent == null
244
+ ? fail('unsupported-operation')
245
+ : scaled(args[0], exponent);
246
+ }
247
+ case 'mod':
248
+ return args[0] ?? fail('unsupported-operation');
249
+ default:
250
+ // Comparisons, logic, bit ops — not meaningful on dimensioned values.
251
+ return fail('unsupported-operation');
252
+ }
253
+ };
254
+ const walkFunction = (node) => {
255
+ const args = walkArgs(node);
256
+ if (!args)
257
+ return null;
258
+ const name = typeof node.fn === 'string' ? node.fn : node.fn?.name;
259
+ if (!name)
260
+ return fail('unsupported-operation');
261
+ if (name === 'sqrt')
262
+ return scaled(args[0] ?? SCALAR, 0.5);
263
+ if (name === 'cbrt')
264
+ return scaled(args[0] ?? SCALAR, 1 / 3);
265
+ if (name === 'nthRoot') {
266
+ const root = node.args?.[1] ? constantValue(node.args[1]) : 2;
267
+ return root == null || root === 0
268
+ ? fail('unsupported-operation')
269
+ : scaled(args[0] ?? SCALAR, 1 / root);
270
+ }
271
+ if (DIMENSION_PRESERVING_FNS.has(name)) {
272
+ const dimensioned = args.filter((e) => !isScalar(e));
273
+ if (dimensioned.length === 0)
274
+ return SCALAR;
275
+ return dimensioned.every((e) => sameExponents(e, dimensioned[0]))
276
+ ? dimensioned[0]
277
+ : fail('inconsistent-terms');
278
+ }
279
+ if (DIRECT_TRIG_FNS.has(name)) {
280
+ // Degrees in, plain ratio out.
281
+ const arg = args[0] ?? SCALAR;
282
+ return arg.len === 0 && arg.ang <= 1
283
+ ? SCALAR
284
+ : fail('inconsistent-terms');
285
+ }
286
+ if (INVERSE_TRIG_FNS.has(name)) {
287
+ // atan2's two arguments cancel, so they only need to match.
288
+ if (!args.every((e) => sameExponents(e, args[0] ?? SCALAR))) {
289
+ return fail('inconsistent-terms');
290
+ }
291
+ angleBases.add(CANONICAL_ANGLE_BASE);
292
+ return { len: 0, ang: 1 };
293
+ }
294
+ if (SCALAR_FNS.has(name)) {
295
+ return args.every(isScalar) ? SCALAR : fail('inconsistent-terms');
296
+ }
297
+ return fail('unsupported-operation');
298
+ };
299
+ const exponents = walk(parsed.node);
300
+ if (!exponents) {
301
+ return { ok: false, reason: failure ?? 'unsupported-operation' };
302
+ }
303
+ if (exponents.ang !== 0) {
304
+ // Angles don't combine with lengths into anything nameable (a length·deg
305
+ // has no unit here), and neither does deg².
306
+ if (exponents.len !== 0 || exponents.ang !== 1) {
307
+ return { ok: false, reason: 'unnameable-dimension' };
308
+ }
309
+ const bases = [...angleBases];
310
+ return {
311
+ ok: true,
312
+ dimension: 'angle',
313
+ unit: bases.length === 1 ? bases[0] : null,
314
+ bases,
315
+ };
316
+ }
317
+ if (exponents.len === 0) {
318
+ return { ok: true, dimension: 'none', unit: null, bases: [] };
319
+ }
320
+ if (exponents.len < 1 || exponents.len > 3) {
321
+ return { ok: false, reason: 'unnameable-dimension' };
322
+ }
323
+ const dimension = exponents.len === 1 ? 'length' : exponents.len === 2 ? 'area' : 'volume';
324
+ const bases = [...lengthBases];
325
+ return {
326
+ ok: true,
327
+ dimension,
328
+ unit: bases.length === 1
329
+ ? (UNIT_BY_BASE[bases[0]]?.[exponents.len] ?? null)
330
+ : null,
331
+ bases,
332
+ };
333
+ };
334
+ /**
335
+ * True when an expression's raw value is already canonical, so omitting
336
+ * `resultUnit` is correct rather than a 25400× mistake. Only the
337
+ * no-variable-units form (and dimensionless results) qualifies.
338
+ */
339
+ export const producesCanonicalValue = (inferred) => inferred.dimension === 'none' ||
340
+ (inferred.bases.length > 0 &&
341
+ inferred.bases.every((b) => CANONICAL_BASES.has(b)));
342
+ /** Same physical unit, ignoring display form: `in` ≡ `in_frac`, `ft` ≡ `ft_in_frac`. */
343
+ export const isEquivalentUnit = (a, b) => a === b ||
344
+ (unitDimension(a) === unitDimension(b) && baseUnitOf(a) === baseUnitOf(b));
345
+ /**
346
+ * How far off a value is when it carries `actual` but was declared as
347
+ * `declared` — 144 for in² labelled ft². Used to make validation messages
348
+ * say what the author will actually see.
349
+ */
350
+ export const unitScaleRatio = (declared, actual) => toCanonical(1, declared) / toCanonical(1, actual);
351
+ /**
352
+ * The `resultUnit` an equation should carry, or null when it should be omitted
353
+ * (dimensionless or already-canonical result) or cannot be derived. An
354
+ * existing equivalent unit is preserved so `in_frac` isn't churned to `in`.
355
+ */
356
+ export const deriveResultUnit = (fields, equation) => {
357
+ const inferred = inferExpressionUnit(fields, equation);
358
+ if (!inferred.ok || inferred.unit == null)
359
+ return null;
360
+ if (producesCanonicalValue(inferred))
361
+ return null;
362
+ const current = equation.resultUnit;
363
+ return current != null && isEquivalentUnit(current, inferred.unit)
364
+ ? current
365
+ : inferred.unit;
366
+ };
367
+ // Rebuild an equation without a key, since Firestore rejects `undefined`.
368
+ const withoutResultUnit = (equation) => {
369
+ const { resultUnit: _dropped, ...rest } = equation;
370
+ return rest;
371
+ };
372
+ /**
373
+ * Bring an equation's unit annotations back in line with the fields it
374
+ * references. Field edits (a dimension switch, a kind change, a deletion) are
375
+ * written independently of equations, so a stale `variableUnits` entry or
376
+ * `resultUnit` would otherwise keep converting against a unit the field no
377
+ * longer uses — the failure mode this whole module exists to prevent.
378
+ */
379
+ export const reconcileEquationUnits = (fields, equations) => {
380
+ const fieldById = new Map(fields.map((f) => [f.id, f]));
381
+ let changed = false;
382
+ const next = equations.map((equation) => {
383
+ let repaired = equation;
384
+ // 1. Variable units must match the dimension of the field they annotate.
385
+ if (equation.variableUnits) {
386
+ const variableUnits = {};
387
+ let unitsChanged = false;
388
+ for (const [variable, unit] of Object.entries(equation.variableUnits)) {
389
+ const field = fieldById.get(equation.variableToFieldId[variable] ?? '');
390
+ if (!field) {
391
+ unitsChanged = true; // variable or field is gone
392
+ continue;
393
+ }
394
+ if (unitDimension(unit) === fieldDimension(field)) {
395
+ variableUnits[variable] = unit;
396
+ continue;
397
+ }
398
+ const replacement = fieldDefaultUnit(field);
399
+ unitsChanged = true;
400
+ if (replacement)
401
+ variableUnits[variable] = replacement;
402
+ }
403
+ if (unitsChanged) {
404
+ repaired =
405
+ Object.keys(variableUnits).length > 0
406
+ ? { ...repaired, variableUnits }
407
+ : (() => {
408
+ const { variableUnits: _dropped, ...rest } = repaired;
409
+ return rest;
410
+ })();
411
+ }
412
+ }
413
+ // 2. The result unit follows from the repaired variable units.
414
+ const target = fieldById.get(equation.targetFieldId);
415
+ const targetDimension = target ? fieldDimension(target) : 'none';
416
+ if (targetDimension === 'none') {
417
+ if (repaired.resultUnit != null)
418
+ repaired = withoutResultUnit(repaired);
419
+ }
420
+ else {
421
+ const derived = deriveResultUnit(fields, repaired);
422
+ if (derived != null) {
423
+ if (repaired.resultUnit !== derived) {
424
+ repaired = { ...repaired, resultUnit: derived };
425
+ }
426
+ }
427
+ else if (repaired.resultUnit != null &&
428
+ unitDimension(repaired.resultUnit) !== targetDimension) {
429
+ // Not derivable AND not even the right dimension: drop it rather than
430
+ // keep converting a length factor into an area field.
431
+ repaired = withoutResultUnit(repaired);
432
+ }
433
+ }
434
+ if (repaired !== equation)
435
+ changed = true;
436
+ return repaired;
437
+ });
438
+ return { equations: next, changed };
439
+ };
@@ -1,6 +1,7 @@
1
1
  export * from './schema.js';
2
2
  export * from './categories.js';
3
3
  export * from './units.js';
4
+ export * from './expressionUnits.js';
4
5
  export * from './evaluate.js';
5
6
  export * from './solve.js';
6
7
  export * from './instance.js';
@@ -5,6 +5,7 @@
5
5
  export * from './schema.js';
6
6
  export * from './categories.js';
7
7
  export * from './units.js';
8
+ export * from './expressionUnits.js';
8
9
  export * from './evaluate.js';
9
10
  export * from './solve.js';
10
11
  export * from './instance.js';
@@ -111,6 +111,18 @@ export interface CalculatorEquation {
111
111
  expression: string;
112
112
  /** Variable letter -> field id. */
113
113
  variableToFieldId: Record<string, string>;
114
+ /**
115
+ * Variable letter -> display unit its value enters the scope in. A mapped
116
+ * variable without an entry stays canonical (µm-scale) — the pre-1.9
117
+ * behavior. Unit dimension must match the mapped field's dimension.
118
+ */
119
+ variableUnits?: Record<string, CalculatorUnit>;
120
+ /**
121
+ * Display unit the expression's RESULT is expressed in; converted back to
122
+ * canonical before storage/chaining. Only meaningful for measurement/angle
123
+ * targets — number targets take the result raw. Absent = canonical.
124
+ */
125
+ resultUnit?: CalculatorUnit;
114
126
  }
115
127
  /** A runtime/preview value for one field, in canonical units. */
116
128
  export type CalculatorFieldValue = number | string | string[] | null;
@@ -159,6 +171,12 @@ export declare const findEquation: (definition: Pick<CalculatorDefinition, "equa
159
171
  export declare const equationForField: (definition: Pick<CalculatorDefinition, "equations">, fieldId: string) => CalculatorEquation | undefined;
160
172
  /** The dimension a field's numeric value carries. */
161
173
  export declare const fieldDimension: (field: CalculatorField) => FieldDimension;
174
+ /**
175
+ * The unit a field's value is entered and displayed in, or null for fields
176
+ * that carry no unit. Also the unit an equation referencing the field starts
177
+ * out annotated with.
178
+ */
179
+ export declare const fieldDefaultUnit: (field: CalculatorField) => CalculatorUnit | null;
162
180
  /** Fields whose value participates in equations as a number. */
163
181
  export declare const isNumericFieldKind: (kind: CalculatorFieldKind) => kind is ColumnType.Number | ColumnType.Measurement | ColumnType.Angle | ColumnType.ConversionTable;
164
182
  /** Field kinds an equation may target (compute into). */
@@ -13,11 +13,17 @@ import { unitsForDimension } from './units.js';
13
13
  // brackets) and every runtime field value is canonical — µm for length, µm²
14
14
  // area, µm³ volume, degrees for angle, raw for dimensionless (see units.ts).
15
15
  //
16
- // EXPRESSION SEMANTICS: equations are math.js expressions evaluated over a
17
- // canonical scope. Numeric literals inside an expression are therefore
18
- // canonical too (a bare `2` multiplying a length is a plain scalar; a bare
19
- // additive length constant would be micrometers). Authors should express
20
- // length constants as input fields with a defaultValue instead of literals.
16
+ // EXPRESSION SEMANTICS: equations are math.js expressions. By default they
17
+ // evaluate over a canonical scope, which makes numeric literals canonical too
18
+ // (a bare `2` multiplying a length is a plain scalar; a bare additive length
19
+ // constant would be micrometers). Per-equation `variableUnits` / `resultUnit`
20
+ // instead evaluate in author-chosen display units: each mapped variable is
21
+ // converted OUT of canonical into its unit before entering the scope, and the
22
+ // result is converted back FROM `resultUnit` into canonical. This is what
23
+ // makes literals meaningful ("roof area in ft² / 100 = roofing squares") —
24
+ // without it, a number-kind output consuming a measurement variable silently
25
+ // receives µm-scale values. The authoring UI always sets units; the bare
26
+ // canonical form survives for pre-existing definitions.
21
27
  // ---------------------------------------------------------------------------
22
28
  /**
23
29
  * Version of the definition format itself. Bumped only on breaking schema
@@ -53,6 +59,21 @@ export const fieldDimension = (field) => {
53
59
  return 'none';
54
60
  }
55
61
  };
62
+ /**
63
+ * The unit a field's value is entered and displayed in, or null for fields
64
+ * that carry no unit. Also the unit an equation referencing the field starts
65
+ * out annotated with.
66
+ */
67
+ export const fieldDefaultUnit = (field) => {
68
+ switch (field.kind) {
69
+ case ColumnType.Measurement:
70
+ return field.unit.defaultUnit;
71
+ case ColumnType.Angle:
72
+ return field.angleUnit ?? 'deg';
73
+ default:
74
+ return null;
75
+ }
76
+ };
56
77
  /** Fields whose value participates in equations as a number. */
57
78
  export const isNumericFieldKind = (kind) => kind === ColumnType.Number ||
58
79
  kind === ColumnType.Measurement ||
@@ -157,6 +157,8 @@ export declare const calculatorDefinitionSchema: z.ZodObject<{
157
157
  targetFieldId: z.ZodString;
158
158
  expression: z.ZodString;
159
159
  variableToFieldId: z.ZodRecord<z.ZodString, z.ZodString>;
160
+ variableUnits: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
161
+ resultUnit: z.ZodOptional<z.ZodString>;
160
162
  }, z.core.$strip>>;
161
163
  diagramFileId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
162
164
  isPublic: z.ZodBoolean;
@@ -182,8 +184,16 @@ export interface ValidationResult {
182
184
  ok: boolean;
183
185
  issues: ValidationIssue[];
184
186
  }
185
- /** Free variables of an expression (function names and constants excluded). */
186
- export declare const expressionSymbols: (expression: string) => {
187
+ /**
188
+ * Free variables of an expression (function names and constants excluded).
189
+ *
190
+ * `knownVariables` are treated as variables even when mathjs would resolve
191
+ * them as constants: the authoring UI names variables A, B, C… and `E` is
192
+ * Euler's number, so without this a mapped `E` reads as a constant and gets
193
+ * reported as an unused variable. Evaluation is unaffected — a scope entry
194
+ * shadows the constant — so this only realigns validation with reality.
195
+ */
196
+ export declare const expressionSymbols: (expression: string, knownVariables?: Iterable<string>) => {
187
197
  ok: true;
188
198
  symbols: string[];
189
199
  } | {