@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.
- package/dist/annotation/canvas/AnnotationCanvasInner.js +5 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +10 -3
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +2 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +17 -3
- package/dist/annotation/canvas/Tool.d.ts +1 -0
- package/dist/annotation/canvas/elements/PlaneElement.d.ts +18 -0
- package/dist/annotation/canvas/elements/PlaneElement.js +115 -0
- package/dist/annotation/canvas/planeGeometry.d.ts +48 -0
- package/dist/annotation/canvas/planeGeometry.js +507 -0
- package/dist/annotation/canvas/tools/measurementTool.d.ts +1 -0
- package/dist/annotation/canvas/tools/measurementTool.js +13 -0
- package/dist/annotation/canvas/tools/planeTool.d.ts +13 -0
- package/dist/annotation/canvas/tools/planeTool.js +391 -0
- package/dist/annotation/canvas/tools/selectTool.js +9 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +3 -1
- package/dist/annotation/canvas/useAnnotationCanvasState.js +22 -1
- package/dist/canvas/AnnotationCanvas.d.ts +11 -0
- package/dist/canvas/AnnotationCanvas.js +10 -0
- package/dist/canvas/AnnotationCanvas.native.d.ts +8 -0
- package/dist/canvas/AnnotationCanvas.native.js +6 -0
- package/dist/canvas/AnnotationCanvasInner.d.ts +39 -0
- package/dist/canvas/AnnotationCanvasInner.js +219 -0
- package/dist/canvas/AnnotationCanvasInner.native.d.ts +35 -0
- package/dist/canvas/AnnotationCanvasInner.native.js +138 -0
- package/dist/canvas/AnnotationCanvasSkia.d.ts +27 -0
- package/dist/canvas/AnnotationCanvasSkia.js +20 -0
- package/dist/canvas/Tool.d.ts +38 -0
- package/dist/canvas/Tool.js +1 -0
- package/dist/canvas/elements/BackgroundImageElement.d.ts +9 -0
- package/dist/canvas/elements/BackgroundImageElement.js +37 -0
- package/dist/canvas/elements/MeasurementStampElement.d.ts +13 -0
- package/dist/canvas/elements/MeasurementStampElement.js +30 -0
- package/dist/canvas/elements/ShapeElement.d.ts +7 -0
- package/dist/canvas/elements/ShapeElement.js +62 -0
- package/dist/canvas/elements/StrokeElement.d.ts +7 -0
- package/dist/canvas/elements/StrokeElement.js +18 -0
- package/dist/canvas/measurementPicker.d.ts +10 -0
- package/dist/canvas/measurementPicker.js +1 -0
- package/dist/canvas/measurementStampOverlay.d.ts +11 -0
- package/dist/canvas/measurementStampOverlay.js +1 -0
- package/dist/canvas/pointerAdapter.d.ts +3 -0
- package/dist/canvas/pointerAdapter.js +19 -0
- package/dist/canvas/stampLayout.d.ts +5 -0
- package/dist/canvas/stampLayout.js +14 -0
- package/dist/canvas/tools/measurementStampTool.d.ts +9 -0
- package/dist/canvas/tools/measurementStampTool.js +37 -0
- package/dist/canvas/tools/panTool.d.ts +5 -0
- package/dist/canvas/tools/panTool.js +25 -0
- package/dist/canvas/tools/penTool.d.ts +13 -0
- package/dist/canvas/tools/penTool.js +68 -0
- package/dist/canvas/tools/selectTool.d.ts +2 -0
- package/dist/canvas/tools/selectTool.js +182 -0
- package/dist/canvas/useAnnotationCanvasState.d.ts +54 -0
- package/dist/canvas/useAnnotationCanvasState.js +210 -0
- package/dist/canvas/viewport.d.ts +16 -0
- package/dist/canvas/viewport.js +54 -0
- package/dist/data/AnnotationDataContext.d.ts +8 -0
- package/dist/data/AnnotationDataContext.js +11 -0
- package/dist/data/AnnotationDataProvider.d.ts +65 -0
- package/dist/data/AnnotationDataProvider.js +4 -0
- package/dist/data/InMemoryAnnotationProvider.d.ts +30 -0
- package/dist/data/InMemoryAnnotationProvider.js +197 -0
- package/dist/data/canvasPersistence.d.ts +3 -0
- package/dist/data/canvasPersistence.js +26 -0
- package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +33 -0
- package/dist/data/hooks/useAnnotationCanvasDoc.js +314 -0
- package/dist/data/hooks/useAnnotationDoc.d.ts +7 -0
- package/dist/data/hooks/useAnnotationDoc.js +33 -0
- package/dist/data/hooks/useAnnotationList.d.ts +7 -0
- package/dist/data/hooks/useAnnotationList.js +26 -0
- package/dist/data/hooks/useAnnotationMutations.d.ts +9 -0
- package/dist/data/hooks/useAnnotationMutations.js +11 -0
- package/dist/exports.d.ts +2 -0
- package/dist/exports.js +2 -0
- package/dist/hooks/useParseMeasurement.d.ts +4 -0
- package/dist/hooks/useParseMeasurement.js +14 -0
- package/dist/types/annotation.d.ts +37 -0
- package/dist/types/annotation.js +30 -0
- package/dist/utils/evaluateFormula.d.ts +20 -0
- package/dist/utils/evaluateFormula.js +31 -0
- 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;
|