@reekon-tools/boldr-utils 1.6.39 → 1.7.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.
- package/dist/annotation/canvas/elements/BackgroundImageElement.js +2 -7
- package/dist/annotation/canvas/tools/measurementLineTool.d.ts +12 -0
- package/dist/annotation/canvas/tools/measurementLineTool.js +95 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.js +31 -17
- package/dist/annotation/data/AnnotationDataProvider.d.ts +6 -1
- package/dist/annotation/data/AnnotationDataProvider.js +4 -1
- package/dist/calculator/evaluate.d.ts +49 -0
- package/dist/calculator/evaluate.js +207 -0
- package/dist/calculator/index.d.ts +5 -0
- package/dist/calculator/index.js +9 -0
- package/dist/calculator/schema.d.ts +163 -0
- package/dist/calculator/schema.js +129 -0
- package/dist/calculator/solve.d.ts +46 -0
- package/dist/calculator/solve.js +227 -0
- package/dist/calculator/units.d.ts +50 -0
- package/dist/calculator/units.js +137 -0
- package/dist/calculator/validate.d.ts +192 -0
- package/dist/calculator/validate.js +370 -0
- package/dist/exports.d.ts +2 -1
- package/dist/exports.js +4 -1
- package/package.json +3 -2
- package/dist/canvas/AnnotationCanvas.d.ts +0 -11
- package/dist/canvas/AnnotationCanvas.js +0 -10
- package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
- package/dist/canvas/AnnotationCanvas.native.js +0 -6
- package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
- package/dist/canvas/AnnotationCanvasInner.js +0 -219
- package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
- package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
- package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
- package/dist/canvas/AnnotationCanvasSkia.js +0 -20
- package/dist/canvas/Tool.d.ts +0 -38
- package/dist/canvas/Tool.js +0 -1
- package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
- package/dist/canvas/elements/BackgroundImageElement.js +0 -37
- package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
- package/dist/canvas/elements/MeasurementStampElement.js +0 -30
- package/dist/canvas/elements/ShapeElement.d.ts +0 -7
- package/dist/canvas/elements/ShapeElement.js +0 -62
- package/dist/canvas/elements/StrokeElement.d.ts +0 -7
- package/dist/canvas/elements/StrokeElement.js +0 -18
- package/dist/canvas/measurementPicker.d.ts +0 -10
- package/dist/canvas/measurementPicker.js +0 -1
- package/dist/canvas/measurementStampOverlay.d.ts +0 -11
- package/dist/canvas/measurementStampOverlay.js +0 -1
- package/dist/canvas/pointerAdapter.d.ts +0 -3
- package/dist/canvas/pointerAdapter.js +0 -19
- package/dist/canvas/stampLayout.d.ts +0 -5
- package/dist/canvas/stampLayout.js +0 -14
- package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
- package/dist/canvas/tools/measurementStampTool.js +0 -37
- package/dist/canvas/tools/panTool.d.ts +0 -5
- package/dist/canvas/tools/panTool.js +0 -25
- package/dist/canvas/tools/penTool.d.ts +0 -13
- package/dist/canvas/tools/penTool.js +0 -68
- package/dist/canvas/tools/selectTool.d.ts +0 -2
- package/dist/canvas/tools/selectTool.js +0 -182
- package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
- package/dist/canvas/useAnnotationCanvasState.js +0 -210
- package/dist/canvas/viewport.d.ts +0 -16
- package/dist/canvas/viewport.js +0 -54
- package/dist/data/AnnotationDataContext.d.ts +0 -8
- package/dist/data/AnnotationDataContext.js +0 -11
- package/dist/data/AnnotationDataProvider.d.ts +0 -65
- package/dist/data/AnnotationDataProvider.js +0 -4
- package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
- package/dist/data/InMemoryAnnotationProvider.js +0 -197
- package/dist/data/canvasPersistence.d.ts +0 -3
- package/dist/data/canvasPersistence.js +0 -26
- package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
- package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
- package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
- package/dist/data/hooks/useAnnotationDoc.js +0 -33
- package/dist/data/hooks/useAnnotationList.d.ts +0 -7
- package/dist/data/hooks/useAnnotationList.js +0 -26
- package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
- package/dist/data/hooks/useAnnotationMutations.js +0 -11
- package/dist/hooks/useParseMeasurement.d.ts +0 -4
- package/dist/hooks/useParseMeasurement.js +0 -14
- package/dist/utils/evaluateFormula.d.ts +0 -20
- package/dist/utils/evaluateFormula.js +0 -31
|
@@ -18,15 +18,10 @@ const useBackgroundUrl = (image, resolveUrl) => {
|
|
|
18
18
|
let cancelled = false;
|
|
19
19
|
const resolve = resolveUrlRef.current;
|
|
20
20
|
if (resolve) {
|
|
21
|
-
|
|
22
|
-
// an unhandled rejection here — keep the current url; the consumer's
|
|
23
|
-
// own retries/invalidations drive the re-resolve.
|
|
24
|
-
resolve(image.storagePath)
|
|
25
|
-
.then((next) => {
|
|
21
|
+
resolve(image.storagePath).then((next) => {
|
|
26
22
|
if (!cancelled)
|
|
27
23
|
setUrl(next);
|
|
28
|
-
})
|
|
29
|
-
.catch(() => { });
|
|
24
|
+
});
|
|
30
25
|
}
|
|
31
26
|
else {
|
|
32
27
|
setUrl(image.downloadUrl);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PlacedMeasurementRef } from '../../../types/annotation.js';
|
|
2
|
+
import type { Tool } from '../Tool.js';
|
|
3
|
+
export interface MeasurementLineToolOptions {
|
|
4
|
+
id?: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
minDragPx?: number;
|
|
7
|
+
autoSwitchToSelect?: boolean;
|
|
8
|
+
selectToolId?: string;
|
|
9
|
+
onAutoSwitch?: (toToolId: string) => void;
|
|
10
|
+
onPlaced?: (measurement: PlacedMeasurementRef) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare const createMeasurementLineTool: (options?: MeasurementLineToolOptions) => Tool;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
|
+
import { DEFAULT_LINE_POS, recomputeAnchor } from '../measurementGeometry.js';
|
|
3
|
+
let counter = 0;
|
|
4
|
+
const makeId = () => `annotation-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
5
|
+
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
6
|
+
// Build the blank line-measurement annotation for a drag from `a` to `b`: a
|
|
7
|
+
// 2-point line with a value tile magnetized to its center (linePos 0.5). The
|
|
8
|
+
// payload mirrors useAnnotationCanvasState's placeAnnotationAtCenter so a drawn
|
|
9
|
+
// line and a (legacy) center-placed one are byte-identical once committed.
|
|
10
|
+
const buildLineMeasurement = (opts) => {
|
|
11
|
+
const line = { a: opts.a, b: opts.b };
|
|
12
|
+
return {
|
|
13
|
+
id: opts.id,
|
|
14
|
+
layerId: opts.layerId,
|
|
15
|
+
placement: 'line',
|
|
16
|
+
line,
|
|
17
|
+
linePos: DEFAULT_LINE_POS,
|
|
18
|
+
// Center of the line; recomputeAnchor keeps this in sync on edits.
|
|
19
|
+
anchor: recomputeAnchor(line, 'line', DEFAULT_LINE_POS, {
|
|
20
|
+
x: (opts.a.x + opts.b.x) / 2,
|
|
21
|
+
y: (opts.a.y + opts.b.y) / 2,
|
|
22
|
+
}),
|
|
23
|
+
showLabel: true,
|
|
24
|
+
showValue: true,
|
|
25
|
+
createdAt: Date.now(),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
// Drag-to-draw measurement-line tool. Press-drag rubber-bands a measurement
|
|
29
|
+
// annotation (a line with a blank value tile at its center); release commits
|
|
30
|
+
// it. Mirrors createShapeTool's interaction so "input lines" are placed the
|
|
31
|
+
// same way as shapes — draw to add, not tap-the-icon-to-add — replacing the
|
|
32
|
+
// old center-place affordance. The annotation stays blank (no measurement
|
|
33
|
+
// associated) until the user fills it via the tile / picker. Skia-free, like
|
|
34
|
+
// every tool factory; it renders its live preview through ctx.preview, so it
|
|
35
|
+
// works on web and (via the generic tool-pan dispatch) on native.
|
|
36
|
+
export const createMeasurementLineTool = (options = {}) => {
|
|
37
|
+
const minDragPx = options.minDragPx ?? 4;
|
|
38
|
+
const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
|
|
39
|
+
const selectToolId = options.selectToolId ?? 'select';
|
|
40
|
+
return {
|
|
41
|
+
id: options.id ?? 'measure-line',
|
|
42
|
+
label: options.label ?? 'Measurement line',
|
|
43
|
+
cursor: 'crosshair',
|
|
44
|
+
onPointerDown(event) {
|
|
45
|
+
return {
|
|
46
|
+
kind: 'measurement-line-drawing',
|
|
47
|
+
id: makeId(),
|
|
48
|
+
startWorld: event.world,
|
|
49
|
+
startScreen: event.screen,
|
|
50
|
+
moved: false,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
onPointerMove(event, ctx, state) {
|
|
54
|
+
const s = state;
|
|
55
|
+
if (s?.kind !== 'measurement-line-drawing')
|
|
56
|
+
return s;
|
|
57
|
+
const measurement = buildLineMeasurement({
|
|
58
|
+
id: s.id,
|
|
59
|
+
layerId: firstLayerId(ctx.document),
|
|
60
|
+
a: s.startWorld,
|
|
61
|
+
b: event.world,
|
|
62
|
+
});
|
|
63
|
+
ctx.preview({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
64
|
+
return { ...s, moved: true };
|
|
65
|
+
},
|
|
66
|
+
onPointerUp(event, ctx, state) {
|
|
67
|
+
const s = state;
|
|
68
|
+
if (s?.kind !== 'measurement-line-drawing')
|
|
69
|
+
return;
|
|
70
|
+
const dx = event.screen.x - s.startScreen.x;
|
|
71
|
+
const dy = event.screen.y - s.startScreen.y;
|
|
72
|
+
if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
|
|
73
|
+
// Accidental tap — discard the rubber-band, commit nothing.
|
|
74
|
+
ctx.preview({ ops: [] });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const measurement = buildLineMeasurement({
|
|
78
|
+
id: s.id,
|
|
79
|
+
layerId: firstLayerId(ctx.document),
|
|
80
|
+
a: s.startWorld,
|
|
81
|
+
b: event.world,
|
|
82
|
+
});
|
|
83
|
+
ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
84
|
+
// Leave it selected (and hand back to select) so the blank line can be
|
|
85
|
+
// filled / moved straight away — the same flow as the text tool.
|
|
86
|
+
ctx.setSelection({ ids: [measurement.id] });
|
|
87
|
+
options.onPlaced?.(measurement);
|
|
88
|
+
if (autoSwitchToSelect)
|
|
89
|
+
options.onAutoSwitch?.(selectToolId);
|
|
90
|
+
},
|
|
91
|
+
onCancel(_state, ctx) {
|
|
92
|
+
ctx.preview({ ops: [] });
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
|
|
3
|
-
import { resolveTileScaleFactor, stampTileDims, tileDocSizeForFactor, tileScaleFromDocSize, } from './stampLayout.js';
|
|
3
|
+
import { resolveTileScaleFactor, stampTileDims, STAMP_TILE_SIZE, tileDocSizeForFactor, tileScaleFromDocSize, } from './stampLayout.js';
|
|
4
4
|
import { createViewportApi, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
|
|
5
5
|
import { buildRemoveMeasurementOps, recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
|
|
6
6
|
// The viewport that frames the document's content on screen. When a background
|
|
@@ -265,6 +265,32 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
265
265
|
],
|
|
266
266
|
});
|
|
267
267
|
};
|
|
268
|
+
// Consecutive place-at-center drops would all land dead-center, stacking
|
|
269
|
+
// each new tile exactly on top of the previous one. Cascade instead: walk
|
|
270
|
+
// down-right in fixed screen-px steps from center until a slot with no
|
|
271
|
+
// existing anchor is found. Occupancy is derived from the live placed
|
|
272
|
+
// measurements (not a drop counter), so moving or deleting a tile frees
|
|
273
|
+
// its slot automatically. Screen space is the right frame — tiles are
|
|
274
|
+
// screen-constant boxes — and mirrors the Nutrient file-viewer's cascade.
|
|
275
|
+
const cascadeCenterWorld = () => {
|
|
276
|
+
const c = ctxRef.current;
|
|
277
|
+
const CASCADE_STEP = 30;
|
|
278
|
+
const anchorsScreen = c.document.placedMeasurements.map((m) => c.viewport.worldToScreen(m.anchor));
|
|
279
|
+
const slotTaken = (x, y) => anchorsScreen.some((p) => Math.abs(p.x - x) < CASCADE_STEP / 2 &&
|
|
280
|
+
Math.abs(p.y - y) < CASCADE_STEP / 2);
|
|
281
|
+
// Stop cascading before the tile's right/bottom edge would leave the
|
|
282
|
+
// screen (tiles are centered on their anchor).
|
|
283
|
+
const halfTile = (STAMP_TILE_SIZE / 2) * c.tileScaleFactor * c.tileViewportScale;
|
|
284
|
+
let cx = width / 2;
|
|
285
|
+
let cy = height / 2;
|
|
286
|
+
while (slotTaken(cx, cy) &&
|
|
287
|
+
cx + halfTile + CASCADE_STEP <= width &&
|
|
288
|
+
cy + halfTile + CASCADE_STEP <= height) {
|
|
289
|
+
cx += CASCADE_STEP;
|
|
290
|
+
cy += CASCADE_STEP;
|
|
291
|
+
}
|
|
292
|
+
return c.viewport.screenToWorld({ x: cx, y: cy });
|
|
293
|
+
};
|
|
268
294
|
imperativeRef.current = {
|
|
269
295
|
undo() {
|
|
270
296
|
const entry = undoStackRef.current.pop();
|
|
@@ -328,10 +354,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
328
354
|
},
|
|
329
355
|
placeMeasurementAtCenter(ref) {
|
|
330
356
|
const c = ctxRef.current;
|
|
331
|
-
const anchor =
|
|
332
|
-
x: width / 2,
|
|
333
|
-
y: height / 2,
|
|
334
|
-
});
|
|
357
|
+
const anchor = cascadeCenterWorld();
|
|
335
358
|
const placed = {
|
|
336
359
|
id: `measurement-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`,
|
|
337
360
|
layerId: c.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
|
|
@@ -352,10 +375,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
352
375
|
},
|
|
353
376
|
placeAnnotationAtCenter(opts) {
|
|
354
377
|
const c = ctxRef.current;
|
|
355
|
-
const center =
|
|
356
|
-
x: width / 2,
|
|
357
|
-
y: height / 2,
|
|
358
|
-
});
|
|
378
|
+
const center = cascadeCenterWorld();
|
|
359
379
|
// Default line length: a generous fraction of the doc width, clamped,
|
|
360
380
|
// so the leader and its endcaps clear the fixed-size tile at full-fit.
|
|
361
381
|
const len = opts?.defaultLengthDoc ?? defaultLeaderLenDoc(canvas.viewport.width);
|
|
@@ -492,10 +512,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
492
512
|
},
|
|
493
513
|
placeColumnTileAtCenter(binding) {
|
|
494
514
|
const c = ctxRef.current;
|
|
495
|
-
const anchor =
|
|
496
|
-
x: width / 2,
|
|
497
|
-
y: height / 2,
|
|
498
|
-
});
|
|
515
|
+
const anchor = cascadeCenterWorld();
|
|
499
516
|
const placed = {
|
|
500
517
|
id: `measurement-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`,
|
|
501
518
|
layerId: c.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
|
|
@@ -513,10 +530,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
513
530
|
},
|
|
514
531
|
placeGroupHeaderAtCenter(ref) {
|
|
515
532
|
const c = ctxRef.current;
|
|
516
|
-
const anchor =
|
|
517
|
-
x: width / 2,
|
|
518
|
-
y: height / 2,
|
|
519
|
-
});
|
|
533
|
+
const anchor = cascadeCenterWorld();
|
|
520
534
|
// No showLabel/showValue/placement — a header is not an input; it
|
|
521
535
|
// renders the group's name/index, resolved live by the consumer.
|
|
522
536
|
const placed = {
|
|
@@ -12,8 +12,13 @@ export interface TemplateScope {
|
|
|
12
12
|
orgId?: string;
|
|
13
13
|
templateId: string;
|
|
14
14
|
}
|
|
15
|
-
export
|
|
15
|
+
export interface CalculatorScope {
|
|
16
|
+
kind: 'calculator';
|
|
17
|
+
calculatorId: string;
|
|
18
|
+
}
|
|
19
|
+
export type AnnotationScope = JobGroupScope | TemplateScope | CalculatorScope;
|
|
16
20
|
export declare const isTemplateScope: (s: AnnotationScope) => s is TemplateScope;
|
|
21
|
+
export declare const isCalculatorScope: (s: AnnotationScope) => s is CalculatorScope;
|
|
17
22
|
export declare const annotationScopeKey: (s: AnnotationScope | null) => string;
|
|
18
23
|
export type Unsubscribe = () => void;
|
|
19
24
|
export type FieldOp = {
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
export const isTemplateScope = (s) => 'kind' in s && s.kind === 'template';
|
|
2
|
+
export const isCalculatorScope = (s) => 'kind' in s && s.kind === 'calculator';
|
|
2
3
|
// Stable string identity for a scope, for React hook dep arrays — scope
|
|
3
4
|
// objects are typically rebuilt every render.
|
|
4
5
|
export const annotationScopeKey = (s) => s == null
|
|
5
6
|
? ''
|
|
6
7
|
: isTemplateScope(s)
|
|
7
8
|
? `template/${s.orgId ?? ''}/${s.templateId}`
|
|
8
|
-
:
|
|
9
|
+
: isCalculatorScope(s)
|
|
10
|
+
? `calculator/${s.calculatorId}`
|
|
11
|
+
: `job/${s.orgId}/${s.projectId}/${s.jobId}/${s.groupId}`;
|
|
9
12
|
export const isFieldOp = (v) => !!v &&
|
|
10
13
|
typeof v === 'object' &&
|
|
11
14
|
'kind' in v &&
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CalculatorDefinition, CalculatorEquation, CalculatorField, CalculatorFieldValue, CalculatorValues } from './schema.js';
|
|
2
|
+
export type ExpressionResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
value: number;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
error: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The unit-agnostic expression core: compile + evaluate + finiteness checks,
|
|
11
|
+
* no unit coercion. Scope values and the result share whatever unit system
|
|
12
|
+
* the caller established (canonical, for calculator equations).
|
|
13
|
+
*/
|
|
14
|
+
export declare const evaluateExpression: (expression: string, scope: Record<string, number>) => ExpressionResult;
|
|
15
|
+
/**
|
|
16
|
+
* The canonical numeric value a field contributes to an equation scope, or
|
|
17
|
+
* null when the field has no usable value. Select and instructions fields are
|
|
18
|
+
* non-computational (validation rejects mapping them into equations).
|
|
19
|
+
*/
|
|
20
|
+
export declare const numericFieldValue: (field: CalculatorField, value: CalculatorFieldValue | undefined) => number | null;
|
|
21
|
+
export type EquationResult = {
|
|
22
|
+
ok: true;
|
|
23
|
+
value: number;
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
reason: 'missing-inputs';
|
|
27
|
+
missingFieldIds: string[];
|
|
28
|
+
} | {
|
|
29
|
+
ok: false;
|
|
30
|
+
reason: 'error';
|
|
31
|
+
error: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Evaluate one equation against canonical field values. Variables referencing
|
|
35
|
+
* other output fields recurse into their equations (cycle-guarded).
|
|
36
|
+
*/
|
|
37
|
+
export declare const evaluateEquation: ({ definition, equation, values, }: {
|
|
38
|
+
definition: Pick<CalculatorDefinition, "fields" | "equations">;
|
|
39
|
+
equation: CalculatorEquation;
|
|
40
|
+
values: CalculatorValues;
|
|
41
|
+
}) => EquationResult;
|
|
42
|
+
/**
|
|
43
|
+
* Evaluate every equation in the definition. Returns a result per target
|
|
44
|
+
* field id — the live-preview / runtime "recompute all outputs" entry point.
|
|
45
|
+
*/
|
|
46
|
+
export declare const evaluateOutputs: ({ definition, values, }: {
|
|
47
|
+
definition: Pick<CalculatorDefinition, "fields" | "equations">;
|
|
48
|
+
values: CalculatorValues;
|
|
49
|
+
}) => Record<string, EquationResult>;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { create, all } from 'mathjs';
|
|
2
|
+
import { ColumnType } from '../types/firestore.js';
|
|
3
|
+
import { equationForField, findField } from './schema.js';
|
|
4
|
+
// Trig in calculator equations works in DEGREES: angle fields are stored in
|
|
5
|
+
// degrees canonically, and construction authors write `H = W * tan(A)`
|
|
6
|
+
// expecting A in degrees. mathjs has no `angle` config option (evaluateFormula
|
|
7
|
+
// passes `{ angle: 'deg' }`, but mathjs ignores it and runs radians), so we
|
|
8
|
+
// override the trig functions with degree-aware wrappers — the documented
|
|
9
|
+
// mathjs recipe for degree mode.
|
|
10
|
+
const math = create(all);
|
|
11
|
+
const DEG = Math.PI / 180;
|
|
12
|
+
math.import({
|
|
13
|
+
sin: (x) => Math.sin(x * DEG),
|
|
14
|
+
cos: (x) => Math.cos(x * DEG),
|
|
15
|
+
tan: (x) => Math.tan(x * DEG),
|
|
16
|
+
sec: (x) => 1 / Math.cos(x * DEG),
|
|
17
|
+
csc: (x) => 1 / Math.sin(x * DEG),
|
|
18
|
+
cot: (x) => 1 / Math.tan(x * DEG),
|
|
19
|
+
asin: (x) => Math.asin(x) / DEG,
|
|
20
|
+
acos: (x) => Math.acos(x) / DEG,
|
|
21
|
+
atan: (x) => Math.atan(x) / DEG,
|
|
22
|
+
atan2: (y, x) => Math.atan2(y, x) / DEG,
|
|
23
|
+
}, { override: true });
|
|
24
|
+
const compile = math.compile.bind(math);
|
|
25
|
+
// Small compile cache — the editor preview re-evaluates on every keystroke.
|
|
26
|
+
const compileCache = new Map();
|
|
27
|
+
const COMPILE_CACHE_MAX = 200;
|
|
28
|
+
const compiled = (expression) => {
|
|
29
|
+
const hit = compileCache.get(expression);
|
|
30
|
+
if (hit)
|
|
31
|
+
return hit;
|
|
32
|
+
const node = compile(expression);
|
|
33
|
+
if (compileCache.size >= COMPILE_CACHE_MAX)
|
|
34
|
+
compileCache.clear();
|
|
35
|
+
compileCache.set(expression, node);
|
|
36
|
+
return node;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The unit-agnostic expression core: compile + evaluate + finiteness checks,
|
|
40
|
+
* no unit coercion. Scope values and the result share whatever unit system
|
|
41
|
+
* the caller established (canonical, for calculator equations).
|
|
42
|
+
*/
|
|
43
|
+
export const evaluateExpression = (expression, scope) => {
|
|
44
|
+
let node;
|
|
45
|
+
try {
|
|
46
|
+
node = compiled(expression);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: `Invalid expression: ${err instanceof Error ? err.message : String(err)}`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
let result;
|
|
55
|
+
try {
|
|
56
|
+
// Spread so mathjs can't mutate the caller's scope.
|
|
57
|
+
result = node.evaluate({ ...scope });
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: `Evaluation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (typeof result !== 'number') {
|
|
66
|
+
return { ok: false, error: 'Expression did not produce a number' };
|
|
67
|
+
}
|
|
68
|
+
if (Number.isNaN(result))
|
|
69
|
+
return { ok: false, error: 'Result is NaN' };
|
|
70
|
+
if (!Number.isFinite(result)) {
|
|
71
|
+
return { ok: false, error: 'Result is not finite (division by zero?)' };
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, value: result };
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* The canonical numeric value a field contributes to an equation scope, or
|
|
77
|
+
* null when the field has no usable value. Select and instructions fields are
|
|
78
|
+
* non-computational (validation rejects mapping them into equations).
|
|
79
|
+
*/
|
|
80
|
+
export const numericFieldValue = (field, value) => {
|
|
81
|
+
switch (field.kind) {
|
|
82
|
+
case ColumnType.Number:
|
|
83
|
+
case ColumnType.Measurement:
|
|
84
|
+
case ColumnType.Angle: {
|
|
85
|
+
const v = value ?? field.defaultValue;
|
|
86
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
87
|
+
}
|
|
88
|
+
case ColumnType.ConversionTable: {
|
|
89
|
+
// Value is the selected conversion's label; the numeric factor comes
|
|
90
|
+
// from the matched entry (Conversion.value is a string app-wide).
|
|
91
|
+
const label = value ?? field.defaultValue;
|
|
92
|
+
if (typeof label !== 'string')
|
|
93
|
+
return null;
|
|
94
|
+
const entry = field.columnData.conversions.find((c) => c.label === label);
|
|
95
|
+
if (!entry)
|
|
96
|
+
return null;
|
|
97
|
+
const parsed = parseFloat(entry.value);
|
|
98
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
99
|
+
}
|
|
100
|
+
default:
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const evaluateEquationInContext = (equation, ctx) => {
|
|
105
|
+
const cached = ctx.cache.get(equation.id);
|
|
106
|
+
if (cached)
|
|
107
|
+
return cached;
|
|
108
|
+
if (ctx.stack.has(equation.id)) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
reason: 'error',
|
|
112
|
+
error: `Circular equation reference involving ${equation.id}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
ctx.stack.add(equation.id);
|
|
116
|
+
try {
|
|
117
|
+
const scope = {};
|
|
118
|
+
const missing = [];
|
|
119
|
+
for (const [variable, fieldId] of Object.entries(equation.variableToFieldId)) {
|
|
120
|
+
const resolved = resolveFieldNumeric(fieldId, ctx);
|
|
121
|
+
if (!resolved.ok) {
|
|
122
|
+
if (resolved.reason === 'missing-inputs') {
|
|
123
|
+
missing.push(...resolved.missingFieldIds);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
return resolved;
|
|
127
|
+
}
|
|
128
|
+
scope[variable] = resolved.value;
|
|
129
|
+
}
|
|
130
|
+
if (missing.length > 0) {
|
|
131
|
+
const result = {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason: 'missing-inputs',
|
|
134
|
+
missingFieldIds: [...new Set(missing)],
|
|
135
|
+
};
|
|
136
|
+
ctx.cache.set(equation.id, result);
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
const evaluated = evaluateExpression(equation.expression, scope);
|
|
140
|
+
const result = evaluated.ok
|
|
141
|
+
? { ok: true, value: evaluated.value }
|
|
142
|
+
: { ok: false, reason: 'error', error: evaluated.error };
|
|
143
|
+
ctx.cache.set(equation.id, result);
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
ctx.stack.delete(equation.id);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
// An output field with an equation resolves to its COMPUTED value; if the
|
|
151
|
+
// computation is blocked on missing inputs but the user supplied a value for
|
|
152
|
+
// the output directly (the two-way-solving posture), the supplied value is
|
|
153
|
+
// used instead. Everything else resolves from `values`/defaults.
|
|
154
|
+
const resolveFieldNumeric = (fieldId, ctx) => {
|
|
155
|
+
const field = findField(ctx.definition, fieldId);
|
|
156
|
+
if (!field) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
reason: 'error',
|
|
160
|
+
error: `Equation references unknown field ${fieldId}`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const equation = equationForField(ctx.definition, fieldId);
|
|
164
|
+
if (equation) {
|
|
165
|
+
const computed = evaluateEquationInContext(equation, ctx);
|
|
166
|
+
if (computed.ok)
|
|
167
|
+
return computed;
|
|
168
|
+
if (computed.reason === 'missing-inputs') {
|
|
169
|
+
const supplied = numericFieldValue(field, ctx.values[fieldId]);
|
|
170
|
+
if (supplied != null)
|
|
171
|
+
return { ok: true, value: supplied };
|
|
172
|
+
}
|
|
173
|
+
return computed;
|
|
174
|
+
}
|
|
175
|
+
const value = numericFieldValue(field, ctx.values[fieldId]);
|
|
176
|
+
if (value == null) {
|
|
177
|
+
return { ok: false, reason: 'missing-inputs', missingFieldIds: [fieldId] };
|
|
178
|
+
}
|
|
179
|
+
return { ok: true, value };
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* Evaluate one equation against canonical field values. Variables referencing
|
|
183
|
+
* other output fields recurse into their equations (cycle-guarded).
|
|
184
|
+
*/
|
|
185
|
+
export const evaluateEquation = ({ definition, equation, values, }) => evaluateEquationInContext(equation, {
|
|
186
|
+
definition,
|
|
187
|
+
values,
|
|
188
|
+
stack: new Set(),
|
|
189
|
+
cache: new Map(),
|
|
190
|
+
});
|
|
191
|
+
/**
|
|
192
|
+
* Evaluate every equation in the definition. Returns a result per target
|
|
193
|
+
* field id — the live-preview / runtime "recompute all outputs" entry point.
|
|
194
|
+
*/
|
|
195
|
+
export const evaluateOutputs = ({ definition, values, }) => {
|
|
196
|
+
const ctx = {
|
|
197
|
+
definition,
|
|
198
|
+
values,
|
|
199
|
+
stack: new Set(),
|
|
200
|
+
cache: new Map(),
|
|
201
|
+
};
|
|
202
|
+
const results = {};
|
|
203
|
+
for (const equation of definition.equations) {
|
|
204
|
+
results[equation.targetFieldId] = evaluateEquationInContext(equation, ctx);
|
|
205
|
+
}
|
|
206
|
+
return results;
|
|
207
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export * from './schema.js';
|
|
2
|
+
export * from './units.js';
|
|
3
|
+
export * from './evaluate.js';
|
|
4
|
+
export * from './solve.js';
|
|
5
|
+
export { calculatorDefinitionSchema, expressionSymbols, validateCalculatorDefinition, type IssueSeverity, type ValidationIssue, type ValidationResult, } from './validate.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Construction Calculator shared module: the calculator-definition schema,
|
|
2
|
+
// per-field unit system, forward evaluator, two-way numeric solver, and
|
|
3
|
+
// definition validation. Pure logic (mathjs + zod only — no Skia/React), safe
|
|
4
|
+
// on web, native, and Node.
|
|
5
|
+
export * from './schema.js';
|
|
6
|
+
export * from './units.js';
|
|
7
|
+
export * from './evaluate.js';
|
|
8
|
+
export * from './solve.js';
|
|
9
|
+
export { calculatorDefinitionSchema, expressionSymbols, validateCalculatorDefinition, } from './validate.js';
|