@reekon-tools/boldr-utils 1.15.1 → 1.15.3

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.js +5 -2
  2. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +10 -3
  3. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +2 -1
  4. package/dist/annotation/canvas/AnnotationCanvasSkia.js +17 -3
  5. package/dist/annotation/canvas/Tool.d.ts +1 -0
  6. package/dist/annotation/canvas/elements/PlaneElement.d.ts +18 -0
  7. package/dist/annotation/canvas/elements/PlaneElement.js +115 -0
  8. package/dist/annotation/canvas/planeGeometry.d.ts +48 -0
  9. package/dist/annotation/canvas/planeGeometry.js +507 -0
  10. package/dist/annotation/canvas/tools/measurementTool.d.ts +1 -0
  11. package/dist/annotation/canvas/tools/measurementTool.js +13 -0
  12. package/dist/annotation/canvas/tools/planeTool.d.ts +13 -0
  13. package/dist/annotation/canvas/tools/planeTool.js +391 -0
  14. package/dist/annotation/canvas/tools/selectTool.js +9 -0
  15. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +3 -1
  16. package/dist/annotation/canvas/useAnnotationCanvasState.js +22 -1
  17. package/dist/canvas/AnnotationCanvas.d.ts +11 -0
  18. package/dist/canvas/AnnotationCanvas.js +10 -0
  19. package/dist/canvas/AnnotationCanvas.native.d.ts +8 -0
  20. package/dist/canvas/AnnotationCanvas.native.js +6 -0
  21. package/dist/canvas/AnnotationCanvasInner.d.ts +39 -0
  22. package/dist/canvas/AnnotationCanvasInner.js +219 -0
  23. package/dist/canvas/AnnotationCanvasInner.native.d.ts +35 -0
  24. package/dist/canvas/AnnotationCanvasInner.native.js +138 -0
  25. package/dist/canvas/AnnotationCanvasSkia.d.ts +27 -0
  26. package/dist/canvas/AnnotationCanvasSkia.js +20 -0
  27. package/dist/canvas/Tool.d.ts +38 -0
  28. package/dist/canvas/Tool.js +1 -0
  29. package/dist/canvas/elements/BackgroundImageElement.d.ts +9 -0
  30. package/dist/canvas/elements/BackgroundImageElement.js +37 -0
  31. package/dist/canvas/elements/MeasurementStampElement.d.ts +13 -0
  32. package/dist/canvas/elements/MeasurementStampElement.js +30 -0
  33. package/dist/canvas/elements/ShapeElement.d.ts +7 -0
  34. package/dist/canvas/elements/ShapeElement.js +62 -0
  35. package/dist/canvas/elements/StrokeElement.d.ts +7 -0
  36. package/dist/canvas/elements/StrokeElement.js +18 -0
  37. package/dist/canvas/measurementPicker.d.ts +10 -0
  38. package/dist/canvas/measurementPicker.js +1 -0
  39. package/dist/canvas/measurementStampOverlay.d.ts +11 -0
  40. package/dist/canvas/measurementStampOverlay.js +1 -0
  41. package/dist/canvas/pointerAdapter.d.ts +3 -0
  42. package/dist/canvas/pointerAdapter.js +19 -0
  43. package/dist/canvas/stampLayout.d.ts +5 -0
  44. package/dist/canvas/stampLayout.js +14 -0
  45. package/dist/canvas/tools/measurementStampTool.d.ts +9 -0
  46. package/dist/canvas/tools/measurementStampTool.js +37 -0
  47. package/dist/canvas/tools/panTool.d.ts +5 -0
  48. package/dist/canvas/tools/panTool.js +25 -0
  49. package/dist/canvas/tools/penTool.d.ts +13 -0
  50. package/dist/canvas/tools/penTool.js +68 -0
  51. package/dist/canvas/tools/selectTool.d.ts +2 -0
  52. package/dist/canvas/tools/selectTool.js +182 -0
  53. package/dist/canvas/useAnnotationCanvasState.d.ts +54 -0
  54. package/dist/canvas/useAnnotationCanvasState.js +210 -0
  55. package/dist/canvas/viewport.d.ts +16 -0
  56. package/dist/canvas/viewport.js +54 -0
  57. package/dist/data/AnnotationDataContext.d.ts +8 -0
  58. package/dist/data/AnnotationDataContext.js +11 -0
  59. package/dist/data/AnnotationDataProvider.d.ts +65 -0
  60. package/dist/data/AnnotationDataProvider.js +4 -0
  61. package/dist/data/InMemoryAnnotationProvider.d.ts +30 -0
  62. package/dist/data/InMemoryAnnotationProvider.js +197 -0
  63. package/dist/data/canvasPersistence.d.ts +3 -0
  64. package/dist/data/canvasPersistence.js +26 -0
  65. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +33 -0
  66. package/dist/data/hooks/useAnnotationCanvasDoc.js +314 -0
  67. package/dist/data/hooks/useAnnotationDoc.d.ts +7 -0
  68. package/dist/data/hooks/useAnnotationDoc.js +33 -0
  69. package/dist/data/hooks/useAnnotationList.d.ts +7 -0
  70. package/dist/data/hooks/useAnnotationList.js +26 -0
  71. package/dist/data/hooks/useAnnotationMutations.d.ts +9 -0
  72. package/dist/data/hooks/useAnnotationMutations.js +11 -0
  73. package/dist/exports.d.ts +2 -0
  74. package/dist/exports.js +2 -0
  75. package/dist/hooks/useParseMeasurement.d.ts +4 -0
  76. package/dist/hooks/useParseMeasurement.js +14 -0
  77. package/dist/types/annotation.d.ts +37 -0
  78. package/dist/types/annotation.js +30 -0
  79. package/dist/utils/evaluateFormula.d.ts +20 -0
  80. package/dist/utils/evaluateFormula.js +31 -0
  81. package/package.json +1 -1
