@reekon-tools/boldr-utils 1.15.4 → 1.16.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 (82) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +134 -11
  2. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +4 -1
  3. package/dist/annotation/canvas/AnnotationCanvasSkia.js +11 -6
  4. package/dist/annotation/canvas/backgroundLayers.d.ts +1 -0
  5. package/dist/annotation/canvas/backgroundLayers.js +8 -0
  6. package/dist/annotation/canvas/elements/PlaneElement.d.ts +3 -0
  7. package/dist/annotation/canvas/elements/PlaneElement.js +5 -5
  8. package/dist/annotation/canvas/measurementGeometry.js +3 -0
  9. package/dist/annotation/canvas/planeGeometry.js +21 -10
  10. package/dist/annotation/canvas/tools/selectTool.js +14 -3
  11. package/dist/calculator/index.d.ts +1 -0
  12. package/dist/calculator/index.js +1 -0
  13. package/dist/calculator/preview.d.ts +22 -0
  14. package/dist/calculator/preview.js +43 -0
  15. package/dist/calculator/schema.d.ts +14 -0
  16. package/dist/calculator/schema.js +2 -0
  17. package/dist/calculator/validate.d.ts +1 -0
  18. package/dist/calculator/validate.js +17 -0
  19. package/dist/exports.d.ts +1 -1
  20. package/dist/exports.js +1 -1
  21. package/dist/types/firestore.d.ts +2 -0
  22. package/package.json +1 -1
  23. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  24. package/dist/canvas/AnnotationCanvas.js +0 -10
  25. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  26. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  27. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  28. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  29. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  30. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  31. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  32. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  33. package/dist/canvas/Tool.d.ts +0 -38
  34. package/dist/canvas/Tool.js +0 -1
  35. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  36. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  37. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  38. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  39. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  40. package/dist/canvas/elements/ShapeElement.js +0 -62
  41. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  42. package/dist/canvas/elements/StrokeElement.js +0 -18
  43. package/dist/canvas/measurementPicker.d.ts +0 -10
  44. package/dist/canvas/measurementPicker.js +0 -1
  45. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  46. package/dist/canvas/measurementStampOverlay.js +0 -1
  47. package/dist/canvas/pointerAdapter.d.ts +0 -3
  48. package/dist/canvas/pointerAdapter.js +0 -19
  49. package/dist/canvas/stampLayout.d.ts +0 -5
  50. package/dist/canvas/stampLayout.js +0 -14
  51. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  52. package/dist/canvas/tools/measurementStampTool.js +0 -37
  53. package/dist/canvas/tools/panTool.d.ts +0 -5
  54. package/dist/canvas/tools/panTool.js +0 -25
  55. package/dist/canvas/tools/penTool.d.ts +0 -13
  56. package/dist/canvas/tools/penTool.js +0 -68
  57. package/dist/canvas/tools/selectTool.d.ts +0 -2
  58. package/dist/canvas/tools/selectTool.js +0 -182
  59. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  60. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  61. package/dist/canvas/viewport.d.ts +0 -16
  62. package/dist/canvas/viewport.js +0 -54
  63. package/dist/data/AnnotationDataContext.d.ts +0 -8
  64. package/dist/data/AnnotationDataContext.js +0 -11
  65. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  66. package/dist/data/AnnotationDataProvider.js +0 -4
  67. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  68. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  69. package/dist/data/canvasPersistence.d.ts +0 -3
  70. package/dist/data/canvasPersistence.js +0 -26
  71. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  72. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  73. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  74. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  75. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  76. package/dist/data/hooks/useAnnotationList.js +0 -26
  77. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  78. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  79. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  80. package/dist/hooks/useParseMeasurement.js +0 -14
  81. package/dist/utils/evaluateFormula.d.ts +0 -20
  82. package/dist/utils/evaluateFormula.js +0 -31
