@weasel-js/labkit 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +27 -0
  2. package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-C_XboUpz.d.ts} +48 -6
  3. package/dist/_dts/{index-iAP6XbH3.d.ts → index-JFAYj5Tv.d.ts} +12 -0
  4. package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
  5. package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-BMNIx3Cy.d.ts} +5 -9
  6. package/dist/canvas/index.d.ts +2 -1
  7. package/dist/{chunk-C6GJKPUI.js → chunk-73KA7WBO.js} +69 -63
  8. package/dist/chunk-73KA7WBO.js.map +1 -0
  9. package/dist/{chunk-DJLDIRFN.js → chunk-BOHF3PQO.js} +4375 -3720
  10. package/dist/chunk-BOHF3PQO.js.map +1 -0
  11. package/dist/{chunk-NRKWVTVT.js → chunk-G5TJVQQT.js} +3 -3
  12. package/dist/{chunk-NRKWVTVT.js.map → chunk-G5TJVQQT.js.map} +1 -1
  13. package/dist/chunk-LN6JDUGB.js +106 -0
  14. package/dist/chunk-LN6JDUGB.js.map +1 -0
  15. package/dist/chunk-THBG7FQZ.js +167 -0
  16. package/dist/chunk-THBG7FQZ.js.map +1 -0
  17. package/dist/{chunk-3TYUJR7Z.js → chunk-VUU5UXHE.js} +6 -3
  18. package/dist/chunk-VUU5UXHE.js.map +1 -0
  19. package/dist/dragdrop/index.d.ts +2 -1
  20. package/dist/index.d.ts +71 -11
  21. package/dist/index.js +165 -22
  22. package/dist/index.js.map +1 -1
  23. package/dist/job/index.d.ts +13 -0
  24. package/dist/job/index.js +3 -0
  25. package/dist/job/index.js.map +1 -0
  26. package/dist/layers/index.d.ts +3 -2
  27. package/dist/passthrough/weasel-canvas.d.ts +1 -1
  28. package/dist/passthrough/weasel-canvas.js +1 -1
  29. package/dist/passthrough/weasel-ui.d.ts +20 -4
  30. package/dist/passthrough/weasel-ui.js +2 -2
  31. package/dist/state/index.d.ts +2 -2
  32. package/dist/state/index.js +2 -2
  33. package/dist/styles.css +10 -0
  34. package/dist/surface/index.d.ts +77 -0
  35. package/dist/surface/index.js +3 -0
  36. package/dist/surface/index.js.map +1 -0
  37. package/dist/ui/layers/index.js +3 -3
  38. package/dist/undo/index.d.ts +2 -1
  39. package/package.json +9 -1
  40. package/src/canvas/AGENTS.md +8 -0
  41. package/src/canvas/useOrbit.test.ts +71 -0
  42. package/src/canvas/useOrbit.ts +149 -0
  43. package/src/index.test.ts +18 -0
  44. package/src/index.ts +13 -0
  45. package/src/instrument/SineWave.smoke.test.tsx +2 -1
  46. package/src/instrument/types.ts +13 -1
  47. package/src/job/index.ts +3 -0
  48. package/src/job/types.ts +47 -0
  49. package/src/job/useJob.test.tsx +210 -0
  50. package/src/job/useJob.ts +134 -0
  51. package/src/lab/Lab.stories.tsx +1 -2
  52. package/src/lab/Workspace.surface.test.tsx +49 -0
  53. package/src/lab/Workspace.tsx +14 -1
  54. package/src/state/store.ts +8 -2
  55. package/src/state/types.ts +4 -2
  56. package/src/state/view.test.ts +127 -0
  57. package/src/state/view.ts +18 -0
  58. package/src/surface/AGENTS.md +64 -0
  59. package/src/surface/SurfaceContext.ts +5 -0
  60. package/src/surface/composeRects.test.ts +50 -0
  61. package/src/surface/composeRects.ts +19 -0
  62. package/src/surface/deviceRect.test.ts +40 -0
  63. package/src/surface/deviceRect.ts +19 -0
  64. package/src/surface/index.ts +7 -0
  65. package/src/surface/rect.ts +16 -0
  66. package/src/surface/useSurfaceTile.test.tsx +67 -0
  67. package/src/surface/useSurfaceTile.ts +32 -0
  68. package/src/surface/useTiledSurface.test.tsx +231 -0
  69. package/src/surface/useTiledSurface.ts +157 -0
  70. package/src/trial/DefaultStatusBar.tsx +2 -2
  71. package/src/trial/Trial.job.test.tsx +75 -0
  72. package/src/trial/Trial.less +12 -0
  73. package/src/trial/Trial.stories.tsx +8 -1
  74. package/src/trial/Trial.tsx +32 -6
  75. package/src/trial/TrialChrome.tsx +33 -6
  76. package/src/trial/slotTypes.ts +2 -1
  77. package/dist/chunk-3TYUJR7Z.js.map +0 -1
  78. package/dist/chunk-C6GJKPUI.js.map +0 -1
  79. package/dist/chunk-DJLDIRFN.js.map +0 -1