@@ -0,0 +1,507 @@
1
+ // Pure geometry for plane calibrations (AnnotationCanvasState.planes): the
2
+ // homography that maps a marked quadrilateral onto its real-world rectangle,
3
+ // plus the derived measurement and calibration-grid helpers. Skia-free and
4
+ // dependency-free so it's safe in tools, hit-tests, and unit tests.
5
+ //
6
+ // Terminology guard: "calibration"/"scale" elsewhere in this package mean TILE
7
+ // SIZING (stampLayout.ts). Everything here is plane calibration — mapping
8
+ // DOC-space geometry to physical lengths in micrometers.
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.
14
+ import { lineLength } from './measurementGeometry.js';
15
+ // Degeneracy guards are scaled to the quad's own extent — doc coordinates are
16
+ // arbitrary (a photo canvas is typically ~1000 units across), so a fixed
17
+ // epsilon would misfire on very small or very large documents.
18
+ const relativeEps = (corners) => {
19
+ let span = 1;
20
+ for (const p of corners) {
21
+ span = Math.max(span, Math.abs(p.x), Math.abs(p.y));
22
+ }
23
+ return span * 1e-9;
24
+ };
25
+ // Heckbert's closed-form homography for the four correspondences
26
+ // (0,0)→TL, (1,0)→TR, (1,1)→BR, (0,1)→BL. Returns null for a degenerate quad
27
+ // (collinear/coincident corners). Corners must already be in TL,TR,BR,BL order
28
+ // (see normalizeQuadOrder).
29
+ export const homographyFromQuad = (corners) => {
30
+ const [p0, p1, p2, p3] = corners;
31
+ const eps = relativeEps(corners);
32
+ const sx = p0.x - p1.x + p2.x - p3.x;
33
+ const sy = p0.y - p1.y + p2.y - p3.y;
34
+ if (Math.abs(sx) < eps && Math.abs(sy) < eps) {
35
+ // Parallelogram → affine (the camera was normal to the plane).
36
+ const h = {
37
+ a: p1.x - p0.x,
38
+ b: p3.x - p0.x,
39
+ c: p0.x,
40
+ d: p1.y - p0.y,
41
+ e: p3.y - p0.y,
42
+ f: p0.y,
43
+ g: 0,
44
+ h: 0,
45
+ };
46
+ // Still reject collinear corners: an affine map with ~zero area.
47
+ return Math.abs(h.a * h.e - h.b * h.d) < eps * eps ? null : h;
48
+ }
49
+ const dx1 = p1.x - p2.x;
50
+ const dx2 = p3.x - p2.x;
51
+ const dy1 = p1.y - p2.y;
52
+ const dy2 = p3.y - p2.y;
53
+ const den = dx1 * dy2 - dy1 * dx2;
54
+ if (Math.abs(den) < eps * eps)
55
+ return null;
56
+ const g = (sx * dy2 - sy * dx2) / den;
57
+ const h = (dx1 * sy - dy1 * sx) / den;
58
+ return {
59
+ a: p1.x - p0.x + g * p1.x,
60
+ b: p3.x - p0.x + h * p3.x,
61
+ c: p0.x,
62
+ d: p1.y - p0.y + g * p1.y,
63
+ e: p3.y - p0.y + h * p3.y,
64
+ f: p0.y,
65
+ g,
66
+ h,
67
+ };
68
+ };
69
+ // A homography is scale-invariant, so its adjugate IS its inverse up to scale;
70
+ // re-normalizing the bottom-right entry to 1 restores the Homography shape.
71
+ // Returns null when that entry (a·e − b·d) is ~zero — the inverse would put
72
+ // the whole doc plane on the vanishing line.
73
+ export const invertHomography = (m) => {
74
+ const norm = m.a * m.e - m.b * m.d;
75
+ if (Math.abs(norm) < 1e-12)
76
+ return null;
77
+ return {
78
+ a: (m.e - m.f * m.h) / norm,
79
+ b: (m.c * m.h - m.b) / norm,
80
+ c: (m.b * m.f - m.c * m.e) / norm,
81
+ d: (m.f * m.g - m.d) / norm,
82
+ e: (m.a - m.c * m.g) / norm,
83
+ f: (m.c * m.d - m.a * m.f) / norm,
84
+ g: (m.d * m.h - m.e * m.g) / norm,
85
+ h: (m.b * m.g - m.a * m.h) / norm,
86
+ };
87
+ };
88
+ // Apply a homography to a point. Null when the point sits (numerically) on the
89
+ // vanishing line — the plane there is at infinity.
90
+ export const applyHomography = (m, p) => {
91
+ const w = m.g * p.x + m.h * p.y + 1;
92
+ if (Math.abs(w) < 1e-12)
93
+ return null;
94
+ return {
95
+ x: (m.a * p.x + m.b * p.y + m.c) / w,
96
+ y: (m.d * p.x + m.e * p.y + m.f) / w,
97
+ };
98
+ };
99
+ const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
100
+ // Strictly convex, non-self-intersecting, no ~zero edges: the cross products
101
+ // of consecutive edges all carry the same sign. This is what makes a marked
102
+ // quad usable as a plane (a bowtie or collinear set has no interior).
103
+ export const isQuadConvexNonDegenerate = (corners) => {
104
+ const eps = relativeEps(corners);
105
+ let sign = 0;
106
+ for (let i = 0; i < 4; i++) {
107
+ const o = corners[i];
108
+ const a = corners[(i + 1) % 4];
109
+ const b = corners[(i + 2) % 4];
110
+ if (lineLength({ a: o, b: a }) < eps)
111
+ return false;
112
+ const c = cross(o, a, b);
113
+ if (Math.abs(c) < eps * eps)
114
+ return false;
115
+ const s = c > 0 ? 1 : -1;
116
+ if (sign === 0)
117
+ sign = s;
118
+ else if (s !== sign)
119
+ return false;
120
+ }
121
+ return true;
122
+ };
123
+ // Order four tapped points into the TL,TR,BR,BL contract homographyFromQuad
124
+ // expects: sort by angle around the centroid (screen coords, y down — an
125
+ // ascending atan2 sort walks the hull clockwise on screen), then rotate the
126
+ // cycle so the corner nearest the top-left (min x+y) leads. Null when the
127
+ // points don't form a convex quad in ANY order (three collinear, a repeat...).
128
+ export const normalizeQuadOrder = (pts) => {
129
+ if (pts.length !== 4)
130
+ return null;
131
+ const cx = (pts[0].x + pts[1].x + pts[2].x + pts[3].x) / 4;
132
+ const cy = (pts[0].y + pts[1].y + pts[2].y + pts[3].y) / 4;
133
+ const sorted = [...pts].sort((p, q) => Math.atan2(p.y - cy, p.x - cx) - Math.atan2(q.y - cy, q.x - cx));
134
+ let lead = 0;
135
+ for (let i = 1; i < 4; i++) {
136
+ if (sorted[i].x + sorted[i].y < sorted[lead].x + sorted[lead].y) {
137
+ lead = i;
138
+ }
139
+ }
140
+ const ordered = [
141
+ sorted[lead],
142
+ sorted[(lead + 1) % 4],
143
+ sorted[(lead + 2) % 4],
144
+ sorted[(lead + 3) % 4],
145
+ ];
146
+ return isQuadConvexNonDegenerate(ordered) ? ordered : null;
147
+ };
148
+ // The dimensions a calibration has been given so far. Null until the user has
149
+ // entered them all (through the plane's dimension tiles) — an incomplete
150
+ // 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;
157
+ // Doc-space point → plane coordinates in micrometers (x across the rectangle's
158
+ // width, y down its height; the TL corner is the origin). Null when the
159
+ // calibration's quad is degenerate, its dimensions aren't entered yet, or the
160
+ // point is on the vanishing line.
161
+ export const docToPlaneUm = (cal, p) => {
162
+ const dims = perspectiveDims(cal);
163
+ if (!dims)
164
+ return null;
165
+ const h = homographyFromQuad(cal.corners);
166
+ if (!h)
167
+ return null;
168
+ const inv = invertHomography(h);
169
+ if (!inv)
170
+ return null;
171
+ const uv = applyHomography(inv, p);
172
+ if (!uv)
173
+ return null;
174
+ return { x: uv.x * dims.widthUm, y: uv.y * dims.heightUm };
175
+ };
176
+ // Real-world distance between two doc-space points under a calibration,
177
+ // micrometers. Scale mode is a single ratio; perspective mode measures on the
178
+ // rectified plane. Null when the calibration can't produce a finite answer
179
+ // (dimensions not entered yet, zero-length reference line, degenerate quad,
180
+ // point at the vanishing line).
181
+ export const planeDistanceUm = (cal, a, b) => {
182
+ if (cal.mode === 'scale') {
183
+ const refDoc = lineLength(cal.refLine);
184
+ const lengthUm = cal.lengthUm ?? 0;
185
+ if (!(refDoc > 0) || !(lengthUm > 0))
186
+ return null;
187
+ return (lineLength({ a, b }) * lengthUm) / refDoc;
188
+ }
189
+ const pa = docToPlaneUm(cal, a);
190
+ const pb = docToPlaneUm(cal, b);
191
+ if (!pa || !pb)
192
+ return null;
193
+ const dx = pb.x - pa.x;
194
+ const dy = pb.y - pa.y;
195
+ const d = Math.sqrt(dx * dx + dy * dy);
196
+ return Number.isFinite(d) ? d : null;
197
+ };
198
+ // ---------------------------------------------------------------------------
199
+ // Plane edges and dimension slots (the entry tiles the plane tool places).
200
+ // ---------------------------------------------------------------------------
201
+ // The doc-space line of one plane edge — the geometry a dimension tile is
202
+ // magnetized to. Null when the edge doesn't exist on the calibration's mode
203
+ // ('ref' is scale-only, the four edges are perspective-only).
204
+ export const planeEdgeLine = (cal, edge) => {
205
+ if (cal.mode === 'scale') {
206
+ return edge === 'ref' ? { a: cal.refLine.a, b: cal.refLine.b } : null;
207
+ }
208
+ const [tl, tr, br, bl] = cal.corners;
209
+ switch (edge) {
210
+ case 'top':
211
+ return { a: tl, b: tr };
212
+ case 'right':
213
+ return { a: tr, b: br };
214
+ case 'bottom':
215
+ return { a: bl, b: br };
216
+ case 'left':
217
+ return { a: tl, b: bl };
218
+ case 'ref':
219
+ return null;
220
+ }
221
+ };
222
+ // The real-world dimension (micrometers) an edge's tile displays, or null
223
+ // while it hasn't been entered. Opposite edges share a slot (top ≡ bottom →
224
+ // widthUm, left ≡ right → heightUm) — entering either sets both.
225
+ export const planeDimensionValueUm = (cal, edge) => {
226
+ if (cal.mode === 'scale') {
227
+ return edge === 'ref' && cal.lengthUm != null && cal.lengthUm > 0
228
+ ? cal.lengthUm
229
+ : null;
230
+ }
231
+ if (edge === 'top' || edge === 'bottom') {
232
+ return cal.widthUm != null && cal.widthUm > 0 ? cal.widthUm : null;
233
+ }
234
+ if (edge === 'left' || edge === 'right') {
235
+ return cal.heightUm != null && cal.heightUm > 0 ? cal.heightUm : null;
236
+ }
237
+ return null;
238
+ };
239
+ // The calibration with one edge's dimension set — what a host commits
240
+ // (setPlane) when a value lands in a dimension tile. Ignores nonsensical
241
+ // input (edge/mode mismatch, non-positive value) by returning the calibration
242
+ // unchanged.
243
+ export const withPlaneDimensionUm = (cal, edge, um) => {
244
+ if (!(um > 0) || !Number.isFinite(um))
245
+ return cal;
246
+ if (cal.mode === 'scale') {
247
+ return edge === 'ref' ? { ...cal, lengthUm: um } : cal;
248
+ }
249
+ if (edge === 'top' || edge === 'bottom')
250
+ return { ...cal, widthUm: um };
251
+ if (edge === 'left' || edge === 'right')
252
+ return { ...cal, heightUm: um };
253
+ return cal;
254
+ };
255
+ // The computed value (micrometers) a plane-sourced line tile should display,
256
+ // or null when it shouldn't (not plane-sourced, no line, no/broken
257
+ // calibration → the host falls back to its usual empty-tile rendering).
258
+ // v1 resolves against the document's single calibration, planes[0].
259
+ export const planeLengthForPlaced = (planes, placed) => {
260
+ if (placed.valueSource !== 'plane')
261
+ return null;
262
+ if (placed.placement !== 'line' || !placed.line)
263
+ return null;
264
+ const cal = planes?.[0];
265
+ if (!cal)
266
+ return null;
267
+ return planeDistanceUm(cal, placed.line.a, placed.line.b);
268
+ };
269
+ const MM_UM = 1000;
270
+ const IN_UM = 25400;
271
+ // Minimum cell count the auto spacing targets along the calibrated extent's
272
+ // long axis (the chosen rung is the largest that still yields this many).
273
+ const GRID_MIN_CELLS = 8;
274
+ // Cap on grid lines per direction. A user-entered spacing that's tiny relative
275
+ // to the photo would otherwise emit thousands of segments; the generators
276
+ // double the spacing until they fit, keeping the overlay drawable.
277
+ const GRID_MAX_LINES = 160;
278
+ // The largest "nice" spacing that divides `extentUm` into at least
279
+ // GRID_MIN_CELLS cells: metric walks {1,2,5}×10ⁿ mm; imperial walks
280
+ // {1/8, 1/4, 1/2, 1, 2, 3, 6, 12, 24, 48} in, then keeps doubling (4ft, 8ft…).
281
+ export const nicePlaneGridSpacingUm = (extentUm, system) => {
282
+ const max = extentUm / GRID_MIN_CELLS;
283
+ if (system === 'imperial') {
284
+ const rungs = [0.125, 0.25, 0.5, 1, 2, 3, 6, 12, 24, 48];
285
+ let best = rungs[0] * IN_UM;
286
+ for (const r of rungs) {
287
+ if (r * IN_UM <= max)
288
+ best = r * IN_UM;
289
+ }
290
+ let doubled = 48 * IN_UM * 2;
291
+ while (doubled <= max) {
292
+ best = doubled;
293
+ doubled *= 2;
294
+ }
295
+ return best;
296
+ }
297
+ let best = 0.1 * MM_UM;
298
+ for (let pow = 0.1 * MM_UM; pow <= max; pow *= 10) {
299
+ for (const m of [1, 2, 5]) {
300
+ if (pow * m <= max)
301
+ best = pow * m;
302
+ }
303
+ }
304
+ return best;
305
+ };
306
+ // Grid spacing a calibration should render at: the stored override, else the
307
+ // auto spacing for its extent. Null while the calibration's dimensions
308
+ // haven't been entered (nothing meaningful to grid). Scale mode has no
309
+ // intrinsic extent — its grid covers the photo — so callers pass the covered
310
+ // doc rect and the extent is that rect converted to micrometers.
311
+ export const resolvePlaneGridSpacingUm = (cal, system, scaleBounds) => {
312
+ if (cal.gridSpacingUm && cal.gridSpacingUm > 0)
313
+ return cal.gridSpacingUm;
314
+ if (cal.mode === 'perspective') {
315
+ const dims = perspectiveDims(cal);
316
+ if (!dims)
317
+ return null;
318
+ return nicePlaneGridSpacingUm(Math.max(dims.widthUm, dims.heightUm), system);
319
+ }
320
+ const refDoc = lineLength(cal.refLine);
321
+ const lengthUm = cal.lengthUm ?? 0;
322
+ if (!scaleBounds || !(refDoc > 0) || !(lengthUm > 0))
323
+ return null;
324
+ const umPerDoc = lengthUm / refDoc;
325
+ const extentUm = Math.max(scaleBounds.width, scaleBounds.height) * umPerDoc;
326
+ return extentUm > 0 ? nicePlaneGridSpacingUm(extentUm, system) : null;
327
+ };
328
+ // How far past the marked quad the grid extends, as a multiple of the quad's
329
+ // own plane-space size, when the photo's plane-space footprint can't be
330
+ // derived (its corner sits at/behind the vanishing line). Also the hard cap
331
+ // on how far it ever extends — a photo grazing the horizon would otherwise
332
+ // ask for a near-infinite plane extent.
333
+ const GRID_EXTENT_CAP = 12;
334
+ // Liang–Barsky segment/rect clip, DOC space. Null when fully outside.
335
+ const clipSegmentToRect = (a, b, rect) => {
336
+ const dx = b.x - a.x;
337
+ const dy = b.y - a.y;
338
+ let t0 = 0;
339
+ let t1 = 1;
340
+ const edges = [
341
+ [-dx, a.x - rect.x],
342
+ [dx, rect.x + rect.width - a.x],
343
+ [-dy, a.y - rect.y],
344
+ [dy, rect.y + rect.height - a.y],
345
+ ];
346
+ for (const [p, q] of edges) {
347
+ if (p === 0) {
348
+ if (q < 0)
349
+ return null;
350
+ continue;
351
+ }
352
+ const r = q / p;
353
+ if (p < 0) {
354
+ if (r > t1)
355
+ return null;
356
+ if (r > t0)
357
+ t0 = r;
358
+ }
359
+ else {
360
+ if (r < t0)
361
+ return null;
362
+ if (r < t1)
363
+ t1 = r;
364
+ }
365
+ }
366
+ if (t0 >= t1)
367
+ return null;
368
+ return {
369
+ a: { x: a.x + t0 * dx, y: a.y + t0 * dy },
370
+ b: { x: a.x + t1 * dx, y: a.y + t1 * dy },
371
+ };
372
+ };
373
+ // Grid lines of a perspective calibration, DOC space, spanning the whole
374
+ // photo (`bounds` — the first background layer's doc rect, or the document
375
+ // rect): the plane's grid continues past the marked quad across everything
376
+ // coplanar with it. Straight lines stay straight under a homography, so each
377
+ // line is two endpoints mapped and then clipped to the photo.
378
+ //
379
+ // The plane-space region to grid comes from pulling the photo's corners back
380
+ // through the inverse homography. Near the plane's horizon (the vanishing
381
+ // line) plane coordinates diverge, so the region is clamped to
382
+ // GRID_EXTENT_CAP× the quad's size — and when a photo corner sits at/behind
383
+ // the horizon (its pullback fails or lands behind the camera, w ≤ 0), the
384
+ // full clamped region is used instead. Lines whose endpoints stray onto the
385
+ // far side of the horizon are dropped (w stays positive across a segment
386
+ // only when it's positive at both ends — w is affine on the plane).
387
+ export const planeGridSegments = (cal, spacingUm, bounds) => {
388
+ const dims = perspectiveDims(cal);
389
+ const h = homographyFromQuad(cal.corners);
390
+ if (!dims ||
391
+ !h ||
392
+ !(spacingUm > 0) ||
393
+ !(bounds.width > 0) ||
394
+ !(bounds.height > 0)) {
395
+ return [];
396
+ }
397
+ const inv = invertHomography(h);
398
+ if (!inv)
399
+ return [];
400
+ // Plane-space (unit-quad uv) region covering the photo, clamped.
401
+ let minU = -GRID_EXTENT_CAP;
402
+ let maxU = 1 + GRID_EXTENT_CAP;
403
+ let minV = -GRID_EXTENT_CAP;
404
+ let maxV = 1 + GRID_EXTENT_CAP;
405
+ const photoCorners = [
406
+ { x: bounds.x, y: bounds.y },
407
+ { x: bounds.x + bounds.width, y: bounds.y },
408
+ { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
409
+ { x: bounds.x, y: bounds.y + bounds.height },
410
+ ];
411
+ let u0 = Infinity;
412
+ let u1 = -Infinity;
413
+ let v0 = Infinity;
414
+ let v1 = -Infinity;
415
+ let allMapped = true;
416
+ for (const c of photoCorners) {
417
+ const uv = applyHomography(inv, c);
418
+ // Reject pullbacks behind the camera: the corner maps to a plane point
419
+ // whose FORWARD image (w) is non-positive — the far side of the horizon.
420
+ if (!uv || h.g * uv.x + h.h * uv.y + 1 <= 1e-9) {
421
+ allMapped = false;
422
+ break;
423
+ }
424
+ u0 = Math.min(u0, uv.x);
425
+ u1 = Math.max(u1, uv.x);
426
+ v0 = Math.min(v0, uv.y);
427
+ v1 = Math.max(v1, uv.y);
428
+ }
429
+ if (allMapped) {
430
+ minU = Math.max(minU, u0);
431
+ maxU = Math.min(maxU, u1);
432
+ minV = Math.max(minV, v0);
433
+ maxV = Math.min(maxV, v1);
434
+ }
435
+ // Always cover at least the quad itself.
436
+ minU = Math.min(minU, 0);
437
+ maxU = Math.max(maxU, 1);
438
+ minV = Math.min(minV, 0);
439
+ maxV = Math.max(maxV, 1);
440
+ // Spacing in uv units per axis; doubled until the line count fits the cap.
441
+ let s = spacingUm;
442
+ const lineCount = (step) => (maxU - minU) / (step / dims.widthUm) +
443
+ (maxV - minV) / (step / dims.heightUm);
444
+ while (lineCount(s) > 2 * GRID_MAX_LINES && Number.isFinite(s)) {
445
+ s *= 2;
446
+ }
447
+ const segments = [];
448
+ const pushLine = (uvA, uvB) => {
449
+ // Both endpoints must sit on the camera side of the horizon (w > 0);
450
+ // w is affine over the plane, so the whole segment then is too.
451
+ if (h.g * uvA.x + h.h * uvA.y + 1 <= 1e-9)
452
+ return;
453
+ if (h.g * uvB.x + h.h * uvB.y + 1 <= 1e-9)
454
+ return;
455
+ const a = applyHomography(h, uvA);
456
+ const b = applyHomography(h, uvB);
457
+ if (!a || !b)
458
+ return;
459
+ const clipped = clipSegmentToRect(a, b, bounds);
460
+ if (clipped)
461
+ segments.push(clipped);
462
+ };
463
+ const stepU = s / dims.widthUm;
464
+ const stepV = s / dims.heightUm;
465
+ for (let k = Math.ceil(minU / stepU); k * stepU <= maxU; k++) {
466
+ const u = k * stepU;
467
+ pushLine({ x: u, y: minV }, { x: u, y: maxV });
468
+ }
469
+ for (let k = Math.ceil(minV / stepV); k * stepV <= maxV; k++) {
470
+ const v = k * stepV;
471
+ pushLine({ x: minU, y: v }, { x: maxU, y: v });
472
+ }
473
+ return segments;
474
+ };
475
+ // Axis-aligned grid of a scale calibration across `bounds` (the photo's doc
476
+ // rect, or the document rect when there's no background), DOC space. Lines
477
+ // anchor at the bounds origin.
478
+ export const planeGridSegmentsForScale = (cal, spacingUm, bounds) => {
479
+ const refDoc = lineLength(cal.refLine);
480
+ const lengthUm = cal.lengthUm ?? 0;
481
+ if (!(refDoc > 0) ||
482
+ !(lengthUm > 0) ||
483
+ !(spacingUm > 0) ||
484
+ !(bounds.width > 0) ||
485
+ !(bounds.height > 0)) {
486
+ return [];
487
+ }
488
+ let spacingDoc = (spacingUm * refDoc) / lengthUm;
489
+ while (Math.max(bounds.width, bounds.height) / spacingDoc > GRID_MAX_LINES &&
490
+ Number.isFinite(spacingDoc)) {
491
+ spacingDoc *= 2;
492
+ }
493
+ const segments = [];
494
+ for (let x = bounds.x + spacingDoc; x < bounds.x + bounds.width; x += spacingDoc) {
495
+ segments.push({
496
+ a: { x, y: bounds.y },
497
+ b: { x, y: bounds.y + bounds.height },
498
+ });
499
+ }
500
+ for (let y = bounds.y + spacingDoc; y < bounds.y + bounds.height; y += spacingDoc) {
501
+ segments.push({
502
+ a: { x: bounds.x, y },
503
+ b: { x: bounds.x + bounds.width, y },
504
+ });
505
+ }
506
+ return segments;
507
+ };
@@ -11,6 +11,7 @@ export interface MeasurementToolOptions {
11
11
  selectToolId?: string;
12
12
  onAutoSwitch?: (toToolId: string) => void;
13
13
  onPlaced?: (measurement: PlacedMeasurementRef) => void;
14
+ planeComputedValues?: boolean;
14
15
  color?: string;
15
16
  width?: number;
16
17
  cap?: StrokeCap;
@@ -38,6 +38,9 @@ const buildMeasurement = (opts) => {
38
38
  showValue: true,
39
39
  createdAt: Date.now(),
40
40
  ...lineStyleFields(placement, style),
41
+ ...(opts.valueSource !== undefined && placement === 'line'
42
+ ? { valueSource: opts.valueSource }
43
+ : {}),
41
44
  };
42
45
  if (placement === 'rectangle') {
43
46
  const rect = { a, b };
@@ -81,6 +84,14 @@ export const createMeasurementTool = (options = {}) => {
81
84
  startCap: options.startCap,
82
85
  dash: options.dash,
83
86
  };
87
+ // 'plane' when this line should display a calibration-computed value:
88
+ // opted in via planeComputedValues AND the live document actually carries a
89
+ // calibration at draw time.
90
+ const valueSourceFor = (ctx) => options.planeComputedValues &&
91
+ placement === 'line' &&
92
+ (ctx.document.planes?.length ?? 0) > 0
93
+ ? 'plane'
94
+ : undefined;
84
95
  const place = (ctx, measurement) => {
85
96
  ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
86
97
  options.onPlaced?.(measurement);
@@ -140,6 +151,7 @@ export const createMeasurementTool = (options = {}) => {
140
151
  a: s.startWorld,
141
152
  b: event.world,
142
153
  style,
154
+ valueSource: valueSourceFor(ctx),
143
155
  });
144
156
  ctx.preview({ ops: [{ op: 'addMeasurement', measurement }] });
145
157
  return { ...s, moved: true };
@@ -163,6 +175,7 @@ export const createMeasurementTool = (options = {}) => {
163
175
  a: s.startWorld,
164
176
  b: event.world,
165
177
  style,
178
+ valueSource: valueSourceFor(ctx),
166
179
  }));
167
180
  },
168
181
  onCancel(_state, ctx) {
@@ -0,0 +1,13 @@
1
+ import type { PlacedMeasurementRef, PlaneCalibration, PlaneCalibrationMode } from '../../../types/annotation.js';
2
+ import type { Tool } from '../Tool.js';
3
+ export interface PlaneToolOptions {
4
+ id?: string;
5
+ label?: string;
6
+ mode: PlaneCalibrationMode;
7
+ onPlaced?(plane: PlaneCalibration): void;
8
+ onInvalidQuad?(): void;
9
+ onExistingPlane?(): void;
10
+ onDimensionTileTap?(tile: PlacedMeasurementRef): void;
11
+ minDragPx?: number;
12
+ }
13
+ export declare const createPlaneTool: (options: PlaneToolOptions) => Tool;