@@ -7,9 +7,10 @@ import Animated, { runOnJS, runOnUI, useAnimatedStyle, useDerivedValue, useShare
7
7
  import { stampTileDims } from './stampLayout.js';
8
8
  import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
9
9
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
10
- import { backgroundLayersOf } from './backgroundLayers.js';
10
+ import { backgroundLayersOf, backgroundLayerDocRect, } from './backgroundLayers.js';
11
11
  import { BackgroundSkImageLoader } from './elements/BackgroundImageElement.js';
12
- import { buildRemoveMeasurementOps, } from './measurementGeometry.js';
12
+ import { buildRemoveMeasurementOps, linePosOf, } from './measurementGeometry.js';
13
+ import { planeEdgeLine, planeGridSegments, planeGridSegmentsForScale, resolvePlaneGridSpacingUm, } from './planeGeometry.js';
13
14
  import { buildShapeFromDrag } from './tools/shapeTool.js';
14
15
  import { SELECTION_PAD } from './textGeometry.js';
15
16
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
@@ -481,10 +482,13 @@ export const AnnotationCanvasInner = (props) => {
481
482
  // The plane tool's JS preview path re-rendered the whole canvas (and
482
483
  // recomputed the photo-spanning grid) per pointer-move, dropping JS frames
483
484
  // on device. Instead the drag runs like the select tool's: the gesture
484
- // begin hit-tests once on JS, the translation lives in shared values
485
- // driving a live outline (worklet twin of PlaneElement's outline path), and
486
- // the drop commits exactly once. The grid + dimension tiles freeze/hide
487
- // until the commit lands.
485
+ // begin hit-tests once on JS, the translation lives in shared values, and
486
+ // the drop commits exactly once. Everything the drag visually moves is
487
+ // rebuilt per frame on the UI thread: the outline (worklet twin of
488
+ // PlaneElement's path), the grid (planeGeometry's generators are worklets),
489
+ // and the dimension-tile chips (a plane branch in the RN overlay's animated
490
+ // style, driven by planeTileCtx). Only the tiles' computed VALUES wait for
491
+ // the commit — they're user-entered dims, which a corner drag can't change.
488
492
  // planeDragMode: 0 idle · 1 handle drag (UI thread) · 2 everything else
489
493
  // (marking / blocked-creation), which streams JS pointer events like
490
494
  // toolPan would have.
@@ -541,6 +545,67 @@ export const AnnotationCanvasInner = (props) => {
541
545
  path.lineTo(b.x, b.y);
542
546
  return path;
543
547
  });
548
+ // Live grid during the drag. Spacing and bounds are start-of-drag constants
549
+ // computed on JS (the dims a corner drag can't change decide the spacing;
550
+ // the photo rect decides the bounds — both mirror PlaneElement's inputs),
551
+ // captured into the derived value; the segments themselves recompute per
552
+ // frame on the UI thread from the live corner — planeGeometry's generators
553
+ // carry 'worklet' directives for exactly this call site. Scale mode's doc
554
+ // spacing tracks the live reference line, which is correct: stretching the
555
+ // line IS changing the calibration's ratio.
556
+ const planeDragGridBounds = useMemo(() => {
557
+ const first = backgroundLayers[0];
558
+ return first
559
+ ? backgroundLayerDocRect(first)
560
+ : {
561
+ x: 0,
562
+ y: 0,
563
+ width: state.effectiveCanvas.viewport.width,
564
+ height: state.effectiveCanvas.viewport.height,
565
+ };
566
+ }, [backgroundLayers, state.effectiveCanvas.viewport]);
567
+ const planeDragGridSpacing = planeDragPlane && planeDragPlane.gridVisible !== false
568
+ ? resolvePlaneGridSpacingUm(planeDragPlane, 'metric', {
569
+ width: planeDragGridBounds.width,
570
+ height: planeDragGridBounds.height,
571
+ })
572
+ : null;
573
+ const planeDragGridPath = useDerivedValue(() => {
574
+ 'worklet';
575
+ const path = Skia.Path.Make();
576
+ if (!planeDragPlane ||
577
+ planeDragGridSpacing == null ||
578
+ planeDragMode.value !== 1) {
579
+ return path;
580
+ }
581
+ const lx = planeDragStartX.value + planeDragTX.value / zoom.value;
582
+ const ly = planeDragStartY.value + planeDragTY.value / zoom.value;
583
+ let segments;
584
+ if (planeDragPlane.mode === 'perspective') {
585
+ const c = planeDragPlane.corners;
586
+ const live = [0, 1, 2, 3].map((i) => i === planeDragIndexVal ? { x: lx, y: ly } : c[i]);
587
+ // A mid-drag non-convex quad yields a null homography → empty grid for
588
+ // that frame; the outline keeps tracking and the drop snaps back.
589
+ segments = planeGridSegments({ ...planeDragPlane, corners: live }, planeDragGridSpacing, planeDragGridBounds);
590
+ }
591
+ else {
592
+ const a = planeDragIndexVal === 0 ? { x: lx, y: ly } : planeDragPlane.refLine.a;
593
+ const b = planeDragIndexVal === 1 ? { x: lx, y: ly } : planeDragPlane.refLine.b;
594
+ segments = planeGridSegmentsForScale({ ...planeDragPlane, refLine: { a, b } }, planeDragGridSpacing, planeDragGridBounds);
595
+ }
596
+ for (const s of segments) {
597
+ path.moveTo(s.a.x, s.a.y);
598
+ path.lineTo(s.b.x, s.b.y);
599
+ }
600
+ return path;
601
+ });
602
+ // Per-tile follow data for the dragged plane's dimension chips, written
603
+ // once at drag begin (see beginPlaneDrag): the committed edge endpoints,
604
+ // whether each end follows the dragged handle, and the tile's linePos.
605
+ // The overlay's animated style lerps along the live edge at that same
606
+ // linePos — the exact anchor syncDimensionTileOps recomputes on commit,
607
+ // so the chip lands where it already is.
608
+ const planeTileCtx = useSharedValue({});
544
609
  // Rectangle-annotation corner drag. `rectDragId` (React state) marks which
545
610
  // annotation's rect renders from the live geometry; `rectCtx` carries the
546
611
  // fixed (opposite) corner and the grabbed corner's start position so the
@@ -1568,6 +1633,32 @@ export const AnnotationCanvasInner = (props) => {
1568
1633
  if (start) {
1569
1634
  planeDragStartX.value = start.x;
1570
1635
  planeDragStartY.value = start.y;
1636
+ // Follow data for the plane's dimension chips: each tile's edge
1637
+ // (planeEdgeLine — the committed geometry its line mirrors), which
1638
+ // endpoint(s) track the grabbed handle (coordinate equality with
1639
+ // the handle's start — both values come from the same committed
1640
+ // plane, so exact comparison is sound), and its linePos.
1641
+ const plane = st.ctx.document.planes?.find((p) => p.id === planeId);
1642
+ const follow = {};
1643
+ if (plane) {
1644
+ for (const m of st.ctx.document.placedMeasurements) {
1645
+ if (m.planeId !== planeId || !m.planeEdge)
1646
+ continue;
1647
+ const edge = planeEdgeLine(plane, m.planeEdge);
1648
+ if (!edge)
1649
+ continue;
1650
+ follow[m.id] = {
1651
+ ax: edge.a.x,
1652
+ ay: edge.a.y,
1653
+ bx: edge.b.x,
1654
+ by: edge.b.y,
1655
+ ma: edge.a.x === start.x && edge.a.y === start.y ? 1 : 0,
1656
+ mb: edge.b.x === start.x && edge.b.y === start.y ? 1 : 0,
1657
+ t: linePosOf(m),
1658
+ };
1659
+ }
1660
+ }
1661
+ planeTileCtx.value = follow;
1571
1662
  planeDragMode.value = 1;
1572
1663
  const drag = { planeId, index };
1573
1664
  planeDragRef.current = drag;
@@ -1588,7 +1679,7 @@ export const AnnotationCanvasInner = (props) => {
1588
1679
  if (f)
1589
1680
  stateRef.current.dispatchPointerMove(buildEvent(f.id, screen));
1590
1681
  };
1591
- const endPlaneDrag = (screen) => {
1682
+ const endPlaneDrag = (screen, translation) => {
1592
1683
  const st = stateRef.current;
1593
1684
  if (planeDragMode.value === 2) {
1594
1685
  const f = planeJsPointerRef.current;
@@ -1596,7 +1687,17 @@ export const AnnotationCanvasInner = (props) => {
1596
1687
  st.dispatchPointerUp(buildEvent(f.id, screen));
1597
1688
  }
1598
1689
  else if (planeDragMode.value === 1 && planeDragRef.current) {
1599
- const world = st.ctx.viewport.screenToWorld(screen);
1690
+ // Commit the position the live outline showed: handle start + the
1691
+ // finger's translation. NOT screenToWorld(finger) — the hit-test
1692
+ // accepts a grab anywhere inside the handle's radius, so the
1693
+ // finger's absolute position is off the handle by that grab offset,
1694
+ // and committing it made the corner visibly shift on release.
1695
+ // Zoom can't change mid-drag (this pan won the gesture race, so the
1696
+ // pinch never activated), so reading it at drop matches every frame.
1697
+ const world = {
1698
+ x: planeDragStartX.value + translation.x / zoom.value,
1699
+ y: planeDragStartY.value + translation.y / zoom.value,
1700
+ };
1600
1701
  const patch = cfg.buildHandleDropPatch(st.ctx.document, planeDragRef.current.index, world);
1601
1702
  // Null = invalid drop (non-convex) — clearing the drag state below
1602
1703
  // snaps the outline back to the committed plane.
@@ -1650,7 +1751,10 @@ export const AnnotationCanvasInner = (props) => {
1650
1751
  .onEnd((e) => {
1651
1752
  'worklet';
1652
1753
  planeDragEnded.value = true;
1653
- runOnJS(endPlaneDrag)({ x: e.x, y: e.y });
1754
+ // The final translation travels as an argument rather than being
1755
+ // read back from the shared values on JS: the last onChange isn't
1756
+ // guaranteed to have carried it, and onEnd doesn't write them.
1757
+ runOnJS(endPlaneDrag)({ x: e.x, y: e.y }, { x: e.translationX, y: e.translationY });
1654
1758
  })
1655
1759
  .onFinalize(() => {
1656
1760
  'worklet';
@@ -1701,6 +1805,7 @@ export const AnnotationCanvasInner = (props) => {
1701
1805
  planeDragTY,
1702
1806
  planeDragStartX,
1703
1807
  planeDragStartY,
1808
+ planeTileCtx,
1704
1809
  longPressEnabled,
1705
1810
  dragX,
1706
1811
  dragY,
@@ -1799,6 +1904,7 @@ export const AnnotationCanvasInner = (props) => {
1799
1904
  planeDragId: planeDrag?.planeId ?? null,
1800
1905
  planeDragIndex: planeDrag?.index ?? null,
1801
1906
  planeDragPath,
1907
+ planeDragGridPath,
1802
1908
  planeDragHandle,
1803
1909
  customPreview,
1804
1910
  };
@@ -1815,7 +1921,7 @@ export const AnnotationCanvasInner = (props) => {
1815
1921
  // non-fullscreen canvas (e.g. a diagram strip) escape into surrounding UI.
1816
1922
  _jsxs(GestureHandlerRootView, { style: [{ width, height, overflow: 'hidden' }, style], children: [backgroundLayers.map((layer) => (_jsx(BackgroundSkImageLoader, { layer: layer, resolveUrl: resolveImageUrl, onImage: onBackgroundSkImage }, layer.id))), _jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia(skiaProps) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
1817
1923
  ? (state.measurementsById.get(placed.measurementId) ?? null)
1818
- : (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.tileScaleFactor, headerTileScaleFactor: state.headerTileScaleFactor, fileTileScaleFactor: state.fileTileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: onMeasurementStampRemove
1924
+ : (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, planeEdgeDragging: planeDrag != null && placed.planeId === planeDrag.planeId, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, planeTileCtx: planeTileCtx, planeDragTX: planeDragTX, planeDragTY: planeDragTY, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.tileScaleFactor, headerTileScaleFactor: state.headerTileScaleFactor, fileTileScaleFactor: state.fileTileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: onMeasurementStampRemove
1819
1925
  ? () => {
1820
1926
  const defaultRemove = () => {
1821
1927
  const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
@@ -1831,7 +1937,7 @@ export const AnnotationCanvasInner = (props) => {
1831
1937
  wrapContent: loupeWrap,
1832
1938
  }) }))] }));
1833
1939
  };
1834
- const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, tileScaleFactor, headerTileScaleFactor, fileTileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
1940
+ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, planeEdgeDragging, planeTileCtx, planeDragTX, planeDragTY, renderMeasurementStamp, tileScaleFactor, headerTileScaleFactor, fileTileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
1835
1941
  // Square tile or wide group-header pill — one footprint source of truth
1836
1942
  // shared with the hit-test (stampTileDims).
1837
1943
  const { width, height } = stampTileDims(placed, tileScaleFactor, tileViewportScale, headerTileScaleFactor ?? tileScaleFactor, fileTileScaleFactor ?? tileScaleFactor);
@@ -1845,6 +1951,7 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
1845
1951
  const removeSize = Math.min(36, Math.min(width, height) * 0.4);
1846
1952
  const anchorX = placed.anchor.x;
1847
1953
  const anchorY = placed.anchor.y;
1954
+ const placedId = placed.id;
1848
1955
  // doc → screen each frame, on the UI thread. Position is a translate
1849
1956
  // transform (cheap, no layout) so the tile stays glued to its anchor. While
1850
1957
  // dragging, the world-space drag offset is folded in; while sliding, the
@@ -1923,6 +2030,22 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
1923
2030
  worldX = (c.fx + c.mx + dragX.value) / 2;
1924
2031
  worldY = (c.fy + c.my + dragY.value) / 2;
1925
2032
  }
2033
+ else if (planeEdgeDragging) {
2034
+ // Tile stays at its linePos along the LIVE plane edge. The plane drag's
2035
+ // translation is screen px (unlike dragX/dragY's world units), so it
2036
+ // converts through the live zoom, same as the outline twin.
2037
+ const c = planeTileCtx.value[placedId];
2038
+ if (c) {
2039
+ const dx = planeDragTX.value / zoom.value;
2040
+ const dy = planeDragTY.value / zoom.value;
2041
+ const ax = c.ma === 1 ? c.ax + dx : c.ax;
2042
+ const ay = c.ma === 1 ? c.ay + dy : c.ay;
2043
+ const bx = c.mb === 1 ? c.bx + dx : c.bx;
2044
+ const by = c.mb === 1 ? c.by + dy : c.by;
2045
+ worldX = ax + (bx - ax) * c.t;
2046
+ worldY = ay + (by - ay) * c.t;
2047
+ }
2048
+ }
1926
2049
  else if (dragging) {
1927
2050
  worldX = anchorX + dragX.value;
1928
2051
  worldY = anchorY + dragY.value;
@@ -94,10 +94,13 @@ export interface AnnotationCanvasSkiaProps {
94
94
  planeDragPath?: SkPath | {
95
95
  value: SkPath;
96
96
  };
97
+ planeDragGridPath?: SkPath | {
98
+ value: SkPath;
99
+ };
97
100
  planeDragHandle?: AnimatedPoint;
98
101
  customPreview?: ReactNode;
99
102
  wrapContent?: (content: ReactNode) => ReactNode;
100
103
  canvasRef?: RefObject<CanvasRef | null>;
101
104
  }
102
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragHandle, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
105
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
103
106
  export {};
@@ -4,9 +4,9 @@ import { normalizeRect, placementOf } from './measurementGeometry.js';
4
4
  import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from './strokeGeometry.js';
5
5
  import { SELECTION_PAD, textResizeGeometry, textShapeBounds, } from './textGeometry.js';
6
6
  import { visualShapeBounds } from './shapeGeometry.js';
7
- import { backgroundLayerDocRect, backgroundLayersOf, } from './backgroundLayers.js';
7
+ import { backgroundLayerDocRect, backgroundLayersOf, canEditBackgroundLayers, } from './backgroundLayers.js';
8
8
  import { BackgroundLayerElement } from './elements/BackgroundImageElement.js';
9
- import { PlaneElement, PLANE_CHROME_COLOR, PLANE_OUTLINE_WIDTH, } from './elements/PlaneElement.js';
9
+ import { PlaneElement, PLANE_CHROME_COLOR, PLANE_GRID_COLOR, PLANE_GRID_OPACITY, PLANE_GRID_WIDTH, PLANE_OUTLINE_WIDTH, } from './elements/PlaneElement.js';
10
10
  import { ShapeElement } from './elements/ShapeElement.js';
11
11
  import { StrokeElement } from './elements/StrokeElement.js';
12
12
  // Default visual constants for the measurement-annotation line (the tile itself
@@ -80,7 +80,7 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
80
80
  // since the function-call pattern works identically on native we use it
81
81
  // in both Inners for consistency. Don't add hooks here; this is a plain
82
82
  // JSX-returning helper, not a component.
83
- export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragHandle, customPreview, wrapContent, canvasRef, }) => {
83
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, customPreview, wrapContent, canvasRef, }) => {
84
84
  // Doc rect a SCALE-mode plane grid covers: the first background layer (the
85
85
  // photo being calibrated), else the document rect. Pinned to layer 0 like
86
86
  // the tile-sizing reference — adding a second image must not re-span the
@@ -99,7 +99,7 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
99
99
  const statics = plane.mode === 'perspective'
100
100
  ? plane.corners
101
101
  : [plane.refLine.a, plane.refLine.b];
102
- return (_jsxs(Group, { children: [_jsx(Path, { path: planeDragPath, color: PLANE_CHROME_COLOR, style: "stroke", strokeWidth: PLANE_OUTLINE_WIDTH, strokeJoin: "round" }), handleRadius != null &&
102
+ return (_jsxs(Group, { children: [planeDragGridPath && (_jsx(Path, { path: planeDragGridPath, color: PLANE_GRID_COLOR, style: "stroke", strokeWidth: PLANE_GRID_WIDTH, opacity: PLANE_GRID_OPACITY })), _jsx(Path, { path: planeDragPath, color: PLANE_CHROME_COLOR, style: "stroke", strokeWidth: PLANE_OUTLINE_WIDTH, strokeJoin: "round" }), handleRadius != null &&
103
103
  statics.map((p, i) => i === planeDragIndex ? null : (_jsxs(Group, { children: [_jsx(Circle, { c: p, r: handleRadius, color: PLANE_CHROME_COLOR }), _jsx(Circle, { c: p, r: handleRadius, color: "#FFFFFF", style: "stroke", strokeWidth: handleRingWidth })] }, i))), handleRadius != null && planeDragHandle && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: planeDragHandle, r: handleRadius, color: PLANE_CHROME_COLOR }), _jsx(Circle, { c: planeDragHandle, r: handleRadius, color: "#FFFFFF", style: "stroke", strokeWidth: handleRingWidth })] }))] }, plane.id));
104
104
  }
105
105
  return (_jsx(PlaneElement, { plane: plane, scaleBounds: planeScaleBounds, showHandles: showPlaneHandles === true, handleRadius: showPlaneHandles ? handleRadius : undefined, handleRingWidth: showPlaneHandles ? handleRingWidth : undefined }, plane.id));
@@ -251,8 +251,13 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
251
251
  // bottom-right handle — backgrounds scale uniformly about their
252
252
  // top-left corner (the transform origin), so a single opposite-corner
253
253
  // grab is the whole gizmo. Chrome tracks the live drag/resize
254
- // transform the same way the layer itself does above.
255
- const layer = backgroundLayersOf(effectiveCanvas.viewport).find((l) => l.id === selectedId);
254
+ // transform the same way the layer itself does above. A lone
255
+ // background is never editable (canEditBackgroundLayers), so it gets
256
+ // no chrome even when a stale selection still names it — e.g. right
257
+ // after the other layer of a pair was removed.
258
+ const layer = canEditBackgroundLayers(effectiveCanvas.viewport)
259
+ ? backgroundLayersOf(effectiveCanvas.viewport).find((l) => l.id === selectedId)
260
+ : undefined;
256
261
  if (layer) {
257
262
  const r = backgroundLayerDocRect(layer);
258
263
  const isResizing = layer.id === resizingId;
@@ -2,6 +2,7 @@ import type { AnnotationBackgroundLayer, AnnotationViewport, BackgroundLayerTran
2
2
  export declare const LEGACY_BACKGROUND_LAYER_ID = "background-legacy";
3
3
  export declare const BACKGROUND_PLACEMENT_GAP_RATIO = 0.02;
4
4
  export declare const backgroundLayersOf: (viewport: AnnotationViewport) => AnnotationBackgroundLayer[];
5
+ export declare const canEditBackgroundLayers: (viewport: AnnotationViewport) => boolean;
5
6
  export declare const backgroundLayerDocRect: (layer: AnnotationBackgroundLayer) => {
6
7
  x: number;
7
8
  y: number;
@@ -46,6 +46,14 @@ export const backgroundLayersOf = (viewport) => {
46
46
  },
47
47
  ];
48
48
  };
49
+ // Whether the background stack is user-editable at all — selectable, draggable,
50
+ // corner-scalable. A lone image IS the canvas: there is nothing to arrange it
51
+ // against, so it exposes no selection chrome and never moves; taps and drags
52
+ // on it fall through to the ordinary empty-canvas behavior (deselect / pan).
53
+ // Only once a second image exists does arranging make sense, and then every
54
+ // layer becomes editable. Shared by the select tool's hit-tests and the
55
+ // selection chrome so both platforms agree on the rule.
56
+ export const canEditBackgroundLayers = (viewport) => backgroundLayersOf(viewport).length > 1;
49
57
  // The doc-space rectangle a layer occupies: the intrinsic pixel grid under
50
58
  // the layer's translate+scale.
51
59
  export const backgroundLayerDocRect = (layer) => ({
@@ -1,6 +1,9 @@
1
1
  import type { PlaneCalibration } from '../../../types/annotation.js';
2
2
  export declare const PLANE_CHROME_COLOR: "#BD30A8";
3
3
  export declare const PLANE_OUTLINE_WIDTH = 14;
4
+ export declare const PLANE_GRID_COLOR = "#FFD1F8";
5
+ export declare const PLANE_GRID_OPACITY = 0.9;
6
+ export declare const PLANE_GRID_WIDTH = 2.5;
4
7
  type AnimatedNumber = number | {
5
8
  value: number;
6
9
  };
@@ -8,15 +8,15 @@ import { FormulaColors } from '../../../theme/colors.js';
8
8
  // information: the formula purple outlines the calibrated quad / reference
9
9
  // line (and inks its handles and dimension-tile lines — see planeTool), while
10
10
  // the grid it casts gets its own pale pink so the two never blur.
11
- // Exported for the live UI-thread handle drag, which redraws the outline in
12
- // AnnotationCanvasSkia while this element is hidden.
11
+ // Exported for the live UI-thread handle drag, which redraws the outline and
12
+ // grid in AnnotationCanvasSkia while this element is hidden.
13
13
  export const PLANE_CHROME_COLOR = FormulaColors.purple;
14
14
  export const PLANE_OUTLINE_WIDTH = 14;
15
15
  const PLANE_COLOR = PLANE_CHROME_COLOR;
16
- const PLANE_GRID_COLOR = '#FFD1F8';
17
- const PLANE_GRID_OPACITY = 0.9;
16
+ export const PLANE_GRID_COLOR = '#FFD1F8';
17
+ export const PLANE_GRID_OPACITY = 0.9;
18
18
  // Doc-space stroke widths (scale with zoom, like the drawn shapes).
19
- const PLANE_GRID_WIDTH = 2.5;
19
+ export const PLANE_GRID_WIDTH = 2.5;
20
20
  // Cross-tick half-length (doc units) marking the ends of a scale-mode
21
21
  // reference line, so it reads as "a measured span", not just a line.
22
22
  const REF_TICK_LEN = 18;
@@ -44,7 +44,10 @@ export const projectToLinePos = (line, world) => {
44
44
  export const snapLinePos = (t, threshold = 0.06) => Math.abs(t - 0.5) <= threshold ? 0.5 : t;
45
45
  // Euclidean length of a line in doc space (used to convert a screen-px snap
46
46
  // radius into a t-space threshold).
47
+ // 'worklet': called from planeGeometry's grid generators, which run per-frame
48
+ // on the UI thread during a plane handle drag. Inert everywhere else.
47
49
  export const lineLength = (line) => {
50
+ 'worklet';
48
51
  const dx = line.b.x - line.a.x;
49
52
  const dy = line.b.y - line.a.y;
50
53
  return Math.sqrt(dx * dx + dy * dy);
@@ -7,15 +7,17 @@
7
7
  // SIZING (stampLayout.ts). Everything here is plane calibration — mapping
8
8
  // DOC-space geometry to physical lengths in micrometers.
9
9
  //
10
- // v1 keeps all of this on the JS thread (values recompute on commit, not
11
- // per-frame), so there are no worklet twins of these functions yet. If live
12
- // drag readouts are ever needed, the twin goes next to the existing geometry
13
- // twins in AnnotationCanvasInner.native.tsx.
10
+ // The homography/grid chain carries 'worklet' directives (inert everywhere
11
+ // but a Reanimated build): AnnotationCanvasInner.native.tsx rebuilds the grid
12
+ // per frame on the UI thread during a plane handle drag, calling these
13
+ // directly rather than maintaining an inline twin of ~200 lines of math.
14
+ // Values (planeDistanceUm etc.) still recompute on commit, JS-side.
14
15
  import { lineLength } from './measurementGeometry.js';
15
16
  // Degeneracy guards are scaled to the quad's own extent — doc coordinates are
16
17
  // arbitrary (a photo canvas is typically ~1000 units across), so a fixed
17
18
  // epsilon would misfire on very small or very large documents.
18
19
  const relativeEps = (corners) => {
20
+ 'worklet';
19
21
  let span = 1;
20
22
  for (const p of corners) {
21
23
  span = Math.max(span, Math.abs(p.x), Math.abs(p.y));
@@ -27,6 +29,7 @@ const relativeEps = (corners) => {
27
29
  // (collinear/coincident corners). Corners must already be in TL,TR,BR,BL order
28
30
  // (see normalizeQuadOrder).
29
31
  export const homographyFromQuad = (corners) => {
32
+ 'worklet';
30
33
  const [p0, p1, p2, p3] = corners;
31
34
  const eps = relativeEps(corners);
32
35
  const sx = p0.x - p1.x + p2.x - p3.x;
@@ -71,6 +74,7 @@ export const homographyFromQuad = (corners) => {
71
74
  // Returns null when that entry (a·e − b·d) is ~zero — the inverse would put
72
75
  // the whole doc plane on the vanishing line.
73
76
  export const invertHomography = (m) => {
77
+ 'worklet';
74
78
  const norm = m.a * m.e - m.b * m.d;
75
79
  if (Math.abs(norm) < 1e-12)
76
80
  return null;
@@ -88,6 +92,7 @@ export const invertHomography = (m) => {
88
92
  // Apply a homography to a point. Null when the point sits (numerically) on the
89
93
  // vanishing line — the plane there is at infinity.
90
94
  export const applyHomography = (m, p) => {
95
+ 'worklet';
91
96
  const w = m.g * p.x + m.h * p.y + 1;
92
97
  if (Math.abs(w) < 1e-12)
93
98
  return null;
@@ -148,12 +153,15 @@ export const normalizeQuadOrder = (pts) => {
148
153
  // The dimensions a calibration has been given so far. Null until the user has
149
154
  // entered them all (through the plane's dimension tiles) — an incomplete
150
155
  // calibration renders its geometry but computes nothing.
151
- const perspectiveDims = (cal) => cal.widthUm != null &&
152
- cal.widthUm > 0 &&
153
- cal.heightUm != null &&
154
- cal.heightUm > 0
155
- ? { widthUm: cal.widthUm, heightUm: cal.heightUm }
156
- : null;
156
+ const perspectiveDims = (cal) => {
157
+ 'worklet';
158
+ return cal.widthUm != null &&
159
+ cal.widthUm > 0 &&
160
+ cal.heightUm != null &&
161
+ cal.heightUm > 0
162
+ ? { widthUm: cal.widthUm, heightUm: cal.heightUm }
163
+ : null;
164
+ };
157
165
  // Doc-space point → plane coordinates in micrometers (x across the rectangle's
158
166
  // width, y down its height; the TL corner is the origin). Null when the
159
167
  // calibration's quad is degenerate, its dimensions aren't entered yet, or the
@@ -333,6 +341,7 @@ export const resolvePlaneGridSpacingUm = (cal, system, scaleBounds) => {
333
341
  const GRID_EXTENT_CAP = 12;
334
342
  // Liang–Barsky segment/rect clip, DOC space. Null when fully outside.
335
343
  const clipSegmentToRect = (a, b, rect) => {
344
+ 'worklet';
336
345
  const dx = b.x - a.x;
337
346
  const dy = b.y - a.y;
338
347
  let t0 = 0;
@@ -385,6 +394,7 @@ const clipSegmentToRect = (a, b, rect) => {
385
394
  // far side of the horizon are dropped (w stays positive across a segment
386
395
  // only when it's positive at both ends — w is affine on the plane).
387
396
  export const planeGridSegments = (cal, spacingUm, bounds) => {
397
+ 'worklet';
388
398
  const dims = perspectiveDims(cal);
389
399
  const h = homographyFromQuad(cal.corners);
390
400
  if (!dims ||
@@ -476,6 +486,7 @@ export const planeGridSegments = (cal, spacingUm, bounds) => {
476
486
  // rect, or the document rect when there's no background), DOC space. Lines
477
487
  // anchor at the bounds origin.
478
488
  export const planeGridSegmentsForScale = (cal, spacingUm, bounds) => {
489
+ 'worklet';
479
490
  const refDoc = lineLength(cal.refLine);
480
491
  const lengthUm = cal.lengthUm ?? 0;
481
492
  if (!(refDoc > 0) ||
@@ -1,4 +1,4 @@
1
- import { backgroundLayerDocRect, backgroundLayersOf, } from '../backgroundLayers.js';
1
+ import { backgroundLayerDocRect, backgroundLayersOf, canEditBackgroundLayers, } from '../backgroundLayers.js';
2
2
  import { stampTileDims } from '../stampLayout.js';
3
3
  import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
4
4
  import { hitShapeOutline, visualShapeBounds } from '../shapeGeometry.js';
@@ -462,7 +462,10 @@ const shapeCornerPatch = (doc, id, corner, delta) => {
462
462
  // hit-test: a tap selects one only when every stroke/shape/measurement
463
463
  // misses, and a drag translates one only when it is ALREADY selected — the
464
464
  // layout tool's two-step model, which keeps a full-bleed background from
465
- // hijacking pan/marquee gestures. ---
465
+ // hijacking pan/marquee gestures. And none of it applies to a LONE image: with
466
+ // a single background there is nothing to arrange, so it is not selectable,
467
+ // draggable, or scalable at all (canEditBackgroundLayers) — every hit-test
468
+ // below misses, and the gesture falls through to deselect / pan. ---
466
469
  // Doc-space clamp on a resized background's larger doc dimension. The floor
467
470
  // keeps a corner drag from collapsing a layer below grabbable size; the
468
471
  // ceiling keeps the doc rect (which feeds fit math and the web SVG raster
@@ -478,6 +481,8 @@ const pointInBackgroundLayer = (layer, p) => {
478
481
  // layers draw on top). Point-in-doc-rect, no padding: a background is a big
479
482
  // target already.
480
483
  const findBackgroundHit = (doc, world) => {
484
+ if (!canEditBackgroundLayers(doc.viewport))
485
+ return null;
481
486
  const layers = backgroundLayersOf(doc.viewport);
482
487
  for (let i = layers.length - 1; i >= 0; i--) {
483
488
  if (pointInBackgroundLayer(layers[i], world)) {
@@ -490,6 +495,8 @@ const findBackgroundHit = (doc, world) => {
490
495
  // paths call this only after the general hit-test misses, so annotation
491
496
  // elements always win a grab over the background beneath them.
492
497
  const findSelectedBackgroundHit = (doc, world, selectedIds) => {
498
+ if (!canEditBackgroundLayers(doc.viewport))
499
+ return null;
493
500
  const layers = backgroundLayersOf(doc.viewport);
494
501
  for (let i = layers.length - 1; i >= 0; i--) {
495
502
  const layer = layers[i];
@@ -527,7 +534,11 @@ const backgroundResizeGeometry = (layer) => {
527
534
  // endpoint handles either way.
528
535
  const findResizeHandleHit = (doc, id, world, zoom) => {
529
536
  const s = doc.shapes.find((x) => x.id === id);
530
- const layer = s ? null : backgroundLayerById(doc, id);
537
+ // A lone background is not editable at all (canEditBackgroundLayers), so
538
+ // its corner offers no handle — even to a stale selection naming it.
539
+ const layer = s || !canEditBackgroundLayers(doc.viewport)
540
+ ? null
541
+ : backgroundLayerById(doc, id);
531
542
  const geom = s
532
543
  ? textResizeGeometry(s)
533
544
  : layer
@@ -9,4 +9,5 @@ export * from './instance.js';
9
9
  export * from './runtime.js';
10
10
  export * from './display.js';
11
11
  export * from './chips.js';
12
+ export * from './preview.js';
12
13
  export { calculatorDefinitionSchema, expressionSymbols, validateCalculatorDefinition, type IssueSeverity, type ValidationIssue, type ValidationResult, } from './validate.js';
@@ -15,4 +15,5 @@ export * from './instance.js';
15
15
  export * from './runtime.js';
16
16
  export * from './display.js';
17
17
  export * from './chips.js';
18
+ export * from './preview.js';
18
19
  export { calculatorDefinitionSchema, expressionSymbols, validateCalculatorDefinition, } from './validate.js';
@@ -0,0 +1,22 @@
1
+ import { type CalculatorDefinition, type CalculatorField } from './schema.js';
2
+ import type { CalculatorInstanceFileData } from './instance.js';
3
+ /**
4
+ * The field a thumbnail headlines, per CalculatorDefinition.previewFieldId's
5
+ * tri-state: an explicit id (when it names a numeric output), null for none,
6
+ * absent for auto — the sole numeric output, if there is exactly one.
7
+ */
8
+ export declare const previewFieldFor: (definition: Pick<CalculatorDefinition, "fields" | "previewFieldId">) => CalculatorField | null;
9
+ export interface PreviewValue {
10
+ field: CalculatorField;
11
+ /** Display string in `unit` — fractional inches etc., same as the runtime. */
12
+ value: string;
13
+ /** Unit label; empty for unit-less numbers. */
14
+ unit: string;
15
+ }
16
+ /**
17
+ * The headline value for an instance, or null when there is nothing to show:
18
+ * no preview field, the field is unresolved (inputs missing), or it is in
19
+ * conflict (the runtime withholds conflicted values behind a dash — a tile
20
+ * should show nothing rather than a dash).
21
+ */
22
+ export declare const previewValueFor: (fileData: Pick<CalculatorInstanceFileData, "definition" | "entries" | "displayUnitOverrides">) => PreviewValue | null;
@@ -0,0 +1,43 @@
1
+ // Thumbnail headline for a saved calculator instance (Asana
2
+ // 1217881404454870): which output a tile shows, and its formatted value.
3
+ // Shared by boldr-pro's FilePreview and rock-desktop so both platforms pick
4
+ // the same field and print the same string. Pure — runs the same
5
+ // deriveRuntime pass the runtime and the T1R drift check use, so it needs no
6
+ // stored result (computed values are never persisted; see instance.ts).
7
+ import { isEquationTargetKind, } from './schema.js';
8
+ import { deriveRuntime } from './runtime.js';
9
+ import { displayUnitFor, formatFieldValue } from './display.js';
10
+ /** Outputs eligible to headline a thumbnail: numeric, equation-target kinds. */
11
+ const numericOutputs = (definition) => definition.fields.filter((field) => field.role === 'output' && isEquationTargetKind(field.kind));
12
+ /**
13
+ * The field a thumbnail headlines, per CalculatorDefinition.previewFieldId's
14
+ * tri-state: an explicit id (when it names a numeric output), null for none,
15
+ * absent for auto — the sole numeric output, if there is exactly one.
16
+ */
17
+ export const previewFieldFor = (definition) => {
18
+ if (definition.previewFieldId === null)
19
+ return null;
20
+ const candidates = numericOutputs(definition);
21
+ if (typeof definition.previewFieldId === 'string') {
22
+ return (candidates.find((field) => field.id === definition.previewFieldId) ?? null);
23
+ }
24
+ return candidates.length === 1 ? candidates[0] : null;
25
+ };
26
+ /**
27
+ * The headline value for an instance, or null when there is nothing to show:
28
+ * no preview field, the field is unresolved (inputs missing), or it is in
29
+ * conflict (the runtime withholds conflicted values behind a dash — a tile
30
+ * should show nothing rather than a dash).
31
+ */
32
+ export const previewValueFor = (fileData) => {
33
+ const field = previewFieldFor(fileData.definition);
34
+ if (!field)
35
+ return null;
36
+ const resolved = deriveRuntime(fileData.definition, fileData.entries ?? {})
37
+ .resolved[field.id];
38
+ if (!resolved || resolved.conflict || typeof resolved.value !== 'number') {
39
+ return null;
40
+ }
41
+ const unit = displayUnitFor(field, fileData.displayUnitOverrides ?? {});
42
+ return { field, ...formatFieldValue(field, unit, resolved.value) };
43
+ };
@@ -190,6 +190,20 @@ export interface CalculatorDefinition {
190
190
  /** Inputs + outputs in one list, discriminated by `role`. */
191
191
  fields: CalculatorField[];
192
192
  equations: CalculatorEquation[];
193
+ /**
194
+ * The output whose resolved value a saved instance's THUMBNAIL headlines
195
+ * (Asana 1217881404454870 — Area/Volume and every T1R function calculator
196
+ * are impractical to open one by one just to read the result). Tri-state:
197
+ * - absent (undefined): AUTO — the sole numeric output when the
198
+ * calculator has exactly one, nothing when it has several. This is also
199
+ * what instances frozen before the field existed resolve to, so they
200
+ * gain a headline without being re-saved.
201
+ * - null: explicitly none — the author opted a single-output calc out.
202
+ * - id: that output. Must be role 'output' and an equation-target kind;
203
+ * anything else is validated as a warning and ignored at runtime.
204
+ * Always resolve through previewFieldFor() — never read this raw.
205
+ */
206
+ previewFieldId?: string | null;
193
207
  /** Diagram file (files subcollection doc carrying the Skia canvas). */
194
208
  diagramFileId?: string | null;
195
209
  /** Public calculators are readable by everyone; rules/query key. */
@@ -208,6 +208,8 @@ export const createEmptyCalculatorDefinition = (id) => ({
208
208
  category: null,
209
209
  fields: [],
210
210
  equations: [],
211
+ // previewFieldId is deliberately ABSENT, not null: absent means "auto"
212
+ // (headline the sole numeric output), null means the author opted out.
211
213
  diagramFileId: null,
212
214
  isPublic: false,
213
215
  });