@@ -0,0 +1,13 @@
1
+ import { a as JobCapability, J as JobHandle } from '../_dts/types-DJ79Tg5J.js';
2
+ export { b as JobEvent, c as JobFailure, d as JobStatus } from '../_dts/types-DJ79Tg5J.js';
3
+
4
+ interface UseJobOptions<TS, TC, TItem> {
5
+ capability: JobCapability<TS, TC, TItem>;
6
+ config: TC;
7
+ state: TS;
8
+ setState: (next: TS | ((prev: TS) => TS)) => void;
9
+ }
10
+ declare function useJob<TS, TC, TItem>({ capability, config, state, setState, }: UseJobOptions<TS, TC, TItem>): JobHandle;
11
+
12
+ export { JobCapability, JobHandle, useJob };
13
+ export type { UseJobOptions };
@@ -0,0 +1,3 @@
1
+ export { useJob } from '../chunk-LN6JDUGB.js';
2
+ //# sourceMappingURL=index.js.map
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
@@ -1,8 +1,9 @@
1
- import { d as LayerDescriptor } from '../_dts/index-iAP6XbH3.js';
2
- export { L as LayerCapability } from '../_dts/index-iAP6XbH3.js';
1
+ import { d as LayerDescriptor } from '../_dts/index-JFAYj5Tv.js';
2
+ export { L as LayerCapability } from '../_dts/index-JFAYj5Tv.js';
3
3
  import * as react_jsx_runtime from 'react/jsx-runtime';
4
4
  import 'react';
5
5
  import '../_dts/types-x92Kfeme.js';
6
+ import '../_dts/types-DJ79Tg5J.js';
6
7
 
7
8
  /** Props for `<LayerList>`. */
