@weasel-js/labkit 1.7.0 → 1.7.1

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 (208) hide show
  1. package/README.md +3 -3
  2. package/dist/_dts/{CanvasStackContext-D2jnIwfk.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
  3. package/dist/_dts/{Stage-CCWfQQWM.d.ts → Stage-D6t7o0pF.d.ts} +3 -2
  4. package/dist/_dts/{index-CsYqUHD3.d.ts → index-BTmaRy6a.d.ts} +10 -6
  5. package/dist/_dts/{types-CSm9jgol.d.ts → index-DCMA4Fy1.d.ts} +59 -113
  6. package/dist/_dts/{types-CRPSGFAp.d.ts → types-BUBSIZuV.d.ts} +8 -7
  7. package/dist/_dts/{useTrialState-CTZPgeZ7.d.ts → useTrialState-Bh8bzFgE.d.ts} +1 -1
  8. package/dist/canvas/index.d.ts +8 -6
  9. package/dist/canvas/index.js +6 -6
  10. package/dist/chrome/index.d.ts +7 -5
  11. package/dist/chrome/index.js +10 -9
  12. package/dist/chunk-2ES4W47N.js +8 -0
  13. package/dist/chunk-2ES4W47N.js.map +1 -0
  14. package/dist/{chunk-WPLAANH4.js → chunk-2N7UBSS2.js} +12 -12
  15. package/dist/chunk-2N7UBSS2.js.map +1 -0
  16. package/dist/chunk-4KSC7NXL.js +15491 -0
  17. package/dist/chunk-4KSC7NXL.js.map +1 -0
  18. package/dist/{chunk-7BO7HNGF.js → chunk-CEGFXTBD.js} +8 -61
  19. package/dist/chunk-CEGFXTBD.js.map +1 -0
  20. package/dist/{chunk-275JRRVD.js → chunk-E23Q6A3Y.js} +232 -37
  21. package/dist/chunk-E23Q6A3Y.js.map +1 -0
  22. package/dist/{chunk-GOZLNM4L.js → chunk-E6NARGU7.js} +5 -5
  23. package/dist/{chunk-GOZLNM4L.js.map → chunk-E6NARGU7.js.map} +1 -1
  24. package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
  25. package/dist/chunk-EESJUSYR.js.map +1 -0
  26. package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
  27. package/dist/chunk-EFXRM6XQ.js.map +1 -0
  28. package/dist/chunk-F4ACIK7Z.js +210 -0
  29. package/dist/chunk-F4ACIK7Z.js.map +1 -0
  30. package/dist/{chunk-FMK7UQ6V.js → chunk-FB4MIZOO.js} +61 -44
  31. package/dist/chunk-FB4MIZOO.js.map +1 -0
  32. package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
  33. package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
  34. package/dist/{chunk-I5DUQ5HM.js → chunk-GAHELLJT.js} +75 -72
  35. package/dist/chunk-GAHELLJT.js.map +1 -0
  36. package/dist/{chunk-YT75VYRD.js → chunk-IBVRMHW5.js} +5 -5
  37. package/dist/{chunk-YT75VYRD.js.map → chunk-IBVRMHW5.js.map} +1 -1
  38. package/dist/{chunk-T5IBSPAM.js → chunk-JFOJESJF.js} +33 -23
  39. package/dist/chunk-JFOJESJF.js.map +1 -0
  40. package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
  41. package/dist/chunk-PMLZP7RT.js.map +1 -0
  42. package/dist/{chunk-Y23G2VZL.js → chunk-READTDQX.js} +2 -2
  43. package/dist/chunk-READTDQX.js.map +1 -0
  44. package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
  45. package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
  46. package/dist/{chunk-ZIFPHIWZ.js → chunk-T6DLGEJB.js} +12 -14
  47. package/dist/chunk-T6DLGEJB.js.map +1 -0
  48. package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
  49. package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
  50. package/dist/{chunk-UNAZSLWE.js → chunk-VYVGVBGY.js} +17 -20
  51. package/dist/chunk-VYVGVBGY.js.map +1 -0
  52. package/dist/{chunk-KMJ7RWD3.js → chunk-WLMDTI2Q.js} +3 -3
  53. package/dist/{chunk-KMJ7RWD3.js.map → chunk-WLMDTI2Q.js.map} +1 -1
  54. package/dist/{chunk-J7OWWTCW.js → chunk-XE5HJOPL.js} +5 -6
  55. package/dist/chunk-XE5HJOPL.js.map +1 -0
  56. package/dist/chunk-XVUKDSYV.js +38 -0
  57. package/dist/chunk-XVUKDSYV.js.map +1 -0
  58. package/dist/{chunk-FZ7YFQN7.js → chunk-YCFKW5RX.js} +3 -3
  59. package/dist/chunk-YCFKW5RX.js.map +1 -0
  60. package/dist/{chunk-CQLQPQ4P.js → chunk-ZQIHJXP4.js} +2 -2
  61. package/dist/chunk-ZQIHJXP4.js.map +1 -0
  62. package/dist/config/index.d.ts +18 -10
  63. package/dist/config/index.js +4 -4
  64. package/dist/controls/index.d.ts +4 -7
  65. package/dist/controls/index.js +6 -5
  66. package/dist/dragdrop/index.d.ts +23 -3
  67. package/dist/dragdrop/index.js +4 -3
  68. package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
  69. package/dist/index.d.ts +60 -714
  70. package/dist/index.js +357 -334
  71. package/dist/index.js.map +1 -1
  72. package/dist/job/index.d.ts +8 -0
  73. package/dist/job/index.js +1 -1
  74. package/dist/layers/index.d.ts +6 -5
  75. package/dist/layers/index.js +3 -2
  76. package/dist/loupe/index.d.ts +100 -30
  77. package/dist/loupe/index.js +585 -6
  78. package/dist/loupe/index.js.map +1 -1
  79. package/dist/overview/index.d.ts +4 -2
  80. package/dist/overview/index.js +15 -14
  81. package/dist/overview/index.js.map +1 -1
  82. package/dist/passthrough/weasel-canvas.d.ts +2 -0
  83. package/dist/passthrough/weasel-ui.d.ts +2 -2871
  84. package/dist/passthrough/weasel-ui.js +3 -2
  85. package/dist/primitives/index.d.ts +213 -3
  86. package/dist/primitives/index.js +8 -7
  87. package/dist/state/index.d.ts +7 -5
  88. package/dist/state/index.js +6 -6
  89. package/dist/styles.css +60 -44
  90. package/dist/surface/index.d.ts +10 -2
  91. package/dist/surface/index.js +1 -1
  92. package/dist/undo/index.d.ts +5 -3
  93. package/package.json +11 -11
  94. package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
  95. package/src/annotations/AnnotationOverlay.tsx +50 -31
  96. package/src/annotations/Annotations.overlay.test.tsx +31 -0
  97. package/src/annotations/capture.browser.test.ts +41 -0
  98. package/src/annotations/capture.ts +14 -7
  99. package/src/annotations/drawOne.ts +2 -2
  100. package/src/annotations/frac.test.ts +44 -1
  101. package/src/annotations/frac.ts +46 -18
  102. package/src/annotations/index.ts +1 -0
  103. package/src/annotations/paint.test.ts +74 -33
  104. package/src/annotations/paint.ts +70 -32
  105. package/src/annotations/store.test.ts +212 -2
  106. package/src/annotations/store.ts +130 -27
  107. package/src/annotations/svgNodes.test.ts +22 -10
  108. package/src/annotations/svgNodes.ts +3 -3
  109. package/src/annotations/types.ts +29 -10
  110. package/src/canvas/CameraInput.test.tsx +30 -0
  111. package/src/canvas/CameraInput.tsx +16 -15
  112. package/src/canvas/CanvasStack.tsx +5 -5
  113. package/src/canvas/CanvasStackContext.ts +4 -1
  114. package/src/canvas/Stage.tsx +3 -3
  115. package/src/canvas/cameraRegistry.ts +1 -0
  116. package/src/canvas/worldSpec.ts +3 -1
  117. package/src/chrome/builtins.test.ts +7 -6
  118. package/src/chrome/builtins.tsx +5 -1
  119. package/src/chrome/index.ts +1 -1
  120. package/src/chrome/merge.test.ts +5 -0
  121. package/src/chrome/merge.ts +4 -2
  122. package/src/chrome/regions/PaletteRegion.test.tsx +2 -3
  123. package/src/chrome/regions/PaletteRegion.tsx +3 -4
  124. package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
  125. package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
  126. package/src/chrome/regions/ViewportRegion.less +6 -0
  127. package/src/chrome/regions/ViewportRegion.tsx +10 -4
  128. package/src/chrome/types.ts +4 -2
  129. package/src/config/auto.ts +1 -0
  130. package/src/config/builder.ts +6 -0
  131. package/src/config/types.ts +2 -1
  132. package/src/controls/ControlPanel.test.tsx +37 -3
  133. package/src/controls/ControlPanel.tsx +12 -3
  134. package/src/dragdrop/DragDropRuntime.tsx +22 -13
  135. package/src/dragdrop/DragGhost.tsx +3 -0
  136. package/src/dragdrop/Palette.tsx +2 -0
  137. package/src/index.test.ts +5 -0
  138. package/src/index.ts +1 -1
  139. package/src/instrument/types.ts +0 -6
  140. package/src/job/useJob.ts +14 -9
  141. package/src/lab/Lab.tsx +5 -1
  142. package/src/lab/LabShell.less +1 -0
  143. package/src/lab/LabSwitcher.browser.test.tsx +27 -0
  144. package/src/lab/LabSwitcher.less +2 -0
  145. package/src/lab/LabZoom.tsx +4 -4
  146. package/src/lab/Workspace.tsx +48 -52
  147. package/src/loupe/AGENTS.md +28 -15
  148. package/src/loupe/CanvasLoupe.tsx +2 -3
  149. package/src/loupe/DomLoupe.tsx +1 -7
  150. package/src/loupe/Loupe.less +16 -0
  151. package/src/loupe/TrialLoupe.tsx +120 -57
  152. package/src/loupe/index.ts +1 -6
  153. package/src/loupe/types.test.ts +3 -3
  154. package/src/loupe/types.ts +24 -27
  155. package/src/loupe/useLoupe.test.tsx +29 -4
  156. package/src/loupe/useLoupe.ts +20 -17
  157. package/src/overview/OverviewMarks.tsx +12 -4
  158. package/src/overview/TrialOverview.tsx +3 -3
  159. package/src/passthrough/weasel-ui.test.ts +1 -1
  160. package/src/passthrough/weasel-ui.ts +1 -1
  161. package/src/primitives/FloatingPanel.tsx +7 -3
  162. package/src/primitives/JobProgress.less +1 -0
  163. package/src/primitives/Readout.test.tsx +2 -2
  164. package/src/primitives/Split.tsx +69 -41
  165. package/src/primitives/Toolbar.tsx +3 -3
  166. package/src/primitives/ZoomControl.less +1 -2
  167. package/src/specimen/Specimen.tsx +0 -1
  168. package/src/state/undock.ts +3 -0
  169. package/src/state/useOpenOnce.ts +20 -16
  170. package/src/state/view.ts +1 -0
  171. package/src/surface/index.ts +1 -1
  172. package/src/surface/useTiledSurface.test.tsx +14 -0
  173. package/src/surface/useTiledSurface.ts +11 -3
  174. package/src/trial/Trial.annotations.test.tsx +73 -0
  175. package/src/trial/Trial.less +0 -16
  176. package/src/trial/Trial.loupe.test.tsx +28 -11
  177. package/src/trial/Trial.tsx +74 -62
  178. package/src/trial/TrialChrome.tsx +6 -3
  179. package/src/trial/index.ts +2 -0
  180. package/src/trial/loupeSwitch.ts +15 -0
  181. package/dist/_dts/LayerList.d-DiuEArKq.d.ts +0 -83
  182. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +0 -79
  183. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +0 -141
  184. package/dist/_dts/Select.d-Bensr_MT.d.ts +0 -579
  185. package/dist/_dts/index-B2Yj5aA0.d.ts +0 -274
  186. package/dist/chunk-275JRRVD.js.map +0 -1
  187. package/dist/chunk-4LJSC7MB.js +0 -143
  188. package/dist/chunk-4LJSC7MB.js.map +0 -1
  189. package/dist/chunk-7BO7HNGF.js.map +0 -1
  190. package/dist/chunk-CQLQPQ4P.js.map +0 -1
  191. package/dist/chunk-FMK7UQ6V.js.map +0 -1
  192. package/dist/chunk-FZ7YFQN7.js.map +0 -1
  193. package/dist/chunk-HXXTULDN.js.map +0 -1
  194. package/dist/chunk-I5DUQ5HM.js.map +0 -1
  195. package/dist/chunk-IJJUK4FU.js.map +0 -1
  196. package/dist/chunk-J7OWWTCW.js.map +0 -1
  197. package/dist/chunk-M7HP3FTK.js +0 -549
  198. package/dist/chunk-M7HP3FTK.js.map +0 -1
  199. package/dist/chunk-MIM5R4ZT.js +0 -14972
  200. package/dist/chunk-MIM5R4ZT.js.map +0 -1
  201. package/dist/chunk-RWNDDKOH.js.map +0 -1
  202. package/dist/chunk-T5IBSPAM.js.map +0 -1
  203. package/dist/chunk-UNAZSLWE.js.map +0 -1
  204. package/dist/chunk-WPLAANH4.js.map +0 -1
  205. package/dist/chunk-Y23G2VZL.js.map +0 -1
  206. package/dist/chunk-ZIFPHIWZ.js.map +0 -1
  207. package/src/primitives/useRovingTabIndex.test.ts +0 -48
  208. package/src/primitives/useRovingTabIndex.ts +0 -88
@@ -1,13 +1,17 @@
1
+ import {
2
+ type Box,
3
+ boundsOfCoords,
4
+ boxContainsBox,
5
+ boxContainsPoint,
6
+ type Rect,
7
+ } from '@weasel-js/geom';
1
8
  import type { FracPoint, FracRect } from './types';
2
9
 
3
10
  /** A mark's box in its target's world: CSS pixels at zoom 1. Matches weasel's
4
11
  * default `RectPose`, which is what the scene stores. */
5
- export interface WorldRect {
6
- x: number;
7
- y: number;
8
- width: number;
9
- height: number;
10
- }
12
+ export type WorldRect = Rect;
13
+
14
+ const boxOfFrac = (f: FracRect): Box => [f.x, f.y, f.x + f.w, f.y + f.h];
11
15
 
12
16
  /** Where a fraction lands on a content box of this size. */
13
17
  export function fracToWorld(f: FracRect, content: { w: number; h: number }): WorldRect {
@@ -46,22 +50,46 @@ export function roundFrac(f: FracRect): FracRect {
46
50
  /** Whether `pt` falls in `box`, widened by `tol` on every side so a hairline
47
51
  * mark stays reachable. */
48
52
  export function fracContains(box: FracRect, pt: FracPoint, tol = 0): boolean {
49
- return (
50
- pt.x >= box.x - tol &&
51
- pt.x <= box.x + box.w + tol &&
52
- pt.y >= box.y - tol &&
53
- pt.y <= box.y + box.h + tol
54
- );
53
+ const [x0, y0, x1, y1] = boxOfFrac(box);
54
+ return boxContainsPoint([x0 - tol, y0 - tol, x1 + tol, y1 + tol], pt.x, pt.y);
55
55
  }
56
56
 
57
57
  /** Whether `outer` wholly encloses `inner`. A marquee takes what it encloses,
58
58
  * not what it grazes — brushing selection is a different gesture, and this
59
59
  * answers false for two rects that merely overlap. */
60
60
  export function fracEncloses(outer: FracRect, inner: FracRect): boolean {
61
- return (
62
- inner.x >= outer.x &&
63
- inner.y >= outer.y &&
64
- inner.x + inner.w <= outer.x + outer.w &&
65
- inner.y + inner.h <= outer.y + outer.h
66
- );
61
+ return boxContainsBox(boxOfFrac(outer), boxOfFrac(inner));
62
+ }
63
+
64
+ /** The smallest box holding every point — zero-size for a single one, and
65
+ * `null` for none. */
66
+ export function boundsOf(points: readonly FracPoint[]): FracRect | null {
67
+ const b = boundsOfCoords(points.flatMap((p) => [p.x, p.y]));
68
+ return b && { x: b[0], y: b[1], w: b[2] - b[0], h: b[3] - b[1] };
69
+ }
70
+
71
+ /** Points restated as fractions of `box` itself, so they move and stretch with
72
+ * it. Unit-agnostic: `points` and `box` only have to share one. A box with no
73
+ * width or height puts every point on its edge along that axis. */
74
+ export function toShape(points: readonly FracPoint[], box: FracRect): FracPoint[] {
75
+ return points.map((p) => ({
76
+ x: box.w === 0 ? 0 : (p.x - box.x) / box.w,
77
+ y: box.h === 0 ? 0 : (p.y - box.y) / box.h,
78
+ }));
67
79
  }
80
+
81
+ /** `toShape`'s inverse: a shape placed in `box`, in `box`'s units. */
82
+ export function fromShape(shape: readonly FracPoint[], box: FracRect): FracPoint[] {
83
+ return shape.map((s) => ({ x: box.x + s.x * box.w, y: box.y + s.y * box.h }));
84
+ }
85
+
86
+ /** A world rect as the `{ x, y, w, h }` the shape helpers take. */
87
+ export const boxOf = (r: WorldRect): FracRect => ({ x: r.x, y: r.y, w: r.width, h: r.height });
88
+
89
+ const roundPoint = (p: FracPoint): FracPoint => ({
90
+ x: Math.round(p.x * DP) / DP,
91
+ y: Math.round(p.y * DP) / DP,
92
+ });
93
+
94
+ /** `roundFrac` for points. */
95
+ export const roundPoints = (pts: readonly FracPoint[]): FracPoint[] => pts.map(roundPoint);
@@ -23,6 +23,7 @@ export type {
23
23
  AnnotationMeaning,
24
24
  AnnotationPatch,
25
25
  AnnotationQuery,
26
+ AnnotationSelectionMode,
26
27
  AnnotationStatus,
27
28
  AnnotationsApi,
28
29
  AnnotationsCapability,
@@ -1,9 +1,11 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { markCommands } from './paint';
1
+ import { registerNodeShape, type SceneNode, shapeCoversPoint } from '@weasel-js/core';
2
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
+ import type { WorldRect } from './frac';
4
+ import { markCommands, POINT_MARK_SHAPE } from './paint';
3
5
  import type { AnnotationData, AnnotationKind, FracPoint } from './types';
4
6
 
5
- const CONTENT = { w: 200, h: 100 };
6
7
  const POSE = { x: 20, y: 10, width: 60, height: 40 };
8
+ const AT_50: WorldRect = { x: 50, y: 50, width: 0, height: 0 };
7
9
 
8
10
  function mark(kind: AnnotationKind, extra: Partial<AnnotationData> = {}) {
9
11
  return { pose: POSE, data: { target: 't', kind, ...extra } as AnnotationData };
@@ -17,20 +19,21 @@ function anchors(cmds: ReturnType<typeof markCommands>): number[] {
17
19
  return p.coords ? [...p.coords] : [];
18
20
  }
19
21
 
22
+ /** In fractions of `POSE`: (35, 10) and (80, 30) in world. */
20
23
  const ENDS: readonly FracPoint[] = [
21
- { x: 0.1, y: 0.2 },
22
- { x: 0.6, y: 0.9 },
24
+ { x: 0.25, y: 0 },
25
+ { x: 1, y: 0.5 },
23
26
  ];
24
27
 
25
28
  describe('markCommands', () => {
26
29
  it('draws a rect at the pose', () => {
27
- const [cmd] = markCommands(mark('rect'), CONTENT);
30
+ const [cmd] = markCommands(mark('rect'));
28
31
  expect(cmd).toMatchObject({ kind: 'path', path: { kind: 'rect', ...POSE } });
29
32
  expect((cmd as { fill?: unknown }).fill).toBeUndefined();
30
33
  });
31
34
 
32
35
  it('draws an ellipse inscribed in the pose', () => {
33
- const pts = anchors(markCommands(mark('ellipse'), CONTENT));
36
+ const pts = anchors(markCommands(mark('ellipse')));
34
37
  const xs = pts.filter((_, i) => i % 2 === 0);
35
38
  const ys = pts.filter((_, i) => i % 2 === 1);
36
39
  expect(Math.min(...xs)).toBeCloseTo(POSE.x, 4);
@@ -39,21 +42,16 @@ describe('markCommands', () => {
39
42
  expect(Math.max(...ys)).toBeCloseTo(POSE.y + POSE.height, 4);
40
43
  });
41
44
 
42
- it('runs a line between its two stored ends, converted to world', () => {
43
- expect(anchors(markCommands(mark('line', { points: ENDS }), CONTENT))).toEqual([
44
- 0.1 * 200,
45
- 0.2 * 100,
46
- 0.6 * 200,
47
- 0.9 * 100,
48
- ]);
45
+ it('runs a line between its two stored ends, placed in the pose', () => {
46
+ expect(anchors(markCommands(mark('line', { shape: ENDS })))).toEqual([35, 10, 80, 30]);
49
47
  });
50
48
 
51
49
  it('makes an arrow the same line, carrying an end marker', () => {
52
- const cmds = markCommands(mark('arrow', { points: ENDS }), CONTENT);
50
+ const cmds = markCommands(mark('arrow', { shape: ENDS }));
53
51
  expect(cmds).toHaveLength(1);
54
52
  const stroke = (cmds[0] as { stroke?: { markerEnd?: unknown } }).stroke;
55
53
  expect(stroke?.markerEnd).toBe('arrow');
56
- expect(anchors(cmds)).toEqual([0.1 * 200, 0.2 * 100, 0.6 * 200, 0.9 * 100]);
54
+ expect(anchors(cmds)).toEqual([35, 10, 80, 30]);
57
55
  });
58
56
 
59
57
  it('threads a freehand stroke through every point', () => {
@@ -62,15 +60,13 @@ describe('markCommands', () => {
62
60
  { x: 0.5, y: 0.5 },
63
61
  { x: 1, y: 0 },
64
62
  ];
65
- expect(anchors(markCommands(mark('stroke', { points: pts }), CONTENT))).toEqual([
66
- 0, 0, 100, 50, 200, 0,
67
- ]);
63
+ expect(anchors(markCommands(mark('stroke', { shape: pts })))).toEqual([20, 10, 50, 30, 80, 10]);
68
64
  });
69
65
 
70
- it('falls back to the pose diagonal when a points kind has no points', () => {
71
- // A stored mark from a writer that dropped `points` must still draw
66
+ it('falls back to the pose diagonal when a vertex kind has no shape', () => {
67
+ // A stored mark from a writer that dropped `shape` must still draw
72
68
  // something at the right place, not vanish.
73
- expect(anchors(markCommands(mark('line'), CONTENT))).toEqual([
69
+ expect(anchors(markCommands(mark('line')))).toEqual([
74
70
  POSE.x,
75
71
  POSE.y,
76
72
  POSE.x + POSE.width,
@@ -79,18 +75,18 @@ describe('markCommands', () => {
79
75
  });
80
76
 
81
77
  it('draws text at the pose, carrying the title', () => {
82
- const [cmd] = markCommands(mark('text', { title: 'missing edge' }), CONTENT);
78
+ const [cmd] = markCommands(mark('text', { title: 'missing edge' }));
83
79
  expect(cmd).toMatchObject({ kind: 'text', x: POSE.x, y: POSE.y });
84
80
  const runs = (cmd as { runs: { text: string }[] }).runs;
85
81
  expect(runs.map((r) => r.text).join('')).toBe('missing edge');
86
82
  });
87
83
 
88
84
  it('draws an untitled text mark as nothing rather than an empty run', () => {
89
- expect(markCommands(mark('text'), CONTENT)).toEqual([]);
85
+ expect(markCommands(mark('text'))).toEqual([]);
90
86
  });
91
87
 
92
- it('draws a point as a small unfilled ring centered on its one stored point', () => {
93
- const cmds = markCommands(mark('point', { points: [{ x: 0.25, y: 0.5 }] }), CONTENT);
88
+ it('draws a point as a small unfilled ring centered on its position', () => {
89
+ const cmds = markCommands({ pose: AT_50, data: { target: 't', kind: 'point' } });
94
90
  expect(cmds).toHaveLength(1);
95
91
  expect((cmds[0] as { fill?: unknown }).fill).toBeUndefined();
96
92
  const pts = anchors(cmds);
@@ -104,9 +100,56 @@ describe('markCommands', () => {
104
100
  });
105
101
  });
106
102
 
103
+ describe('a point mark at different zooms', () => {
104
+ const POINT = { pose: AT_50, data: { target: 't', kind: 'point' } as AnnotationData };
105
+ const radiusAt = (zoom: number) => {
106
+ const xs = anchors(markCommands(POINT, {}, { x: zoom, y: zoom })).filter((_, i) => i % 2 === 0);
107
+ return (Math.max(...xs) - Math.min(...xs)) / 2;
108
+ };
109
+
110
+ it('sizes the ring in screen pixels, so its world radius scales inversely with zoom', () => {
111
+ expect(radiusAt(2)).toBeCloseTo(2, 4);
112
+ expect(radiusAt(4)).toBeCloseTo(1, 4);
113
+ });
114
+
115
+ it('keeps the ring round on screen under a non-uniform scale', () => {
116
+ const pts = anchors(markCommands(POINT, {}, { x: 2, y: 4 }));
117
+ const xs = pts.filter((_, i) => i % 2 === 0);
118
+ const ys = pts.filter((_, i) => i % 2 === 1);
119
+ expect((Math.max(...xs) - Math.min(...xs)) * 2).toBeCloseTo(
120
+ (Math.max(...ys) - Math.min(...ys)) * 4,
121
+ 4,
122
+ );
123
+ });
124
+ });
125
+
126
+ describe('picking a point mark', () => {
127
+ let dispose: () => void = () => {};
128
+ beforeEach(() => {
129
+ dispose = registerNodeShape(POINT_MARK_SHAPE, { priority: 'high' });
130
+ });
131
+ afterEach(() => dispose());
132
+
133
+ const pose: WorldRect = { x: 50, y: 50, width: 0, height: 0 };
134
+ const node = {
135
+ id: 'p',
136
+ kind: 'leaf',
137
+ layer: 'marks',
138
+ pose,
139
+ data: { target: 't', kind: 'point' },
140
+ } as unknown as SceneNode<AnnotationData, 'marks', WorldRect>;
141
+
142
+ // Ring radius 4px plus half the 2px outline: the ring's outer edge is 5px out.
143
+ it.each([1, 4])('reaches the ring it draws and no further, at zoom %i', (zoom) => {
144
+ const at = (px: number) => shapeCoversPoint(node, pose, 50 + px / zoom, 50, { scale: zoom });
145
+ expect(at(4.9)).toBe(true);
146
+ expect(at(5.5)).toBe(false);
147
+ });
148
+ });
149
+
107
150
  describe('markCommands styling', () => {
108
151
  it('takes a status color over the default', () => {
109
- const [cmd] = markCommands(mark('rect'), CONTENT, { color: '#30a46c' });
152
+ const [cmd] = markCommands(mark('rect'), { color: '#30a46c' });
110
153
  expect((cmd as { stroke?: { paint?: { color?: string } } }).stroke?.paint?.color).toBe(
111
154
  '#30a46c',
112
155
  );
@@ -114,21 +157,19 @@ describe('markCommands styling', () => {
114
157
 
115
158
  it('dashes a stale mark rather than hiding it', () => {
116
159
  // A stale mark still describes something; dropping it would lose it.
117
- const [fresh] = markCommands(mark('rect'), CONTENT);
118
- const [stale] = markCommands(mark('rect'), CONTENT, { stale: true });
160
+ const [fresh] = markCommands(mark('rect'));
161
+ const [stale] = markCommands(mark('rect'), { stale: true });
119
162
  expect((fresh as { stroke?: { dash?: number[] } }).stroke?.dash).toBeUndefined();
120
163
  expect((stale as { stroke?: { dash?: number[] } }).stroke?.dash).toEqual([6, 4]);
121
164
  });
122
165
 
123
166
  it('dashes a stale point ring like any other mark', () => {
124
- const [ring] = markCommands(mark('point', { points: [{ x: 0.25, y: 0.5 }] }), CONTENT, {
125
- stale: true,
126
- });
167
+ const [ring] = markCommands(mark('point'), { stale: true });
127
168
  expect((ring as { stroke?: { dash?: number[] } }).stroke?.dash).toEqual([6, 4]);
128
169
  });
129
170
 
130
171
  it("colors a text mark's glyphs too, not only the outlines", () => {
131
- const [cmd] = markCommands(mark('text', { title: 'x' }), CONTENT, { color: '#30a46c' });
172
+ const [cmd] = markCommands(mark('text', { title: 'x' }), { color: '#30a46c' });
132
173
  const runs = (cmd as { runs: { fill?: { color?: string } }[] }).runs;
133
174
  expect(runs[0]?.fill?.color).toBe('#30a46c');
134
175
  });
@@ -1,7 +1,16 @@
1
- import type { DrawCommand, Path, Stroke } from '@weasel-js/core';
2
- import { ellipsePath, linePath, PathBuilder, rectPath, textCommand } from '@weasel-js/core';
3
- import type { WorldRect } from './frac';
4
- import type { AnnotationData, FracPoint } from './types';
1
+ import type { DrawCommand, NodeShapeEntry, Path, Stroke, View } from '@weasel-js/core';
2
+ import {
3
+ ellipsePath,
4
+ HANDLE_BASE_PX,
5
+ linePath,
6
+ meanScale,
7
+ PathBuilder,
8
+ pxExtent,
9
+ rectPath,
10
+ textCommand,
11
+ } from '@weasel-js/core';
12
+ import { boxOf, fromShape, type WorldRect } from './frac';
13
+ import type { AnnotationData } from './types';
5
14
 
6
15
  /** One loud color, not a themed one: a mark sits over the instrument's own
7
16
  * picture and has to be legible against whatever that picture is. */
@@ -9,7 +18,11 @@ const MARK_COLOR = '#e5484d';
9
18
  /** World units, so a mark thickens with the picture it annotates. */
10
19
  const MARK_WIDTH = 2;
11
20
  const TEXT_SIZE = 14;
12
- const POINT_RADIUS = 4;
21
+ const STALE_DASH = [6, 4];
22
+ /** Screen pixels: a point marks a place rather than a region of the picture,
23
+ * so it holds its size like a selection handle. */
24
+ const POINT_RADIUS_PX = HANDLE_BASE_PX / 2;
25
+ const UNIT_SCALE: View['scale'] = { x: 1, y: 1 };
13
26
 
14
27
  /** The subset of a mark's scene node this needs: where it is, and what it is. */
15
28
  export interface PaintableMark {
@@ -28,16 +41,11 @@ export interface MarkStyle {
28
41
  stale?: boolean;
29
42
  }
30
43
 
31
- const toWorld = (p: FracPoint, content: { w: number; h: number }) => ({
32
- x: p.x * content.w,
33
- y: p.y * content.h,
34
- });
35
-
36
- /** A mark's stored vertices in world units, or the pose's diagonal — a stored
37
- * mark whose `points` did not survive still has to draw somewhere. */
38
- function vertices(m: PaintableMark, content: { w: number; h: number }): { x: number; y: number }[] {
39
- const stored = m.data.points;
40
- if (stored && stored.length >= 2) return stored.map((p) => toWorld(p, content));
44
+ /** A mark's stored vertices placed in its pose, or the pose's diagonal — a
45
+ * stored mark whose `shape` did not survive still has to draw somewhere. */
46
+ function vertices(m: PaintableMark): { x: number; y: number }[] {
47
+ const stored = m.data.shape;
48
+ if (stored && stored.length >= 2) return fromShape(stored, boxOf(m.pose));
41
49
  const { x, y, width, height } = m.pose;
42
50
  return [
43
51
  { x, y },
@@ -57,14 +65,18 @@ function polyline(points: { x: number; y: number }[]): Path {
57
65
  /**
58
66
  * What one mark draws, in its target's world.
59
67
  *
60
- * Pure: a node and the target's content box in, draw commands out. Geometry
61
- * that a bounding box cannot describe — a line's ends, a stroke's path — comes
62
- * from `data.points`, which is in fractions like the bounds.
68
+ * Pure: a node in, draw commands out, in its target's world. Geometry that a
69
+ * bounding box cannot describe — a line's ends, a stroke's path — comes from
70
+ * `data.shape`, placed in the pose.
71
+ *
72
+ * `scale` is the view's, world to screen. Only a point reads it — its ring is
73
+ * sized in screen pixels — and the default draws that ring as though a world
74
+ * unit were a pixel.
63
75
  */
64
76
  export function markCommands(
65
77
  m: PaintableMark,
66
- content: { w: number; h: number },
67
78
  style: MarkStyle = {},
79
+ scale: View['scale'] = UNIT_SCALE,
68
80
  ): DrawCommand[] {
69
81
  const color = style.color ?? MARK_COLOR;
70
82
  const stroke: Stroke = {
@@ -72,7 +84,7 @@ export function markCommands(
72
84
  width: MARK_WIDTH,
73
85
  cap: 'round',
74
86
  join: 'round',
75
- ...(style.stale ? { dash: [6, 4] } : {}),
87
+ ...(style.stale ? { dash: STALE_DASH } : {}),
76
88
  };
77
89
 
78
90
  switch (m.data.kind) {
@@ -83,26 +95,32 @@ export function markCommands(
83
95
  case 'ellipse':
84
96
  return [{ kind: 'path', path: ellipsePath(m.pose), stroke }];
85
97
  case 'line': {
86
- const [a, b] = vertices(m, content);
98
+ const [a, b] = vertices(m);
87
99
  return [{ kind: 'path', path: linePath(a, b), stroke }];
88
100
  }
89
101
  case 'arrow': {
90
- const [a, b] = vertices(m, content);
102
+ const [a, b] = vertices(m);
91
103
  // The spec's arrow: a line carrying an end marker, not its own geometry.
92
104
  return [{ kind: 'path', path: linePath(a, b), stroke: { ...stroke, markerEnd: 'arrow' } }];
93
105
  }
94
106
  case 'stroke':
95
- return [{ kind: 'path', path: polyline(vertices(m, content)), stroke }];
107
+ return [{ kind: 'path', path: polyline(vertices(m)), stroke }];
96
108
  case 'point': {
97
- const stored = m.data.points?.[0];
98
- const c = stored ? toWorld(stored, content) : { x: m.pose.x, y: m.pose.y };
99
- const ring = {
100
- x: c.x - POINT_RADIUS,
101
- y: c.y - POINT_RADIUS,
102
- width: 2 * POINT_RADIUS,
103
- height: 2 * POINT_RADIUS,
104
- };
105
- return [{ kind: 'path', path: ellipsePath(ring), stroke }];
109
+ const c = { x: m.pose.x, y: m.pose.y };
110
+ const r = pxExtent(POINT_RADIUS_PX, scale);
111
+ const ring = { x: c.x - r.x, y: c.y - r.y, width: 2 * r.x, height: 2 * r.y };
112
+ const px = 1 / meanScale(scale);
113
+ return [
114
+ {
115
+ kind: 'path',
116
+ path: ellipsePath(ring),
117
+ stroke: {
118
+ ...stroke,
119
+ width: MARK_WIDTH * px,
120
+ ...(stroke.dash ? { dash: stroke.dash.map((d) => d * px) } : {}),
121
+ },
122
+ },
123
+ ];
106
124
  }
107
125
  case 'text': {
108
126
  const text = m.data.title;
@@ -124,3 +142,23 @@ export function markCommands(
124
142
  }
125
143
  }
126
144
  }
145
+
146
+ /**
147
+ * What picking reads for a point mark: its pose is zero-size, so without this
148
+ * the pointer slop alone decides how close counts, whatever the ring's size.
149
+ * Registered by the overlay, which is the one place marks are picked.
150
+ */
151
+ export const POINT_MARK_SHAPE: NodeShapeEntry<AnnotationData, WorldRect> = {
152
+ id: 'labkit:point-mark',
153
+ matches: (node) =>
154
+ node.layer === 'marks' && node.data?.kind === 'point' && typeof node.data.target === 'string',
155
+ // The overlay paints through its own draw callback. This is for a scene
156
+ // drawn without one.
157
+ paint: (node, pose) => markCommands({ pose, data: node.data }),
158
+ silhouette: (_node, pose) => rectPath(pose.x, pose.y, 0, 0),
159
+ // Filled, so a click inside the ring lands on it as it would on a handle.
160
+ ink: (_node, _pose, ctx) => {
161
+ const s = ctx?.scale ?? 1;
162
+ return { filled: true, outset: (POINT_RADIUS_PX + MARK_WIDTH / 2) / s, inset: 0 };
163
+ },
164
+ };
@@ -1,6 +1,8 @@
1
+ import { asNodeId } from '@weasel-js/core';
1
2
  import { describe, expect, it, vi } from 'vitest';
3
+ import { markCommands } from './paint';
2
4
  import { annotationsFromJSON, createAnnotationStore } from './store';
3
- import type { AnnotationInit, AnnotationTargetInfo } from './types';
5
+ import type { AnnotationInit, AnnotationsApi, AnnotationTargetInfo, FracPoint } from './types';
4
6
 
5
7
  const TARGETS: AnnotationTargetInfo[] = [
6
8
  { id: 'naive', content: { w: 256, h: 170 }, positionDependsOn: ['angle', 'shading'] },
@@ -162,7 +164,7 @@ describe('the annotation store', () => {
162
164
  // because jsdom has no WebGL2 and the overlay's canvas never paints or
163
165
  // hit-tests. That the canvas is bound to this scene is core's claim.
164
166
  it('merges the selection across targets, and maps it back to annotation ids', () => {
165
- const store = makeStore();
167
+ const store = createAnnotationStore({ targets: () => TARGETS, selection: 'per-target' });
166
168
  const onNaive = store.add(RING);
167
169
  const onOcct = store.add({ ...RING, target: 'occt' });
168
170
 
@@ -227,6 +229,86 @@ describe('the annotation store', () => {
227
229
  expect(fn.mock.calls.length).toBe(before);
228
230
  });
229
231
 
232
+ describe('across targets', () => {
233
+ /** Selects a mark the way its pane's canvas does: on that target's scene. */
234
+ const click = (store: AnnotationsApi, id: string): void => {
235
+ const [target, node] = id.split('/') as [string, string];
236
+ store.sceneFor(target).setSelection([asNodeId(node)]);
237
+ };
238
+
239
+ it('clears a selection standing in one target when a mark is selected in another', () => {
240
+ const store = makeStore();
241
+ const onNaive = store.add(RING);
242
+ const onOcct = store.add({ ...RING, target: 'occt' });
243
+
244
+ click(store, onNaive);
245
+ click(store, onOcct);
246
+ expect(store.selection()).toEqual([onOcct]);
247
+ expect(store.sceneFor('naive').getSelection()).toEqual([]);
248
+ });
249
+
250
+ it('tells subscribers once, after the other target is already cleared', () => {
251
+ const store = makeStore();
252
+ const onNaive = store.add(RING);
253
+ const onOcct = store.add({ ...RING, target: 'occt' });
254
+ click(store, onNaive);
255
+
256
+ const seen: (readonly string[])[] = [];
257
+ store.subscribe(() => seen.push(store.selection()));
258
+ click(store, onOcct);
259
+ expect(seen).toEqual([[onOcct]]);
260
+ });
261
+
262
+ it('keeps a setSelection naming two targets to the first one, in one event', () => {
263
+ const store = makeStore();
264
+ const onNaive = store.add(RING);
265
+ const onOcct = store.add({ ...RING, target: 'occt' });
266
+ click(store, onNaive);
267
+
268
+ const fn = vi.fn();
269
+ store.subscribe(fn);
270
+ store.setSelection([onOcct, onNaive]);
271
+ expect(store.selection()).toEqual([onOcct]);
272
+ expect(fn).toHaveBeenCalledTimes(1);
273
+ });
274
+
275
+ it('leaves the other target alone for a change that selects nothing new', () => {
276
+ const store = makeStore();
277
+ const onNaive = store.add(RING);
278
+ store.add({ ...RING, target: 'occt' });
279
+ click(store, onNaive);
280
+
281
+ store.add({ ...RING, target: 'occt' });
282
+ store.sceneFor('occt').setSelection([]);
283
+ expect(store.selection()).toEqual([onNaive]);
284
+ });
285
+
286
+ it("keeps each target's own selection under `selection: 'per-target'`", () => {
287
+ const store = createAnnotationStore({ targets: () => TARGETS, selection: 'per-target' });
288
+ const onNaive = store.add(RING);
289
+ const onOcct = store.add({ ...RING, target: 'occt' });
290
+
291
+ click(store, onNaive);
292
+ click(store, onOcct);
293
+ expect(store.selection()).toEqual([onNaive, onOcct]);
294
+
295
+ store.setSelection([onOcct, onNaive]);
296
+ expect(store.selection()).toEqual([onNaive, onOcct]);
297
+ });
298
+
299
+ it('reads the mode at each change, for a capability swapped under a live store', () => {
300
+ let mode: 'exclusive' | 'per-target' = 'per-target';
301
+ const store = createAnnotationStore({ targets: () => TARGETS, selection: () => mode });
302
+ const onNaive = store.add(RING);
303
+ const onOcct = store.add({ ...RING, target: 'occt' });
304
+
305
+ click(store, onNaive);
306
+ mode = 'exclusive';
307
+ click(store, onOcct);
308
+ expect(store.selection()).toEqual([onOcct]);
309
+ });
310
+ });
311
+
230
312
  it('round-trips through JSON, and the snapshot is JSON-clean', () => {
231
313
  const store = makeStore();
232
314
  const id = store.add(RING, { angle: 'iso', shading: 'outline' });
@@ -295,6 +377,134 @@ describe('the annotation store', () => {
295
377
  });
296
378
  });
297
379
 
380
+ /** What a target's first mark draws, as its path's points in fractions of the
381
+ * content box — the drawn side, to hold against what the store hit-tests. */
382
+ function drawn(store: AnnotationsApi, target = 'naive'): FracPoint[] {
383
+ const { w, h } = TARGETS.find((t) => t.id === target)?.content ?? { w: 0, h: 0 };
384
+ const [painted] = store.paintedMarks(target);
385
+ if (!painted) throw new Error('no mark painted');
386
+ const [cmd] = markCommands(painted.mark, painted.style);
387
+ if (cmd?.kind !== 'path') throw new Error('expected a path command');
388
+ const coords = [...((cmd.path as { coords?: Float32Array }).coords ?? [])];
389
+ const out: FracPoint[] = [];
390
+ for (let i = 0; i < coords.length; i += 2) {
391
+ out.push({ x: (coords[i] ?? 0) / w, y: (coords[i + 1] ?? 0) / h });
392
+ }
393
+ return out;
394
+ }
395
+
396
+ const centerOf = (pts: FracPoint[]): FracPoint => {
397
+ const xs = pts.map((p) => p.x);
398
+ const ys = pts.map((p) => p.y);
399
+ return {
400
+ x: (Math.min(...xs) + Math.max(...xs)) / 2,
401
+ y: (Math.min(...ys) + Math.max(...ys)) / 2,
402
+ };
403
+ };
404
+
405
+ const expectNear = (got: readonly FracPoint[], want: readonly FracPoint[]) => {
406
+ expect(got).toHaveLength(want.length);
407
+ got.forEach((p, i) => {
408
+ expect(p.x).toBeCloseTo(want[i]?.x ?? Number.NaN, 3);
409
+ expect(p.y).toBeCloseTo(want[i]?.y ?? Number.NaN, 3);
410
+ });
411
+ };
412
+
413
+ describe('a moved mark', () => {
414
+ const POINT: AnnotationInit = {
415
+ target: 'naive',
416
+ kind: 'point',
417
+ frac: { x: 0.25, y: 0.5, w: 0, h: 0 },
418
+ points: [{ x: 0.25, y: 0.5 }],
419
+ };
420
+ const LINE: AnnotationInit = {
421
+ target: 'naive',
422
+ kind: 'line',
423
+ frac: { x: 0.1, y: 0.2, w: 0.5, h: 0.4 },
424
+ points: [
425
+ { x: 0.1, y: 0.6 },
426
+ { x: 0.6, y: 0.2 },
427
+ ],
428
+ };
429
+
430
+ it('draws a point where the store hit-tests it after update moves it', () => {
431
+ const store = makeStore();
432
+ const id = store.add(POINT);
433
+ store.update(id, { frac: { x: 0.75, y: 0.25, w: 0, h: 0 } });
434
+
435
+ expectNear([centerOf(drawn(store))], [{ x: 0.75, y: 0.25 }]);
436
+ expect(store.hitTest('naive', { x: 0.75, y: 0.25 }, 0.001).map((a) => a.id)).toEqual([id]);
437
+ expect(store.get(id)?.points).toEqual([{ x: 0.75, y: 0.25 }]);
438
+ });
439
+
440
+ it('carries a line and a stroke along with their bounds', () => {
441
+ for (const kind of ['line', 'arrow', 'stroke'] as const) {
442
+ const store = makeStore();
443
+ const id = store.add({ ...LINE, kind });
444
+ store.update(id, { frac: { x: 0.3, y: 0.3, w: 0.5, h: 0.4 } });
445
+ const moved = [
446
+ { x: 0.3, y: 0.7 },
447
+ { x: 0.8, y: 0.3 },
448
+ ];
449
+ expectNear(drawn(store), moved);
450
+ expect(store.get(id)?.points).toEqual(moved);
451
+ }
452
+ });
453
+
454
+ it('stretches stored vertices with a resized box', () => {
455
+ const store = makeStore();
456
+ const id = store.add(LINE);
457
+ store.update(id, { frac: { x: 0.1, y: 0.2, w: 0.25, h: 0.8 } });
458
+ expectNear(drawn(store), [
459
+ { x: 0.1, y: 1 },
460
+ { x: 0.35, y: 0.2 },
461
+ ]);
462
+ });
463
+
464
+ it('follows a pose written straight to the scene, the way the pane moves a mark', () => {
465
+ const store = makeStore();
466
+ const id = store.add(POINT);
467
+ const node = asNodeId(id.slice(id.lastIndexOf('/') + 1));
468
+ store.sceneFor('naive').setPose(node, { x: 0.5 * 256, y: 0.1 * 170, width: 0, height: 0 });
469
+
470
+ expectNear([centerOf(drawn(store))], [{ x: 0.5, y: 0.1 }]);
471
+ expect(store.get(id)?.points).toEqual([{ x: 0.5, y: 0.1 }]);
472
+ });
473
+
474
+ it('moves the bounds when only the points are patched', () => {
475
+ const store = makeStore();
476
+ const id = store.add(POINT);
477
+ store.update(id, { points: [{ x: 0.9, y: 0.9 }] });
478
+
479
+ expect(store.get(id)?.frac).toEqual({ x: 0.9, y: 0.9, w: 0, h: 0 });
480
+ expectNear([centerOf(drawn(store))], [{ x: 0.9, y: 0.9 }]);
481
+ });
482
+
483
+ it('reads points a store saved in content fractions, before they followed the bounds', () => {
484
+ const legacy = {
485
+ version: 1,
486
+ scenes: {
487
+ naive: {
488
+ version: 1,
489
+ systemLayers: [{ id: 'marks' }],
490
+ nodes: [
491
+ {
492
+ id: 'n1',
493
+ kind: 'leaf',
494
+ layer: 'marks',
495
+ pose: { x: 0.1 * 256, y: 0.2 * 170, width: 0.5 * 256, height: 0.4 * 170 },
496
+ data: { target: 'naive', kind: 'line', points: LINE.points },
497
+ },
498
+ ],
499
+ },
500
+ },
501
+ };
502
+ const store = annotationsFromJSON(legacy, () => TARGETS);
503
+ expectNear(drawn(store), LINE.points ?? []);
504
+ expect(store.get('naive/n1')?.points).toEqual(LINE.points);
505
+ });
506
+ });
507
+
298
508
  describe('paintedMarks', () => {
299
509
  it("hands over a target's marks in world units with their resolved style", () => {
300
510
  let config: Record<string, string> = { angle: 'iso', shading: 'outline' };