8
9
  interface LayerListProps {
@@ -1,6 +1,6 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { Ref } from 'react';
3
- import { N as Node, V as View, D as DrawCommand, S as Scene, B as Bounds, a as ViewportDims, P as PathDrawCommand } from '../_dts/DrawCommand-BkZztJsW.js';
3
+ import { N as Node, V as View, D as DrawCommand, S as Scene, B as Bounds, a as ViewportDims, P as PathDrawCommand } from '../_dts/DrawCommand-C_XboUpz.js';
4
4
 
5
5
  /**
6
6
  * `renderSceneToCanvas` — substrate for detached, read-only scene views
@@ -1,3 +1,3 @@
1
- export { FALLBACK_FIT_VIEW, MinimapCanvas, SceneViewCanvas, buildSceneViewCommands, computeFitView2 as computeFitView, computeIndicatorCommand, renderSceneToCanvas } from '../chunk-DJLDIRFN.js';
1
+ export { FALLBACK_FIT_VIEW, MinimapCanvas, SceneViewCanvas, buildSceneViewCommands, computeFitView, computeIndicatorCommand, renderSceneToCanvas } from '../chunk-BOHF3PQO.js';
2
2
  //# sourceMappingURL=weasel-canvas.js.map
3
3
  //# sourceMappingURL=weasel-canvas.js.map
@@ -1,7 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import * as react from 'react';
3
3
  import { ReactNode, CSSProperties, ButtonHTMLAttributes, ReactElement, MutableRefObject, KeyboardEvent, PointerEvent as PointerEvent$1, RefCallback, RefObject } from 'react';
4
- import { V as View, D as DrawCommand, O as Op, b as NodeId, c as Path, S as Scene, H as History, B as Bounds } from '../_dts/DrawCommand-BkZztJsW.js';
4
+ import { V as View, D as DrawCommand, O as Op, b as NodeId, c as Path, S as Scene, H as History, B as Bounds } from '../_dts/DrawCommand-C_XboUpz.js';
5
5
  import { TextFieldProps, ValidationResult, CheckboxProps as CheckboxProps$1, SwitchProps as SwitchProps$1, TabProps as TabProps$1, TabListProps as TabListProps$1, TabPanelProps as TabPanelProps$1, TabsProps as TabsProps$1, RadioProps as RadioProps$1, RadioGroupProps as RadioGroupProps$1, NumberFieldProps as NumberFieldProps$1, SelectProps as SelectProps$1, ListBoxItemProps, ComboBoxProps as ComboBoxProps$1, SliderProps as SliderProps$1, ModalOverlayProps, DialogProps as DialogProps$1 } from 'react-aria-components';
6
6
 
7
7
  /**
@@ -1373,7 +1373,9 @@ interface Dims {
1373
1373
  * @template TData - The data object passed to each draw call.
1374
1374
  */
1375
1375
  interface RenderLayer<TData> {
1376
- /** Unique identifier used in visibility maps and ordering arrays. */
1376
+ /** Unique identifier used in visibility maps and ordering arrays. When a
1377
+ * cache is in use, an id must identify the same logical layer across
1378
+ * frames — reusing it for a different layer can serve cross-layer commands. */
1377
1379
  id: string;
1378
1380
  /** Human-readable name for UI toggles. */
1379
1381
  label: string;
@@ -1391,6 +1393,21 @@ interface RenderLayer<TData> {
1391
1393
  * subset manually with `viewToMat3(view)`.
1392
1394
  */
1393
1395
  draw: (data: TData, view: View, dims: Dims) => DrawCommand[];
1396
+ /**
1397
+ * Optional cache key. When present and a `LayerCommandCache` is supplied to
1398
+ * `drawLayers`, the layer's previous `DrawCommand[]` is reused as long as
1399
+ * every entry is `Object.is`-equal to the previous call's. A layer with no
1400
+ * `deps` rebuilds on every frame.
1401
+ *
1402
+ * **The returned commands must be treated as immutable.** A cached tree is
1403
+ * handed to the renderer again on later frames, so mutating a tree you
1404
+ * previously returned corrupts the cache silently rather than erroring.
1405
+ *
1406
+ * **Screen-space layers are not protected against a stale `view`/`dims`
1407
+ * the way world-space layers are** (see `space` below) — include them in
1408
+ * `deps` if `draw` reads them.
1409
+ */
1410
+ deps?: (data: TData, view: View, dims: Dims) => readonly unknown[];
1394
1411
  /**
1395
1412
  * Whether the layer is shown when no explicit visibility entry exists.
1396
1413
  * Defaults to `true` when absent.
@@ -4708,8 +4725,7 @@ interface AnchorRenderProps {
4708
4725
  * Per-instance curve-rendering overrides. Currently empty — passing `{}`
4709
4726
  * means "draw the curve", and `false`/`null` means don't.
4710
4727
  */
4711
- interface CurveSettings {
4712
- }
4728
+ type CurveSettings = object;
4713
4729
  /** Shades the region between the curve and one edge of the plot. */
4714
4730
  interface FillSettings {
4715
4731
  side: 'below' | 'above';
@@ -1,4 +1,4 @@
1
- export { m as ActionBar, r3 as ActionsBar, Ft as Badge, a as Button, o3 as Checkbox, p4 as ComboBox, m2 as ComboBoxItem, F as CurveEditor, u as DataGrid, c3 as Dialog, qe as EDGE_PROFILES, n as Field, u3 as Input, s as KeyCap, c2 as KeySequence, o2 as MINUS_SIGN, f2 as NumberField, r2 as OptionsBar, _ as Plot2D, n4 as PointPlotter, r4 as Powerline, u4 as Radio, l as RadioGroup, l2 as RangeSlider, p3 as Select, h as SelectItem, n2 as Sidebar, o4 as SidebarPanel, v as Slider, a3 as Switch, c4 as Tab, s3 as TabList, l3 as TabPanel, o6 as Tabs, r as ToggleBar, o5 as ToolButton, n3 as ToolGroup, _2 as ToolPalette, He as chromaAt, u2 as detectPlatform, p as dlog, r5 as fieldClasses, s2 as formatNumber, d2 as formatShortcut, u5 as formatShortcutParts, o as inferKeycapKind, f as isDebugEnabled, e as keyGlyph, p2 as keySpecFromKey, d as keySpecsFromMods, Ve as oklchToHex, p5 as paintGradientTrack, c as useReorderDragList, a2 as useRovingTabIndex } from '../chunk-C6GJKPUI.js';
2
- import '../chunk-DJLDIRFN.js';
1
+ export { m as ActionBar, r3 as ActionsBar, Ft as Badge, a as Button, o3 as Checkbox, p4 as ComboBox, m2 as ComboBoxItem, F as CurveEditor, u as DataGrid, c3 as Dialog, qe as EDGE_PROFILES, n as Field, u3 as Input, s as KeyCap, c2 as KeySequence, o2 as MINUS_SIGN, f2 as NumberField, r2 as OptionsBar, _ as Plot2D, n4 as PointPlotter, r4 as Powerline, u4 as Radio, l as RadioGroup, l2 as RangeSlider, p3 as Select, h as SelectItem, n2 as Sidebar, o4 as SidebarPanel, v as Slider, a3 as Switch, c4 as Tab, s3 as TabList, l3 as TabPanel, o6 as Tabs, r as ToggleBar, o5 as ToolButton, n3 as ToolGroup, _2 as ToolPalette, He as chromaAt, u2 as detectPlatform, p as dlog, r5 as fieldClasses, s2 as formatNumber, d2 as formatShortcut, u5 as formatShortcutParts, o as inferKeycapKind, f as isDebugEnabled, e as keyGlyph, p2 as keySpecFromKey, d as keySpecsFromMods, Ve as oklchToHex, p5 as paintGradientTrack, c as useReorderDragList, a2 as useRovingTabIndex } from '../chunk-73KA7WBO.js';
2
+ import '../chunk-BOHF3PQO.js';
3
3
  //# sourceMappingURL=weasel-ui.js.map
4
4
  //# sourceMappingURL=weasel-ui.js.map
@@ -1,5 +1,5 @@
1
- import { S as SerializedTrial, I as InstrumentSerializers, T as TrialRecord, U as UndoStack, a as StorageAdapter } from '../_dts/useTrialState-DYe2vUwN.js';
2
- export { C as CURRENT_DOCUMENT_VERSION, b as CreateLabStoreOptions, L as LabDocument, c as LabStore, d as LabStoreActions, e as LabStoreContext, f as LabStoreProvider, g as LabStoreState, M as Migration, h as SavedSnapshot, i as TrialIdContext, j as TrialIdProvider, k as TrialStateHandle, l as createLabStore, m as createMemoryAdapter, n as labDocumentKey, o as localStorageAdapter, p as noneAdapter, q as quarantineKey, s as sessionStorageAdapter, u as urlHashAdapter, r as useLabStore, t as useTrialId, v as useTrialState } from '../_dts/useTrialState-DYe2vUwN.js';
1
+ import { S as SerializedTrial, I as InstrumentSerializers, T as TrialRecord, U as UndoStack, a as StorageAdapter } from '../_dts/useTrialState-BMNIx3Cy.js';
2
+ export { C as CURRENT_DOCUMENT_VERSION, b as CreateLabStoreOptions, L as LabDocument, c as LabStore, d as LabStoreActions, e as LabStoreContext, f as LabStoreProvider, g as LabStoreState, M as Migration, h as SavedSnapshot, i as TrialIdContext, j as TrialIdProvider, k as TrialStateHandle, l as createLabStore, m as createMemoryAdapter, n as labDocumentKey, o as localStorageAdapter, p as noneAdapter, q as quarantineKey, s as sessionStorageAdapter, u as urlHashAdapter, r as useLabStore, t as useTrialId, v as useTrialState } from '../_dts/useTrialState-BMNIx3Cy.js';
3
3
  import * as react_jsx_runtime from 'react/jsx-runtime';
4
4
  import { ReactNode } from 'react';
5
5
  import 'zustand/vanilla';
@@ -1,5 +1,5 @@
1
- import { createLabStore, LabStoreProvider, TrialIdProvider } from '../chunk-3TYUJR7Z.js';
2
- export { CURRENT_DOCUMENT_VERSION, LabStoreContext, LabStoreProvider, TrialIdContext, TrialIdProvider, createLabStore, createMemoryAdapter, decodeUrlHash, deserializeTrials, emptyUndoStack, encodeUrlHash, labDocumentKey, labStorageKey, localStorageAdapter, noneAdapter, quarantineKey, serializeTrials, sessionStorageAdapter, urlHashAdapter, useLabStore, useTrialId, useTrialState } from '../chunk-3TYUJR7Z.js';
1
+ import { createLabStore, LabStoreProvider, TrialIdProvider } from '../chunk-VUU5UXHE.js';
2
+ export { CURRENT_DOCUMENT_VERSION, LabStoreContext, LabStoreProvider, TrialIdContext, TrialIdProvider, createLabStore, createMemoryAdapter, decodeUrlHash, deserializeTrials, emptyUndoStack, encodeUrlHash, labDocumentKey, labStorageKey, localStorageAdapter, noneAdapter, quarantineKey, serializeTrials, sessionStorageAdapter, urlHashAdapter, useLabStore, useTrialId, useTrialState } from '../chunk-VUU5UXHE.js';
3
3
  import { useRef } from 'react';
4
4
  import { jsx } from 'react/jsx-runtime';
5
5
 
package/dist/styles.css CHANGED
@@ -627,6 +627,16 @@ body:has(.lk-lab) > :has(> .lk-lab) {
627
627
  color: var(--wzl-fg-muted);
628
628
  font-size: var(--wzl-font-size-sm);
629
629
  }
630
+ .lk-trial__job {
631
+ display: flex;
632
+ gap: 8px;
633
+ align-items: center;
634
+ font-size: 0.75rem;
635
+ }
636
+ .lk-trial__job-failures,
637
+ .lk-trial__job-error {
638
+ color: var(--wsl-color-danger, #c0392b);
639
+ }
630
640
  .lk-control-panel {
631
641
  display: flex;
632
642
  flex-direction: column;
@@ -0,0 +1,77 @@
1
+ import * as react from 'react';
2
+
3
+ /** A box in CSS pixels, measured from the surface's own top-left corner. */
4
+ interface Rect {
5
+ x: number;
6
+ y: number;
7
+ w: number;
8
+ h: number;
9
+ }
10
+ /** The subset of `DOMRect` this package reads. Accepting the subset rather than
11
+ * `DOMRect` is what lets the pure functions be tested without a DOM. */
12
+ interface Box {
13
+ left: number;
14
+ top: number;
15
+ width: number;
16
+ height: number;
17
+ }
18
+
19
+ /** Every tile's box expressed against the container's origin. */
20
+ declare function composeRects(container: Box, tiles: ReadonlyMap<string, Box>): Map<string, Rect>;
21
+ declare function rectsEqual(a: Rect | undefined, b: Rect): boolean;
22
+
23
+ /**
24
+ * A DOM rect as a GL viewport rect: origin at the bottom-left, every edge snapped
25
+ * to the device-pixel grid. Still CSS pixels, because three.js applies its own
26
+ * pixel ratio — snapping here is what stops a tile and its neighbour rounding
27
+ * apart and leaving a hairline column between them.
28
+ */
29
+ declare function toDeviceRect(rect: Rect, surfaceHeight: number, dpr: number): Rect;
30
+
31
+ /** What a surface owner is handed once per animation frame. `rects` carries every
32
+ * tile, not only the dirty ones: a scissored draw has to know where it is drawing
33
+ * relative to a surface that may have resized under it. */
34
+ interface SurfaceFrame {
35
+ dirty: ReadonlySet<string>;
36
+ rects: ReadonlyMap<string, Rect>;
37
+ dpr: number;
38
+ size: {
39
+ width: number;
40
+ height: number;
41
+ };
42
+ }
43
+ /** The invalidators and the two ref callbacks that publish geometry. */
44
+ interface SurfaceHandle {
45
+ /** Mark one tile for redraw. */
46
+ invalidate: (id: string) => void;
47
+ /** Mark every tile — what a resize or a tile-set change means. */
48
+ invalidateAll: () => void;
49
+ /** Re-measure before the next frame. The escape hatch for a host that knows it
50
+ * moved something a ResizeObserver cannot see. */
51
+ invalidateRects: () => void;
52
+ registerTile: (id: string, el: HTMLElement | null) => void;
53
+ containerRef: (el: HTMLElement | null) => void;
54
+ }
55
+ interface UseTiledSurfaceOptions {
56
+ onFrame: (frame: SurfaceFrame) => void;
57
+ }
58
+ declare function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHandle;
59
+
60
+ /** Null when no surface owner is above — a lab with no shared surface at all. */
61
+ declare const SurfaceContext: react.Context<SurfaceHandle | null>;
62
+
63
+ /** The surface above, or null. Use this where a surface is genuinely optional. */
64
+ declare function useSurfaceOptional(): SurfaceHandle | null;
65
+ /** The surface above. Throws where a caller cannot work without one. */
66
+ declare function useSurface(): SurfaceHandle;
67
+ /**
68
+ * A ref callback that publishes this element's rect to the surface under `id`.
69
+ *
70
+ * Attach it to whichever element the surface should draw into — that is not
71
+ * necessarily the trial's own element, since a trial may hold a drawn pane beside
72
+ * an undrawn one, or none at all.
73
+ */
74
+ declare function useSurfaceTile(id: string): (el: HTMLElement | null) => void;
75
+
76
+ export { SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceOptional, useSurfaceTile, useTiledSurface };
77
+ export type { Box, Rect, SurfaceFrame, SurfaceHandle, UseTiledSurfaceOptions };
@@ -0,0 +1,3 @@
1
+ export { SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceOptional, useSurfaceTile, useTiledSurface } from '../chunk-THBG7FQZ.js';
2
+ //# sourceMappingURL=index.js.map
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
@@ -1,5 +1,5 @@
1
- export { LayerStack } from '../../chunk-NRKWVTVT.js';
2
- import '../../chunk-C6GJKPUI.js';
3
- import '../../chunk-DJLDIRFN.js';
1
+ export { LayerStack } from '../../chunk-G5TJVQQT.js';
2
+ import '../../chunk-73KA7WBO.js';
3
+ import '../../chunk-BOHF3PQO.js';
4
4
  //# sourceMappingURL=index.js.map
5
5
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,7 @@
1
- export { U as UndoCapability } from '../_dts/index-iAP6XbH3.js';
1
+ export { U as UndoCapability } from '../_dts/index-JFAYj5Tv.js';
2
2
  import 'react';
3
3
  import '../_dts/types-x92Kfeme.js';
4
+ import '../_dts/types-DJ79Tg5J.js';
4
5
 
5
6
  /** A subscriber to a named event. */
6
7
  type EventListener = () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -55,6 +55,14 @@
55
55
  "types": "./dist/state/index.d.ts",
56
56
  "import": "./dist/state/index.js"
57
57
  },
58
+ "./surface": {
59
+ "types": "./dist/surface/index.d.ts",
60
+ "import": "./dist/surface/index.js"
61
+ },
62
+ "./job": {
63
+ "types": "./dist/job/index.d.ts",
64
+ "import": "./dist/job/index.js"
65
+ },
58
66
  "./weasel-ui": {
59
67
  "types": "./dist/passthrough/weasel-ui.d.ts",
60
68
  "import": "./dist/passthrough/weasel-ui.js"
@@ -2,6 +2,14 @@
2
2
 
3
3
  The `src/canvas/` directory implements the layered canvas primitive used by the `canvas` instrument capability.
4
4
 
5
+ For a renderer labkit does not own — three.js, raw WebGL, anything with its own
6
+ render loop — see `src/surface/AGENTS.md` instead. `CanvasStack` is 2D and
7
+ schedules its own layers; a foreign renderer wants rects and dirtiness only.
8
+
9
+ `useOrbit` also lives here, as the 3D peer of `usePanZoom`. It produces a trial
10
+ view (`{ yaw, pitch, distance, target }`) rather than a matrix — turning that
11
+ into a camera is the host's job.
12
+
5
13
  ## Files
6
14
 
7
15
  | File | Role |
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { clampPitch, orbitAfterDrag, orbitAfterWheel, PITCH_LIMIT, wrapYaw } from './useOrbit';
3
+
4
+ const view = { yaw: 0, pitch: 0, distance: 5, target: { x: 0, y: 0, z: 0 } };
5
+
6
+ describe('clampPitch', () => {
7
+ it('stops just short of the poles, where azimuth becomes undefined', () => {
8
+ expect(clampPitch(Math.PI)).toBeCloseTo(PITCH_LIMIT);
9
+ expect(clampPitch(-Math.PI)).toBeCloseTo(-PITCH_LIMIT);
10
+ expect(PITCH_LIMIT).toBeLessThan(Math.PI / 2);
11
+ });
12
+
13
+ it('leaves an in-range pitch alone', () => {
14
+ expect(clampPitch(0.3)).toBe(0.3);
15
+ });
16
+ });
17
+
18
+ describe('wrapYaw', () => {
19
+ it('wraps into (-PI, PI] so a value cannot drift without bound', () => {
20
+ expect(wrapYaw(3 * Math.PI)).toBeCloseTo(Math.PI);
21
+ expect(wrapYaw(-3 * Math.PI)).toBeCloseTo(Math.PI);
22
+ expect(wrapYaw(0.5)).toBeCloseTo(0.5);
23
+ });
24
+
25
+ it('keeps a full turn equivalent to no turn', () => {
26
+ expect(wrapYaw(2 * Math.PI)).toBeCloseTo(0);
27
+ });
28
+ });
29
+
30
+ describe('orbitAfterDrag', () => {
31
+ it('turns horizontal movement into yaw and vertical into pitch', () => {
32
+ const next = orbitAfterDrag(view, 100, 50);
33
+ expect(next.yaw).not.toBe(view.yaw);
34
+ expect(next.pitch).not.toBe(view.pitch);
35
+ });
36
+
37
+ it('is absolute against the drag start, so re-applying does not compound', () => {
38
+ const once = orbitAfterDrag(view, 100, 50);
39
+ const twice = orbitAfterDrag(view, 100, 50);
40
+ expect(twice).toEqual(once);
41
+ });
42
+
43
+ it('clamps pitch rather than tumbling past the pole', () => {
44
+ const next = orbitAfterDrag(view, 0, 100_000);
45
+ expect(Math.abs(next.pitch)).toBeLessThanOrEqual(PITCH_LIMIT);
46
+ });
47
+
48
+ it('leaves distance and target untouched', () => {
49
+ const next = orbitAfterDrag(view, 100, 50);
50
+ expect(next.distance).toBe(view.distance);
51
+ expect(next.target).toEqual(view.target);
52
+ });
53
+ });
54
+
55
+ describe('orbitAfterWheel', () => {
56
+ it('moves the camera in and out', () => {
57
+ expect(orbitAfterWheel(view, 100, 0.5, 50).distance).toBeGreaterThan(view.distance);
58
+ expect(orbitAfterWheel(view, -100, 0.5, 50).distance).toBeLessThan(view.distance);
59
+ });
60
+
61
+ it('is multiplicative, so a step feels the same at every distance', () => {
62
+ const near = orbitAfterWheel({ ...view, distance: 2 }, 100, 0.5, 50);
63
+ const far = orbitAfterWheel({ ...view, distance: 20 }, 100, 0.5, 50);
64
+ expect(far.distance / 20).toBeCloseTo(near.distance / 2);
65
+ });
66
+
67
+ it('honours its bounds', () => {
68
+ expect(orbitAfterWheel(view, 100_000, 0.5, 50).distance).toBe(50);
69
+ expect(orbitAfterWheel(view, -100_000, 0.5, 50).distance).toBe(0.5);
70
+ });
71
+ });
@@ -0,0 +1,149 @@
1
+ import { type PointerEvent, useCallback, useRef, type WheelEvent } from 'react';
2
+
3
+ /** A point in the space the instrument works in. */
4
+ export interface Vec3 {
5
+ x: number;
6
+ y: number;
7
+ z: number;
8
+ }
9
+
10
+ /** An orbit camera as a trial view: where it looks from, and what it looks at.
11
+ * labkit does not turn this into a matrix — the host's renderer does. */
12
+ export interface OrbitView {
13
+ yaw: number;
14
+ pitch: number;
15
+ distance: number;
16
+ target: Vec3;
17
+ }
18
+
19
+ /** Just short of the pole. At exactly ±PI/2 the azimuth is undefined and the
20
+ * camera rolls, which reads as the model jumping rather than as a limit. */
21
+ export const PITCH_LIMIT = Math.PI / 2 - 0.01;
22
+
23
+ const YAW_PER_PX = 0.008;
24
+ const PITCH_PER_PX = 0.008;
25
+ const DISTANCE_PER_NOTCH = 0.0015;
26
+ const DRAG_THRESHOLD = 3;
27
+
28
+ export function clampPitch(pitch: number): number {
29
+ return Math.min(PITCH_LIMIT, Math.max(-PITCH_LIMIT, pitch));
30
+ }
31
+
32
+ export function wrapYaw(yaw: number): number {
33
+ const wrapped = ((((yaw + Math.PI) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)) - Math.PI;
34
+ return wrapped === -Math.PI ? Math.PI : wrapped;
35
+ }
36
+
37
+ /** The view a drag of (dx, dy) from `start` produces. Absolute against the drag
38
+ * start, so it can be re-applied any number of times without compounding. */
39
+ export function orbitAfterDrag(start: OrbitView, dx: number, dy: number): OrbitView {
40
+ return {
41
+ ...start,
42
+ yaw: wrapYaw(start.yaw + dx * YAW_PER_PX),
43
+ pitch: clampPitch(start.pitch + dy * PITCH_PER_PX),
44
+ };
45
+ }
46
+
47
+ /** Multiplicative, so one notch covers the same proportion of the distance
48
+ * whether the camera is near or far. */
49
+ export function orbitAfterWheel(
50
+ view: OrbitView,
51
+ deltaY: number,
52
+ minDistance: number,
53
+ maxDistance: number,
54
+ ): OrbitView {
55
+ const factor = Math.exp(deltaY * DISTANCE_PER_NOTCH);
56
+ return {
57
+ ...view,
58
+ distance: Math.min(maxDistance, Math.max(minDistance, view.distance * factor)),
59
+ };
60
+ }
61
+
62
+ export interface UseOrbitOptions {
63
+ view: OrbitView;
64
+ onViewChange: (v: OrbitView) => void;
65
+ /** Restored on double-click. Defaults to the view the hook first saw. */
66
+ home?: OrbitView;
67
+ minDistance?: number;
68
+ maxDistance?: number;
69
+ }
70
+
71
+ export interface OrbitHandlers {
72
+ onWheel: (e: WheelEvent<HTMLElement>) => void;
73
+ onPointerDown: (e: PointerEvent<HTMLElement>) => void;
74
+ onPointerMove: (e: PointerEvent<HTMLElement>) => void;
75
+ onPointerUp: (e: PointerEvent<HTMLElement>) => void;
76
+ onDoubleClick: () => void;
77
+ isDragging: () => boolean;
78
+ }
79
+
80
+ interface DragState {
81
+ pointerId: number;
82
+ startX: number;
83
+ startY: number;
84
+ startView: OrbitView;
85
+ moved: boolean;
86
+ }
87
+
88
+ /** Pointer gestures over an orbit view: drag to turn, wheel or pinch to dolly,
89
+ * double-click to go home. The 3D peer of `usePanZoom`. */
90
+ export function useOrbit({
91
+ view,
92
+ onViewChange,
93
+ home,
94
+ minDistance = 0.1,
95
+ maxDistance = 1000,
96
+ }: UseOrbitOptions): OrbitHandlers {
97
+ const dragRef = useRef<DragState | null>(null);
98
+ const viewRef = useRef(view);
99
+ viewRef.current = view;
100
+ const homeRef = useRef(home ?? view);
101
+ if (home) homeRef.current = home;
102
+
103
+ const onWheel = useCallback(
104
+ (e: WheelEvent<HTMLElement>) => {
105
+ e.preventDefault();
106
+ onViewChange(orbitAfterWheel(viewRef.current, e.deltaY, minDistance, maxDistance));
107
+ },
108
+ [onViewChange, minDistance, maxDistance],
109
+ );
110
+
111
+ const onPointerDown = useCallback((e: PointerEvent<HTMLElement>) => {
112
+ if (e.button !== 0) return;
113
+ e.currentTarget.setPointerCapture(e.pointerId);
114
+ dragRef.current = {
115
+ pointerId: e.pointerId,
116
+ startX: e.clientX,
117
+ startY: e.clientY,
118
+ startView: viewRef.current,
119
+ moved: false,
120
+ };
121
+ }, []);
122
+
123
+ const onPointerMove = useCallback(
124
+ (e: PointerEvent<HTMLElement>) => {
125
+ const drag = dragRef.current;
126
+ if (!drag || drag.pointerId !== e.pointerId) return;
127
+ const dx = e.clientX - drag.startX;
128
+ const dy = e.clientY - drag.startY;
129
+ if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
130
+ drag.moved = true;
131
+ onViewChange(orbitAfterDrag(drag.startView, dx, dy));
132
+ },
133
+ [onViewChange],
134
+ );
135
+
136
+ const onPointerUp = useCallback((e: PointerEvent<HTMLElement>) => {
137
+ const drag = dragRef.current;
138
+ if (!drag || drag.pointerId !== e.pointerId) return;
139
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
140
+ e.currentTarget.releasePointerCapture(e.pointerId);
141
+ }
142
+ dragRef.current = null;
143
+ }, []);
144
+
145
+ const onDoubleClick = useCallback(() => onViewChange(homeRef.current), [onViewChange]);
146
+ const isDragging = useCallback(() => dragRef.current?.moved === true, []);
147
+
148
+ return { onWheel, onPointerDown, onPointerMove, onPointerUp, onDoubleClick, isDragging };
149
+ }
package/src/index.test.ts CHANGED
@@ -50,3 +50,21 @@ describe('public export surface', () => {
50
50
  expect(labkit).toHaveProperty('useTrialId');
51
51
  });
52
52
  });
53
+
54
+ describe('surface, job and orbit entry points', () => {
55
+ it('are reachable from the package root', async () => {
56
+ const kit = await import('./index');
57
+ expect(typeof kit.useTiledSurface).toBe('function');
58
+ expect(typeof kit.useSurfaceTile).toBe('function');
59
+ expect(typeof kit.useSurface).toBe('function');
60
+ expect(typeof kit.useSurfaceOptional).toBe('function');
61
+ expect(typeof kit.toDeviceRect).toBe('function');
62
+ expect(typeof kit.composeRects).toBe('function');
63
+ expect(typeof kit.useOrbit).toBe('function');
64
+ expect(typeof kit.orbitAfterDrag).toBe('function');
65
+ expect(typeof kit.useJob).toBe('function');
66
+ expect(typeof kit.as2DView).toBe('function');
67
+ expect(kit.SurfaceContext).toBeDefined();
68
+ expect(kit.DEFAULT_VIEW).toEqual({ zoom: 1, pan: { x: 0, y: 0 } });
69
+ });
70
+ });
package/src/index.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  export type { CanvasLayerDescriptor, CanvasStackContextValue, CanvasStackProps } from './canvas';
2
2
  export { CanvasStack, CanvasStackContext, screenToWorld, worldToScreen } from './canvas';
3
+ export type { OrbitHandlers, OrbitView, UseOrbitOptions, Vec3 } from './canvas/useOrbit';
4
+ export {
5
+ clampPitch,
6
+ orbitAfterDrag,
7
+ orbitAfterWheel,
8
+ PITCH_LIMIT,
9
+ useOrbit,
10
+ wrapYaw,
11
+ } from './canvas/useOrbit';
3
12
  export { ControlPanel } from './controls/ControlPanel';
4
13
  export type {
5
14
  CheckboxField,
@@ -35,6 +44,7 @@ export type {
35
44
  } from './instrument/types';
36
45
  export type { ValidationResult } from './instrument/validateConfigSchema';
37
46
  export { validateConfigSchema } from './instrument/validateConfigSchema';
47
+ export * from './job';
38
48
  export * from './lab';
39
49
  export type { LayerListProps } from './layers';
40
50
  export { LayerList } from './layers';
@@ -68,6 +78,9 @@ export type {
68
78
  UndoStack,
69
79
  } from './state/types';
70
80
  export { useTrialState } from './state/useTrialState';
81
+ export type { ViewTransform2D } from './state/view';
82
+ export { as2DView, DEFAULT_VIEW } from './state/view';
83
+ export * from './surface';
71
84
  export { interstellarTheme } from './theme/interstellar';
72
85
  export * from './trial';
73
86
  export * from './ui/layers';
@@ -1,6 +1,7 @@
1
1
  import { render } from '@testing-library/react';
2
2
  import type { ReactElement } from 'react';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
+ import { DEFAULT_VIEW } from '../state/view';
4
5
  import { defineInstrument } from './defineInstrument';
5
6
  import type { RenderContext } from './types';
6
7
  import { validateConfigSchema } from './validateConfigSchema';
@@ -85,7 +86,7 @@ describe('SineWave instrument smoke test', () => {
85
86
  config: SineWaveInstrument.defaultConfig(),
86
87
  setState: vi.fn(),
87
88
  setConfig: vi.fn(),
88
- trial: { id: 'w', zoom: 1, setZoom: vi.fn() },
89
+ trial: { id: 'w', view: DEFAULT_VIEW, setView: vi.fn(), zoom: 1, setZoom: vi.fn() },
89
90
  emit: vi.fn(),
90
91
  };
91
92
  const { container } = render(SineWaveInstrument.render(ctx) as ReactElement);
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { ConfigField } from '../controls/types';
3
+ import type { JobCapability, JobHandle } from '../job/types';
3
4
 
4
5
  /** What an instrument's `render` is handed: its state and config, the setters
5
6
  * for both, the trial it is mounted in, and a way to emit named events. */
@@ -10,10 +11,18 @@ export interface RenderContext<TS = unknown, TC = unknown> {
10
11
  setConfig: (key: keyof TC, value: unknown) => void;
11
12
  trial: {
12
13
  id: string;
14
+ /** The trial's view, in whatever shape this instrument chose. labkit persists
15
+ * it and restores it on Reset without ever reading into it. */
16
+ view: unknown;
17
+ setView: (next: unknown) => void;
18
+ /** 2D convenience over `view`. Reads 1 and writes nothing when the trial holds
19
+ * a view that is not the 2D one. */
13
20
  zoom: number;
14
21
  setZoom: (z: number) => void;
15
22
  };
16
23
  emit: (event: string) => void;
24
+ /** Present only when the instrument declares a `job`. */
25
+ job?: JobHandle;
17
26
  }
18
27
 
19
28
  /** One 2D canvas layer of an instrument, drawn in declaration order.
@@ -97,10 +106,13 @@ export interface Instrument<TS = unknown, TC = unknown> {
97
106
  layers?: LayerCapability;
98
107
  dragDrop?: DragDropCapability<TS, TC>;
99
108
  undo?: UndoCapability;
109
+ /** Work too slow to do during a render. The runtime starts it, aborts it on
110
+ * unmount and on a `key` change, and renders progress into the trial. */
111
+ job?: JobCapability<TS, TC, never>;
100
112
  }
101
113
 
102
114
  /** Instruments as a lab receives them. `any` rather than `unknown` because
103
115
  * parameter contravariance keeps a `defineInstrument<TS, TC>` result out of
104
116
  * an `Instrument<unknown, unknown>[]`; it is contained to this alias. */
105
117
  // biome-ignore lint/suspicious/noExplicitAny: see above
106
- export type InstrumentList = readonly Instrument<any, any>[];
118
+ export type InstrumentList = readonly Instrument<any, any>[]; // eslint-disable-line @typescript-eslint/no-explicit-any -- contravariant TC; see above
@@ -0,0 +1,3 @@
1
+ export type { JobCapability, JobEvent, JobFailure, JobHandle, JobStatus } from './types';
2
+ export type { UseJobOptions } from './useJob';
3
+ export { useJob } from './useJob';