@wodzik/cubecore 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -24,7 +24,7 @@ headless packages also run in Node / Bun / workers.
24
24
  | `@wodzik/cubecore/bld` | Blindfolded (Old Pochmann): letter schemes (ruwix, Speffz), memo, execution followed letter by letter, a skin with letters |
25
25
  | `@wodzik/cubecore/analyze` | scramble analysis — CFOP: per cross colour the optimal cross / Cross+1…3, the best pair order (fewest moves or by F2L algorithms), OLL / PLL cases, coverage of known algorithms; Roux: per block side FB, second square + block, CMLL case, optimal LSE; ZZ: EOCross, R U L pairs, OCLL + PLL; worker |
26
26
  | `@wodzik/cubecore/element` | `<cube-player>` (algorithms at a tempo or timed solves; controls, progress bar with stage sections, 2D views, live mode), `<cube-scramble>` (follows a scramble on a smart cube; paste your own), `<cube-alg-practice>` (algorithm practice: hidden moves, hints, mistakes, TPS), `<cube-bld>` (blindfolded memo and execution), `<cube-alg>` (the text in sync) |
27
- | `@wodzik/cubecore/bluetooth` | `SmartCubeSession` over smartcube-web-bluetooth (GAN, MoYu, QiYi, GoCube, Giiker): moves timed by the cube's clock, resync, gyro, battery; skin per cube; `SimulatedCube` |
27
+ | `@wodzik/cubecore/bluetooth` | `SmartCubeSession` over smartcube-web-bluetooth (GAN, MoYu, QiYi, GoCube, Giiker): moves timed by the cube's clock, resync, gyro (calibration, drift, other brands' axes), battery; rotations / slices / wide moves read back with the gyro (`GripRecorder`, `heldTokens`); skin per cube; `SimulatedCube` |
28
28
  | `@wodzik/cubecore/react` | `<CubePlayer>`, `<CubeScramble>`, `<CubeAlgPractice>`, `<CubeAlg>`, `useSmartCube()`, `useSolverWorker()` |
29
29
 
30
30
  ## Quick start
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Grips — how a smart cube is held (which physical face is on top, which
3
+ * faces you), read from its gyroscope — and the cube rotations (x, y, z)
4
+ * between them. The orientation is the session's (calibrated: identity =
5
+ * the cube as drawn, U up and F front) — CubeRenderer.setOrientation's axes.
6
+ *
7
+ * A grip is a cubecore Frame: `grip.face.U` is the physical face on top,
8
+ * `grip.face.F` the one towards you (frameFor(bottom, front)). The identity
9
+ * grip is the cube as drawn — physical U up, F front (white top, green front
10
+ * in the western scheme) — which is also how "Reset gyro" expects it held.
11
+ *
12
+ * Physical moves (as the smart cube reports them) re-lettered for a grip are
13
+ * the moves as the solver saw them: with orange (L) in front, an L turn is
14
+ * "F" (rotations.ts heldMove).
15
+ */
16
+ import { type Face, type Frame, type Vec3 } from "../core/index.js";
17
+ import type { Quat } from "./gyro.js";
18
+ export type Grip = Frame;
19
+ export declare const IDENTITY_GRIP: Grip;
20
+ /** The grip with `top` up and `front` towards you. */
21
+ export declare const gripOf: (top: Face, front: Face) => Grip;
22
+ /** `v` turned by `q` (q v q*). */
23
+ export declare function rotateVec(q: Quat, v: Vec3): Vec3;
24
+ export interface GripReading {
25
+ grip: Grip;
26
+ /** The worse of the top / front faces' angle (degrees) from straight up / straight at you. */
27
+ offDeg: number;
28
+ }
29
+ /** The grip nearest to the cube turned by `q` (the calibrated gyro orientation, as CubeRenderer.setOrientation takes it). */
30
+ export declare function readGrip(q: Quat): GripReading;
31
+ /** The quaternion that turns the drawn cube so `frame`'s faces sit where the holder sees them. */
32
+ export declare function frameQuaternion(frame: Frame): {
33
+ x: number;
34
+ y: number;
35
+ z: number;
36
+ w: number;
37
+ };
38
+ /** The orientation (as readGrip takes it) of a cube held exactly in `grip`. */
39
+ export declare const gripQuaternion: (grip: Grip) => Quat;
40
+ /** The grip after a rotation ("x", "y'", "z2", or several: "x y"). */
41
+ export declare function rotateGrip(grip: Grip, rotation: string): Grip;
42
+ /** The shortest rotation from one grip to another ("" when the same). */
43
+ export declare function rotationBetween(from: Grip, to: Grip): string;
44
+ export interface GripChange {
45
+ from: Grip;
46
+ to: Grip;
47
+ /** When the new grip was first reached (before the dwell confirmed it). */
48
+ at: number;
49
+ /** The `mark` passed with the reading that first reached it (GripRecorder: how many moves had come). */
50
+ mark: number;
51
+ rotation: string;
52
+ }
53
+ /**
54
+ * Grip changes from a stream of gyro readings, with hysteresis: a new grip
55
+ * counts once the cube has sat within `maxOffDeg` of it for `dwellMs` — a
56
+ * wobble during a turn, or passing through a grip on the way to another
57
+ * (x2 through x), is not a rotation.
58
+ */
59
+ export declare class GripTracker {
60
+ private readonly maxOffDeg;
61
+ private readonly dwellMs;
62
+ grip: Grip | null;
63
+ private candidate;
64
+ constructor(maxOffDeg?: number, dwellMs?: number);
65
+ /** A reading at time `t`; `mark` is kept with the moment a new grip is first reached (see GripChange.mark). */
66
+ update(q: Quat, t: number, mark?: number): GripChange | null;
67
+ reset(): void;
68
+ }
69
+ /** All 24 grips (for tests and pickers). */
70
+ export declare const ALL_GRIPS: readonly Grip[];
71
+ /** "white top, green front"-style label of a grip, in physical face letters: "U/F". */
72
+ export declare const gripLabel: (g: Grip) => string;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Grips — how a smart cube is held (which physical face is on top, which
3
+ * faces you), read from its gyroscope — and the cube rotations (x, y, z)
4
+ * between them. The orientation is the session's (calibrated: identity =
5
+ * the cube as drawn, U up and F front) — CubeRenderer.setOrientation's axes.
6
+ *
7
+ * A grip is a cubecore Frame: `grip.face.U` is the physical face on top,
8
+ * `grip.face.F` the one towards you (frameFor(bottom, front)). The identity
9
+ * grip is the cube as drawn — physical U up, F front (white top, green front
10
+ * in the western scheme) — which is also how "Reset gyro" expects it held.
11
+ *
12
+ * Physical moves (as the smart cube reports them) re-lettered for a grip are
13
+ * the moves as the solver saw them: with orange (L) in front, an L turn is
14
+ * "F" (rotations.ts heldMove).
15
+ */
16
+ import { FACES, FACE_NORMAL, FRAMES, IDENTITY_FRAME, frameFor } from "../core/index.js";
17
+ export const IDENTITY_GRIP = IDENTITY_FRAME;
18
+ const OPPOSITE = { U: "D", D: "U", R: "L", L: "R", F: "B", B: "F" };
19
+ /** The grip with `top` up and `front` towards you. */
20
+ export const gripOf = (top, front) => frameFor(OPPOSITE[top], front);
21
+ /** `v` turned by `q` (q v q*). */
22
+ export function rotateVec(q, v) {
23
+ const [vx, vy, vz] = v;
24
+ // t = 2 (q.xyz × v); v' = v + w t + q.xyz × t
25
+ const tx = 2 * (q.y * vz - q.z * vy);
26
+ const ty = 2 * (q.z * vx - q.x * vz);
27
+ const tz = 2 * (q.x * vy - q.y * vx);
28
+ return [vx + q.w * tx + (q.y * tz - q.z * ty), vy + q.w * ty + (q.z * tx - q.x * tz), vz + q.w * tz + (q.x * ty - q.y * tx)];
29
+ }
30
+ /** The grip nearest to the cube turned by `q` (the calibrated gyro orientation, as CubeRenderer.setOrientation takes it). */
31
+ export function readGrip(q) {
32
+ let top = "U";
33
+ let front = "F";
34
+ let bestUp = -2;
35
+ let bestFront = -2;
36
+ for (const f of FACES) {
37
+ const n = rotateVec(q, FACE_NORMAL[f]);
38
+ if (n[1] > bestUp)
39
+ [bestUp, top] = [n[1], f];
40
+ if (n[2] > bestFront)
41
+ [bestFront, front] = [n[2], f];
42
+ }
43
+ const deg = (c) => (Math.acos(Math.max(-1, Math.min(1, c))) * 180) / Math.PI;
44
+ // top and front can only clash far from any grip (≈ 45° off on two axes).
45
+ if (OPPOSITE[top] === front || top === front)
46
+ return { grip: IDENTITY_GRIP, offDeg: 90 };
47
+ return { grip: gripOf(top, front), offDeg: Math.max(deg(bestUp), deg(bestFront)) };
48
+ }
49
+ /** The quaternion that turns the drawn cube so `frame`'s faces sit where the holder sees them. */
50
+ export function frameQuaternion(frame) {
51
+ // physical = M · canonical → turn the picture by Mᵀ.
52
+ const M = frame.matrix;
53
+ const m = [0, 1, 2].map((r) => [0, 1, 2].map((c) => M[c][r]));
54
+ const tr = m[0][0] + m[1][1] + m[2][2];
55
+ if (tr > 0) {
56
+ const s = Math.sqrt(tr + 1) * 2;
57
+ return { w: s / 4, x: (m[2][1] - m[1][2]) / s, y: (m[0][2] - m[2][0]) / s, z: (m[1][0] - m[0][1]) / s };
58
+ }
59
+ if (m[0][0] > m[1][1] && m[0][0] > m[2][2]) {
60
+ const s = Math.sqrt(1 + m[0][0] - m[1][1] - m[2][2]) * 2;
61
+ return { w: (m[2][1] - m[1][2]) / s, x: s / 4, y: (m[0][1] + m[1][0]) / s, z: (m[0][2] + m[2][0]) / s };
62
+ }
63
+ if (m[1][1] > m[2][2]) {
64
+ const s = Math.sqrt(1 + m[1][1] - m[0][0] - m[2][2]) * 2;
65
+ return { w: (m[0][2] - m[2][0]) / s, x: (m[0][1] + m[1][0]) / s, y: s / 4, z: (m[1][2] + m[2][1]) / s };
66
+ }
67
+ const s = Math.sqrt(1 + m[2][2] - m[0][0] - m[1][1]) * 2;
68
+ return { w: (m[1][0] - m[0][1]) / s, x: (m[0][2] + m[2][0]) / s, y: (m[1][2] + m[2][1]) / s, z: s / 4 };
69
+ }
70
+ /** The orientation (as readGrip takes it) of a cube held exactly in `grip`. */
71
+ export const gripQuaternion = (grip) => frameQuaternion(grip);
72
+ /** Where each rotation takes the grip: [new top, new front] as canonical faces of the old grip. */
73
+ const ROTATION_STEP = {
74
+ x: ["F", "D"], // the front comes up
75
+ y: ["U", "R"], // the right comes to the front
76
+ z: ["L", "F"], // the left comes up
77
+ };
78
+ /** The grip after a rotation ("x", "y'", "z2", or several: "x y"). */
79
+ export function rotateGrip(grip, rotation) {
80
+ let g = grip;
81
+ for (const token of rotation.split(/\s+/).filter(Boolean)) {
82
+ const axis = token[0];
83
+ const step = ROTATION_STEP[axis];
84
+ if (!step)
85
+ throw new Error(`Not a rotation: ${token}`);
86
+ const turns = token.endsWith("2") ? 2 : token.endsWith("'") ? 3 : 1;
87
+ for (let i = 0; i < turns; i++)
88
+ g = gripOf(g.face[step[0]], g.face[step[1]]);
89
+ }
90
+ return g;
91
+ }
92
+ const SINGLES = ["x", "x'", "x2", "y", "y'", "y2", "z", "z'", "z2"];
93
+ /** Every grip from every other: one rotation, else two (x or z first, then y — the usual "x y'" shape). */
94
+ const CANDIDATES = [
95
+ "",
96
+ ...SINGLES,
97
+ ...["x", "x'", "x2", "z", "z'"].flatMap((a) => ["y", "y'", "y2"].map((b) => `${a} ${b}`)),
98
+ ...["x", "x'"].flatMap((a) => ["z", "z'", "z2"].map((b) => `${a} ${b}`)),
99
+ ];
100
+ /** The shortest rotation from one grip to another ("" when the same). */
101
+ export function rotationBetween(from, to) {
102
+ if (from.id === to.id)
103
+ return "";
104
+ const r = CANDIDATES.find((c) => rotateGrip(from, c).id === to.id);
105
+ if (r === undefined)
106
+ throw new Error("unreachable grip");
107
+ return r;
108
+ }
109
+ /**
110
+ * Grip changes from a stream of gyro readings, with hysteresis: a new grip
111
+ * counts once the cube has sat within `maxOffDeg` of it for `dwellMs` — a
112
+ * wobble during a turn, or passing through a grip on the way to another
113
+ * (x2 through x), is not a rotation.
114
+ */
115
+ export class GripTracker {
116
+ maxOffDeg;
117
+ dwellMs;
118
+ grip = null;
119
+ candidate = null;
120
+ constructor(maxOffDeg = 35, dwellMs = 150) {
121
+ this.maxOffDeg = maxOffDeg;
122
+ this.dwellMs = dwellMs;
123
+ }
124
+ /** A reading at time `t`; `mark` is kept with the moment a new grip is first reached (see GripChange.mark). */
125
+ update(q, t, mark = 0) {
126
+ const { grip, offDeg } = readGrip(q);
127
+ if (offDeg > this.maxOffDeg) {
128
+ this.candidate = null;
129
+ return null;
130
+ }
131
+ if (!this.grip) {
132
+ this.grip = grip;
133
+ return null;
134
+ }
135
+ if (grip.id === this.grip.id) {
136
+ this.candidate = null;
137
+ return null;
138
+ }
139
+ if (this.candidate?.grip.id !== grip.id)
140
+ this.candidate = { grip, since: t, mark };
141
+ if (t - this.candidate.since < this.dwellMs)
142
+ return null;
143
+ const change = { from: this.grip, to: grip, at: this.candidate.since, mark: this.candidate.mark, rotation: rotationBetween(this.grip, grip) };
144
+ this.grip = grip;
145
+ this.candidate = null;
146
+ return change;
147
+ }
148
+ reset() {
149
+ this.grip = null;
150
+ this.candidate = null;
151
+ }
152
+ }
153
+ /** All 24 grips (for tests and pickers). */
154
+ export const ALL_GRIPS = FRAMES;
155
+ /** "white top, green front"-style label of a grip, in physical face letters: "U/F". */
156
+ export const gripLabel = (g) => `${g.face.U}/${g.face.F}`;
@@ -18,14 +18,53 @@ export declare function normalize(q: Quat): Quat;
18
18
  /** A cube's gyro axes → ours. GAN cubes: (x, z, −y). */
19
19
  export type AxisMap = (q: Quat) => Quat;
20
20
  export declare const GAN_AXES: AxisMap;
21
+ /**
22
+ * Other brands report their axes differently (MoYu…): a signed permutation
23
+ * of the vector part after GAN's map. "x,y,z" = GAN's; "-y,z,x" = the new x
24
+ * is the reading's −y, and so on — 48 in all (rotations and mirrorings).
25
+ * Find a cube's with `detectAxes`.
26
+ */
27
+ export declare const DEFAULT_AXES_SPEC = "x,y,z";
28
+ export declare const AXES_SPECS: readonly string[];
29
+ /** A signed permutation of a quaternion's vector part. */
30
+ export declare function permuteAxes(spec: string, q: Quat): Quat;
31
+ /** GAN's map, then `spec`. */
32
+ export declare const axesFromSpec: (spec: string) => AxisMap;
33
+ /**
34
+ * A cube's axes from raw readings (GyroCalibrator.raw): held as drawn, then
35
+ * after turning the whole cube to `expectA`, then (back, and) to `expectB` —
36
+ * the orientations those should read (e.g. grips.gripQuaternion of the grip
37
+ * after an x, and after a y). Best first, with how far off (degrees) each is.
38
+ */
39
+ export declare function detectAxes(start: Quat, afterA: Quat, afterB: Quat, expectA: Quat, expectB: Quat): {
40
+ spec: string;
41
+ errorDeg: number;
42
+ }[];
43
+ /**
44
+ * Raw readings → the orientation to show. The first reading (or `calibrate`)
45
+ * is "held as shown": identity by default, or the orientation of the view it
46
+ * was shown in (`calibrate(shown)` — e.g. a view with yellow on top). The
47
+ * calibration is kept as a raw reading, so new axes (`setAxes`) apply to it
48
+ * too. `align` snaps out drift (the yaw of a gyro wanders): the reading now
49
+ * is taken to be exactly `to`.
50
+ */
21
51
  export declare class GyroCalibrator {
22
- private readonly axes;
52
+ private axes;
23
53
  private basis;
24
- private last;
54
+ private raw;
55
+ private shown;
56
+ private anchor;
25
57
  constructor(axes?: AxisMap);
26
- /** A raw reading → the orientation to show (identity at calibration). The first reading calibrates. */
58
+ /** A raw reading → the orientation to show. The first reading calibrates. */
27
59
  orientation(raw: Quat): Quat;
28
- /** "Held as shown now": the latest reading becomes identity. */
29
- calibrate(): void;
60
+ /** The orientation now (null before any reading). */
61
+ get current(): Quat | null;
62
+ /** The latest raw reading, as the cube sent it. */
63
+ get lastRaw(): Quat | null;
64
+ /** "Held as shown now" — as drawn (identity), or as `shown`. */
65
+ calibrate(shown?: Quat): void;
66
+ /** The reading now is exactly `to` (drift out; the cube is held square). */
67
+ align(to: Quat): void;
68
+ setAxes(axes: AxisMap): void;
30
69
  reset(): void;
31
70
  }
package/bluetooth/gyro.js CHANGED
@@ -20,27 +20,123 @@ export function normalize(q) {
20
20
  return { x: q.x / l, y: q.y / l, z: q.z / l, w: q.w / l };
21
21
  }
22
22
  export const GAN_AXES = (q) => ({ x: q.x, y: q.z, z: -q.y, w: q.w });
23
+ /**
24
+ * Other brands report their axes differently (MoYu…): a signed permutation
25
+ * of the vector part after GAN's map. "x,y,z" = GAN's; "-y,z,x" = the new x
26
+ * is the reading's −y, and so on — 48 in all (rotations and mirrorings).
27
+ * Find a cube's with `detectAxes`.
28
+ */
29
+ export const DEFAULT_AXES_SPEC = "x,y,z";
30
+ export const AXES_SPECS = (() => {
31
+ const perms = [
32
+ ["x", "y", "z"],
33
+ ["x", "z", "y"],
34
+ ["y", "x", "z"],
35
+ ["y", "z", "x"],
36
+ ["z", "x", "y"],
37
+ ["z", "y", "x"],
38
+ ];
39
+ const out = [];
40
+ for (const p of perms)
41
+ for (let s = 0; s < 8; s++)
42
+ out.push(p.map((a, i) => ((s >> i) & 1 ? `-${a}` : a)).join(","));
43
+ return out;
44
+ })();
45
+ const AXIS_INDEX = { x: 0, y: 1, z: 2 };
46
+ /** A signed permutation of a quaternion's vector part. */
47
+ export function permuteAxes(spec, q) {
48
+ if (spec === DEFAULT_AXES_SPEC)
49
+ return q;
50
+ const v = [q.x, q.y, q.z];
51
+ const [x, y, z] = spec.split(",").map((t) => {
52
+ const value = v[AXIS_INDEX[t.replace("-", "")]];
53
+ return t.startsWith("-") ? -value : value;
54
+ });
55
+ return { x, y, z, w: q.w };
56
+ }
57
+ /** GAN's map, then `spec`. */
58
+ export const axesFromSpec = (spec) => (spec === DEFAULT_AXES_SPEC ? GAN_AXES : (q) => permuteAxes(spec, GAN_AXES(q)));
59
+ /** An odd signed permutation (a mirror): on a tie with a rotation, the rotation is the likelier. */
60
+ function isMirror(spec) {
61
+ const t = spec.split(",");
62
+ const order = t.map((a) => AXIS_INDEX[a.replace("-", "")]);
63
+ const inversions = (order[0] > order[1] ? 1 : 0) + (order[0] > order[2] ? 1 : 0) + (order[1] > order[2] ? 1 : 0);
64
+ return (inversions + t.filter((a) => a.startsWith("-")).length) % 2 === 1;
65
+ }
66
+ const angleDeg = (a, b) => {
67
+ const d = Math.abs(a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w);
68
+ return (2 * Math.acos(Math.min(1, d)) * 180) / Math.PI;
69
+ };
70
+ /**
71
+ * A cube's axes from raw readings (GyroCalibrator.raw): held as drawn, then
72
+ * after turning the whole cube to `expectA`, then (back, and) to `expectB` —
73
+ * the orientations those should read (e.g. grips.gripQuaternion of the grip
74
+ * after an x, and after a y). Best first, with how far off (degrees) each is.
75
+ */
76
+ export function detectAxes(start, afterA, afterB, expectA, expectB) {
77
+ const rel = (spec, a, b) => {
78
+ const map = axesFromSpec(spec);
79
+ return normalize(multiply(conjugate(normalize(map(a))), normalize(map(b))));
80
+ };
81
+ return AXES_SPECS.map((spec) => ({ spec, errorDeg: Math.max(angleDeg(rel(spec, start, afterA), expectA), angleDeg(rel(spec, start, afterB), expectB)) })).sort((a, b) => Math.round(a.errorDeg - b.errorDeg) || Number(isMirror(a.spec)) - Number(isMirror(b.spec)));
82
+ }
83
+ /**
84
+ * Raw readings → the orientation to show. The first reading (or `calibrate`)
85
+ * is "held as shown": identity by default, or the orientation of the view it
86
+ * was shown in (`calibrate(shown)` — e.g. a view with yellow on top). The
87
+ * calibration is kept as a raw reading, so new axes (`setAxes`) apply to it
88
+ * too. `align` snaps out drift (the yaw of a gyro wanders): the reading now
89
+ * is taken to be exactly `to`.
90
+ */
23
91
  export class GyroCalibrator {
24
92
  axes;
25
93
  basis = null;
26
- last = null;
94
+ raw = null;
95
+ shown = IDENTITY;
96
+ anchor = IDENTITY;
27
97
  constructor(axes = GAN_AXES) {
28
98
  this.axes = axes;
29
99
  }
30
- /** A raw reading → the orientation to show (identity at calibration). The first reading calibrates. */
100
+ /** A raw reading → the orientation to show. The first reading calibrates. */
31
101
  orientation(raw) {
32
- const q = normalize(this.axes(raw));
33
- this.last = q;
34
- this.basis ??= conjugate(q);
35
- return normalize(multiply(this.basis, q));
102
+ this.raw = raw;
103
+ this.basis ??= raw;
104
+ return this.current;
105
+ }
106
+ /** The orientation now (null before any reading). */
107
+ get current() {
108
+ if (!this.raw || !this.basis)
109
+ return null;
110
+ const rel = normalize(multiply(conjugate(normalize(this.axes(this.basis))), normalize(this.axes(this.raw))));
111
+ // The turn since calibrating is in the cube's axes as it was held then
112
+ // (`shown`): in ours it's shown·rel·shown⁻¹, applied to `shown`.
113
+ return normalize(multiply(this.anchor, multiply(this.shown, rel)));
114
+ }
115
+ /** The latest raw reading, as the cube sent it. */
116
+ get lastRaw() {
117
+ return this.raw;
36
118
  }
37
- /** "Held as shown now": the latest reading becomes identity. */
38
- calibrate() {
39
- if (this.last)
40
- this.basis = conjugate(this.last);
119
+ /** "Held as shown now" — as drawn (identity), or as `shown`. */
120
+ calibrate(shown = IDENTITY) {
121
+ if (this.raw)
122
+ this.basis = this.raw;
123
+ this.shown = shown;
124
+ this.anchor = IDENTITY;
125
+ }
126
+ /** The reading now is exactly `to` (drift out; the cube is held square). */
127
+ align(to) {
128
+ const now = this.current;
129
+ if (!now)
130
+ return;
131
+ this.anchor = normalize(multiply(multiply(to, conjugate(now)), this.anchor));
132
+ }
133
+ setAxes(axes) {
134
+ this.axes = axes;
41
135
  }
42
136
  reset() {
43
137
  this.basis = null;
44
- this.last = null;
138
+ this.raw = null;
139
+ this.shown = IDENTITY;
140
+ this.anchor = IDENTITY;
45
141
  }
46
142
  }
@@ -3,3 +3,5 @@ export * from "./gyro.js";
3
3
  export * from "./session.js";
4
4
  export * from "./simulated.js";
5
5
  export * from "./cubeSkins.js";
6
+ export * from "./grips.js";
7
+ export * from "./rotations.js";
@@ -3,3 +3,5 @@ export * from "./gyro.js";
3
3
  export * from "./session.js";
4
4
  export * from "./simulated.js";
5
5
  export * from "./cubeSkins.js";
6
+ export * from "./grips.js";
7
+ export * from "./rotations.js";
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Cube rotations with a smart cube's moves: what the solver actually did.
3
+ *
4
+ * A smart cube reports face turns relative to its core (the centres) — so
5
+ * after a y regrip an "R" is what the solver called F, and a slice or wide
6
+ * move arrives as face turns while the core turns with it (S = F' B + z,
7
+ * r = L + x, M2 = R' L R' L + x2 when done as two quarters). With the
8
+ * gyroscope (the core's orientation) both can be read back:
9
+ *
10
+ * const rec = new GripRecorder(session); // follows grips as moves come in
11
+ * …
12
+ * const r = rec.forMoves(first, count, t0); // { startRotation, rotations } of those moves
13
+ * heldTokens(moves, r.startRotation, r.rotations)
14
+ * // → [R, U, y, F, M2, …] — moves as the solver named them, regrips, slices
15
+ *
16
+ * GripRecorder marks each rotation with the number of moves that had come
17
+ * before it in the session's stream — the order the cube sent them in, far
18
+ * more reliable than comparing times (a gyro reading carries its arrival
19
+ * time; a move its time on the cube's own clock). heldTokens still allows
20
+ * a slice's rotation to come in a little early or late (a cube held loosely
21
+ * while recognising the next case settles late).
22
+ */
23
+ import { type Grip } from "./grips.js";
24
+ import type { SmartCubeSession } from "./session.js";
25
+ /** A face turn as reported (physical face letters) and when (ms, any origin — the same as the rotations'). */
26
+ export interface TimedMove {
27
+ move: string;
28
+ t: number;
29
+ }
30
+ /** A cube rotation among a list of moves. */
31
+ export interface RotationRecord {
32
+ /** How many of the moves came before it. */
33
+ after: number;
34
+ /** When (ms, the moves' origin). */
35
+ t: number;
36
+ /** "x", "y'", "z2", or two: "x y". */
37
+ move: string;
38
+ }
39
+ export type HeldToken = {
40
+ kind: "rotation";
41
+ move: string;
42
+ t: number;
43
+ after: number;
44
+ } | {
45
+ kind: "move";
46
+ /** As the solver named it (a slice / wide move for a group). */
47
+ move: string;
48
+ t: number;
49
+ /** Index of its (first) reported move. */
50
+ index: number;
51
+ /** A slice / wide move made of several reported moves: the last one's index. */
52
+ lastIndex?: number;
53
+ };
54
+ /** A reported (physical) move ("L'", "R2") as the holder of `grip` names it. */
55
+ export declare function heldMove(move: string, grip: Grip): string;
56
+ /**
57
+ * The moves as the solver saw them: re-lettered for the grip each was made
58
+ * in (`startRotation` from white top / green front — U up, F front — then
59
+ * the rotations), rotations between them (back-to-back ones combined: y y =
60
+ * y2, y y' = nothing), and slices / wide moves read from face moves with
61
+ * the core's rotation.
62
+ */
63
+ export declare function heldTokens(moves: readonly TimedMove[], startRotation: string, rotations: readonly RotationRecord[]): HeldToken[];
64
+ /** A rotation and the face moves around it that make a slice / wide move → that move. */
65
+ export declare function mergeSlicesAndWides(tokens: readonly HeldToken[]): HeldToken[];
66
+ export interface GripEvent {
67
+ /** Moves the session had reported before it (its stream position). */
68
+ after: number;
69
+ /** When it was first reached (performance.now()). */
70
+ time: number;
71
+ grip: Grip;
72
+ /** From the grip before ("" for the first reading). */
73
+ rotation: string;
74
+ }
75
+ export interface GripRecorderOptions {
76
+ /** A grip counts when held within this many degrees of it … */
77
+ maxOffDeg?: number;
78
+ /** … for this long (ms). */
79
+ dwellMs?: number;
80
+ /** Grip changes and move times kept (the latest). */
81
+ keep?: number;
82
+ }
83
+ /**
84
+ * Follows a session's grips (gyro) alongside its moves; `forMoves` gives
85
+ * the rotations of any stretch of moves — a solve, an attempt.
86
+ */
87
+ export declare class GripRecorder {
88
+ private readonly session;
89
+ readonly history: GripEvent[];
90
+ private readonly tracker;
91
+ /** Times of the session's latest moves; moveTimes[i] is move number firstMove + i. */
92
+ private moveTimes;
93
+ private firstMove;
94
+ private readonly keep;
95
+ private readonly listeners;
96
+ private readonly offs;
97
+ constructor(session: SmartCubeSession, options?: GripRecorderOptions);
98
+ /** The grip now (null before the gyro has read one). */
99
+ get grip(): Grip | null;
100
+ /** On every grip change (and the first grip). */
101
+ onChange(fn: (e: GripEvent) => void): () => void;
102
+ /** Forget the grip (after a recalibration: the next reading starts again). */
103
+ reset(): void;
104
+ stop(): void;
105
+ private reading;
106
+ /** The session's number of the (first) move reported at `time` — a move event's time — or null if not kept. Moves can share a time (a slice's two faces). */
107
+ moveNumberAt(time: number): number | null;
108
+ /** The grip in effect before move number `n`, or null. */
109
+ gripBefore(n: number): Grip | null;
110
+ /**
111
+ * The rotations of `count` moves from move number `first`: how the cube
112
+ * was held when they started (`startRotation`, from U up / F front) and
113
+ * each rotation among them (`after` counted within them, `t` from
114
+ * `startTime`). Null without a grip then.
115
+ */
116
+ forMoves(first: number, count: number, startTime: number): {
117
+ startRotation: string;
118
+ rotations: RotationRecord[];
119
+ } | null;
120
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Cube rotations with a smart cube's moves: what the solver actually did.
3
+ *
4
+ * A smart cube reports face turns relative to its core (the centres) — so
5
+ * after a y regrip an "R" is what the solver called F, and a slice or wide
6
+ * move arrives as face turns while the core turns with it (S = F' B + z,
7
+ * r = L + x, M2 = R' L R' L + x2 when done as two quarters). With the
8
+ * gyroscope (the core's orientation) both can be read back:
9
+ *
10
+ * const rec = new GripRecorder(session); // follows grips as moves come in
11
+ * …
12
+ * const r = rec.forMoves(first, count, t0); // { startRotation, rotations } of those moves
13
+ * heldTokens(moves, r.startRotation, r.rotations)
14
+ * // → [R, U, y, F, M2, …] — moves as the solver named them, regrips, slices
15
+ *
16
+ * GripRecorder marks each rotation with the number of moves that had come
17
+ * before it in the session's stream — the order the cube sent them in, far
18
+ * more reliable than comparing times (a gyro reading carries its arrival
19
+ * time; a move its time on the cube's own clock). heldTokens still allows
20
+ * a slice's rotation to come in a little early or late (a cube held loosely
21
+ * while recognising the next case settles late).
22
+ */
23
+ import { applyMoves, solvedState } from "../core/index.js";
24
+ import { GripTracker, IDENTITY_GRIP, rotateGrip, rotationBetween } from "./grips.js";
25
+ /** A reported (physical) move ("L'", "R2") as the holder of `grip` names it. */
26
+ export function heldMove(move, grip) {
27
+ const face = move[0];
28
+ const canonical = Object.keys(grip.face).find((c) => grip.face[c] === face);
29
+ return canonical ? canonical + move.slice(1) : move;
30
+ }
31
+ /**
32
+ * The moves as the solver saw them: re-lettered for the grip each was made
33
+ * in (`startRotation` from white top / green front — U up, F front — then
34
+ * the rotations), rotations between them (back-to-back ones combined: y y =
35
+ * y2, y y' = nothing), and slices / wide moves read from face moves with
36
+ * the core's rotation.
37
+ */
38
+ export function heldTokens(moves, startRotation, rotations) {
39
+ let grip = rotateGrip(IDENTITY_GRIP, startRotation);
40
+ const rots = snapToSlices(moves, grip, [...rotations].sort((a, b) => a.after - b.after || a.t - b.t));
41
+ const tokens = [];
42
+ let r = 0;
43
+ for (let i = 0; i <= moves.length; i++) {
44
+ let target = grip;
45
+ let t = 0;
46
+ while (r < rots.length && rots[r].after <= i) {
47
+ target = rotateGrip(target, rots[r].move);
48
+ t = rots[r].t;
49
+ r++;
50
+ }
51
+ const net = rotationBetween(grip, target);
52
+ if (net)
53
+ tokens.push({ kind: "rotation", move: net, t, after: i });
54
+ grip = target;
55
+ if (i < moves.length)
56
+ tokens.push({ kind: "move", move: heldMove(moves[i].move, grip), t: moves[i].t, index: i });
57
+ }
58
+ return mergeSlicesAndWides(tokens);
59
+ }
60
+ // ─── slices and wide moves ──────────────────────────────────────────────
61
+ /** A slice / wide move's face moves and its rotation, reported this far apart (ms), are one move. */
62
+ const SAME_MOMENT_MS = 500;
63
+ /** How far (ms) a rotation is looked for from the face moves of a wide move. */
64
+ const SNAP_MS = 600;
65
+ /** Face moves one slice / wide move can come as: a half slice turn done as two quarters is four (R' L R' L). */
66
+ const MAX_GROUP = 4;
67
+ /** A slice's rotation can come in this late (ms) — past a couple of other moves — when the cube wasn't held still. */
68
+ const SLICE_LATE_MS = 2500;
69
+ const SLICE_SKIP = 2;
70
+ const AXIS_FACES = { x: ["R", "L"], y: ["U", "D"], z: ["F", "B"] };
71
+ const SLICE_WIDE = ["M", "E", "S", "r", "l", "u", "d", "f", "b"].flatMap((f) => [f, `${f}'`, `${f}2`]);
72
+ const effect = (alg) => applyMoves(solvedState(), alg).join();
73
+ const SLICE_WIDE_EFFECT = new Map();
74
+ /** The slice / wide move the group (held letters and a rotation) amounts to, or null. */
75
+ function sliceOrWide(group) {
76
+ if (SLICE_WIDE_EFFECT.size === 0)
77
+ for (const m of SLICE_WIDE)
78
+ SLICE_WIDE_EFFECT.set(effect(m), m);
79
+ return SLICE_WIDE_EFFECT.get(effect(group.join(" "))) ?? null;
80
+ }
81
+ /**
82
+ * The consecutive run (up to MAX_GROUP) of moves on the axis nearest to
83
+ * `from` going `dir`, skipping at most SLICE_SKIP other moves before it,
84
+ * within SLICE_LATE_MS of the rotation.
85
+ */
86
+ function axisRun(moves, from, dir, onAxis, t) {
87
+ let j = from;
88
+ let skipped = 0;
89
+ while (j >= 0 && j < moves.length && !onAxis(j) && skipped < SLICE_SKIP && Math.abs(moves[j].t - t) <= SLICE_LATE_MS) {
90
+ j += dir;
91
+ skipped++;
92
+ }
93
+ const run = [];
94
+ while (j >= 0 && j < moves.length && onAxis(j) && run.length < MAX_GROUP && Math.abs(moves[j].t - t) <= SLICE_LATE_MS) {
95
+ if (dir === -1)
96
+ run.unshift(j);
97
+ else
98
+ run.push(j);
99
+ j += dir;
100
+ }
101
+ return run;
102
+ }
103
+ /**
104
+ * A slice's rotation can come in a move or two away from its face moves
105
+ * (F' B U L z): put it right after (or before) the moves on its axis it
106
+ * makes a slice / wide move with — the moves in between, made after the
107
+ * centres had turned, get lettered accordingly. Far from them, only a
108
+ * slice's signature (both faces of the axis) is trusted: one face and a
109
+ * rotation could as well be a face turn and a real regrip.
110
+ */
111
+ function snapToSlices(moves, startGrip, rotations) {
112
+ let grip = startGrip;
113
+ const out = rotations.map((r) => ({ ...r }));
114
+ for (const r of out) {
115
+ const faces = AXIS_FACES[r.move[0]];
116
+ if (faces && !/\s/.test(r.move)) {
117
+ const physical = faces.map((f) => grip.face[f]);
118
+ const onAxis = (j) => physical.includes(moves[j].move[0]);
119
+ const letters = (idx) => idx.map((j) => heldMove(moves[j].move, grip));
120
+ const back = axisRun(moves, r.after - 1, -1, onAxis, r.t);
121
+ const fwd = axisRun(moves, r.after, 1, onAxis, r.t);
122
+ const tries = [];
123
+ for (let n = MAX_GROUP; n >= 1; n--)
124
+ if (back.length >= n)
125
+ tries.push([back.slice(-n), back.at(-1) + 1]);
126
+ for (let n = MAX_GROUP; n >= 1; n--)
127
+ if (fwd.length >= n)
128
+ tries.push([fwd.slice(0, n), fwd[0]]);
129
+ for (const [idx, after] of tries) {
130
+ const far = idx.some((j) => Math.abs(moves[j].t - r.t) > SNAP_MS);
131
+ if (far && new Set(idx.map((j) => moves[j].move[0])).size < 2)
132
+ continue;
133
+ if (sliceOrWide([...letters(idx), r.move])) {
134
+ r.after = after;
135
+ // It happened with those moves: its time is theirs.
136
+ r.t = moves[after > idx[0] ? idx.at(-1) : idx[0]].t;
137
+ break;
138
+ }
139
+ }
140
+ }
141
+ grip = rotateGrip(grip, r.move);
142
+ }
143
+ return out.sort((a, b) => a.after - b.after || a.t - b.t);
144
+ }
145
+ /** A rotation and the face moves around it that make a slice / wide move → that move. */
146
+ export function mergeSlicesAndWides(tokens) {
147
+ const out = [...tokens];
148
+ for (let k = 0; k < out.length; k++) {
149
+ const rot = out[k];
150
+ if (rot.kind !== "rotation" || /\s/.test(rot.move))
151
+ continue;
152
+ // The most moves around it first — a slice half turn made of quarters
153
+ // is four (M2: R' L R' L + x2), a slice two, a wide move one.
154
+ const windows = [];
155
+ for (let n = MAX_GROUP; n >= 1; n--)
156
+ for (let o = 0; o <= n; o++)
157
+ windows.push([k - n + o, k + o]);
158
+ for (const [a, b] of windows) {
159
+ if (a < 0 || b >= out.length)
160
+ continue;
161
+ const group = out.slice(a, b + 1);
162
+ const groupMoves = group.filter((t) => t.kind === "move");
163
+ if (groupMoves.length !== group.length - 1 || groupMoves.some((m) => Math.abs(m.t - rot.t) > SAME_MOMENT_MS))
164
+ continue;
165
+ const merged = sliceOrWide(group.map((t) => t.move));
166
+ if (!merged)
167
+ continue;
168
+ const first = groupMoves[0];
169
+ out.splice(a, b - a + 1, { kind: "move", move: merged, t: first.t, index: first.index, lastIndex: groupMoves.at(-1).index });
170
+ k = a;
171
+ break;
172
+ }
173
+ }
174
+ return out;
175
+ }
176
+ /**
177
+ * Follows a session's grips (gyro) alongside its moves; `forMoves` gives
178
+ * the rotations of any stretch of moves — a solve, an attempt.
179
+ */
180
+ export class GripRecorder {
181
+ session;
182
+ history = [];
183
+ tracker;
184
+ /** Times of the session's latest moves; moveTimes[i] is move number firstMove + i. */
185
+ moveTimes = [];
186
+ firstMove = 0;
187
+ keep;
188
+ listeners = new Set();
189
+ offs;
190
+ constructor(session, options = {}) {
191
+ this.session = session;
192
+ this.tracker = new GripTracker(options.maxOffDeg ?? 35, options.dwellMs ?? 150);
193
+ this.keep = options.keep ?? 1000;
194
+ this.offs = [
195
+ session.on("move", (e) => {
196
+ this.moveTimes.push(e.time);
197
+ if (this.moveTimes.length > this.keep) {
198
+ this.moveTimes.shift();
199
+ this.firstMove++;
200
+ }
201
+ }),
202
+ session.on("orientation", (q) => this.reading(q, performance.now())),
203
+ ];
204
+ }
205
+ /** The grip now (null before the gyro has read one). */
206
+ get grip() {
207
+ return this.tracker.grip;
208
+ }
209
+ /** On every grip change (and the first grip). */
210
+ onChange(fn) {
211
+ this.listeners.add(fn);
212
+ return () => this.listeners.delete(fn);
213
+ }
214
+ /** Forget the grip (after a recalibration: the next reading starts again). */
215
+ reset() {
216
+ this.tracker.reset();
217
+ }
218
+ stop() {
219
+ this.offs.forEach((off) => off());
220
+ }
221
+ reading(q, now) {
222
+ const count = this.session.moveCount;
223
+ const wasEmpty = this.tracker.grip === null;
224
+ const change = this.tracker.update(q, now, count);
225
+ let e = null;
226
+ if (wasEmpty && this.tracker.grip)
227
+ e = { after: count, time: now, grip: this.tracker.grip, rotation: "" };
228
+ else if (change)
229
+ e = { after: change.mark, time: change.at, grip: change.to, rotation: change.rotation };
230
+ if (!e)
231
+ return;
232
+ this.history.push(e);
233
+ if (this.history.length > this.keep)
234
+ this.history.shift();
235
+ this.listeners.forEach((l) => l(e));
236
+ }
237
+ /** The session's number of the (first) move reported at `time` — a move event's time — or null if not kept. Moves can share a time (a slice's two faces). */
238
+ moveNumberAt(time) {
239
+ const i = this.moveTimes.indexOf(time);
240
+ return i < 0 ? null : this.firstMove + i;
241
+ }
242
+ /** The grip in effect before move number `n`, or null. */
243
+ gripBefore(n) {
244
+ let g = null;
245
+ for (const e of this.history) {
246
+ if (e.after > n)
247
+ break;
248
+ g = e.grip;
249
+ }
250
+ return g;
251
+ }
252
+ /**
253
+ * The rotations of `count` moves from move number `first`: how the cube
254
+ * was held when they started (`startRotation`, from U up / F front) and
255
+ * each rotation among them (`after` counted within them, `t` from
256
+ * `startTime`). Null without a grip then.
257
+ */
258
+ forMoves(first, count, startTime) {
259
+ const start = this.gripBefore(first);
260
+ if (!start)
261
+ return null;
262
+ const rotations = this.history
263
+ .filter((e) => e.rotation && e.after > first && e.after < first + count)
264
+ .map((e) => ({ after: e.after - first, t: Math.max(0, Math.round(e.time - startTime)), move: e.rotation }));
265
+ return { startRotation: rotationBetween(IDENTITY_GRIP, start), rotations };
266
+ }
267
+ }
@@ -17,6 +17,7 @@ import { type Move, type State } from "../core/index.js";
17
17
  import type { ConnectSmartCubeOptions, SmartCubeCapabilities, SmartCubeCommand, SmartCubeEvent } from "./vendor/smartcube-web-bluetooth/index.js";
18
18
  import type { Skin } from "../skin/index.js";
19
19
  import { type AxisMap, type Quat } from "./gyro.js";
20
+ import { type Grip } from "./grips.js";
20
21
  /** What the session needs from a connection — smartcube-web-bluetooth's, or a SimulatedCube. */
21
22
  export interface CubeConnection {
22
23
  readonly deviceName: string;
@@ -67,8 +68,8 @@ interface Events {
67
68
  export interface SessionOptions {
68
69
  /** A move is `late` when it arrives this much after it happened. Default 1000 ms. */
69
70
  lateMs?: number;
70
- /** Gyro axes of this cube (default: GAN). */
71
- axes?: AxisMap;
71
+ /** Gyro axes of this cube (default: GAN) — a map, or a signed permutation after GAN's ("x,y,z"…, see gyro.ts AXES_SPECS). */
72
+ axes?: AxisMap | string;
72
73
  /**
73
74
  * For a cube that can't reset its own state (QiYi…): the state of it that
74
75
  * counts as solved, kept from an earlier connection (the `base` event) —
@@ -90,6 +91,7 @@ export declare class SmartCubeSession {
90
91
  private readonly subscription;
91
92
  private readonly lateMs;
92
93
  private connected;
94
+ private moves;
93
95
  /** Ask the browser for a cube and connect (needs a user gesture; Chrome / Edge / Bluefy). */
94
96
  static connect(options?: ConnectSmartCubeOptions, session?: SessionOptions): Promise<SmartCubeSession>;
95
97
  constructor(connection: CubeConnection, options?: SessionOptions);
@@ -109,8 +111,25 @@ export declare class SmartCubeSession {
109
111
  get suggestedSkin(): Skin;
110
112
  get isConnected(): boolean;
111
113
  on<K extends keyof Events>(type: K, listener: (e: Events[K]) => void): () => void;
112
- /** "Held as shown now" for the gyro. */
113
- calibrate(): void;
114
+ /**
115
+ * "Held as shown now" for the gyro: as drawn (U up, F front), or as
116
+ * `shown` — the orientation of the view the cube is shown in.
117
+ */
118
+ calibrate(shown?: Quat): void;
119
+ /** The gyro's orientation now (calibrated), or null (none yet / no gyro). */
120
+ get orientation(): Quat | null;
121
+ /** The latest gyro reading as the cube sent it (for finding a cube's axes — gyro.ts detectAxes). */
122
+ get rawOrientation(): Quat | null;
123
+ /** Another brand's gyro axes, while connected — the calibration is kept. */
124
+ setAxes(axes: AxisMap | string): void;
125
+ /**
126
+ * The cube is held square now — snap the gyro onto the nearest grip (its
127
+ * yaw drifts over minutes). Nothing when it's more than `maxOffDeg` off
128
+ * any grip. Returns the grip, or null.
129
+ */
130
+ alignToGrip(maxOffDeg?: number): Grip | null;
131
+ /** Moves reported so far on this connection (a move event's number is the count before it). */
132
+ get moveCount(): number;
114
133
  /**
115
134
  * Tell the session (and the cube, if it can) that the cube is solved now.
116
135
  * Cubes that can't reset their own state (QiYi, …) keep reporting it: the
@@ -16,7 +16,8 @@
16
16
  import { applyMove, parseAlg, relativeState, solvedState, stateFromFacelets, statesEqual } from "../core/index.js";
17
17
  import { ClockSync } from "./clock.js";
18
18
  import { skinForCube } from "./cubeSkins.js";
19
- import { GAN_AXES, GyroCalibrator } from "./gyro.js";
19
+ import { GAN_AXES, GyroCalibrator, axesFromSpec } from "./gyro.js";
20
+ import { gripQuaternion, readGrip } from "./grips.js";
20
21
  export class SmartCubeSession {
21
22
  connection;
22
23
  listeners = new Map();
@@ -28,6 +29,7 @@ export class SmartCubeSession {
28
29
  subscription;
29
30
  lateMs;
30
31
  connected = true;
32
+ moves = 0;
31
33
  /** Ask the browser for a cube and connect (needs a user gesture; Chrome / Edge / Bluefy). */
32
34
  static async connect(options, session) {
33
35
  const { connectSmartCube } = await import("./vendor/smartcube-web-bluetooth/index.js");
@@ -36,7 +38,7 @@ export class SmartCubeSession {
36
38
  constructor(connection, options = {}) {
37
39
  this.connection = connection;
38
40
  this.lateMs = options.lateMs ?? 1000;
39
- this.gyro = new GyroCalibrator(options.axes ?? GAN_AXES);
41
+ this.gyro = new GyroCalibrator(typeof options.axes === "string" ? axesFromSpec(options.axes) : (options.axes ?? GAN_AXES));
40
42
  const base = typeof options.base === "function" ? options.base({ name: connection.deviceName, mac: connection.deviceMAC ?? null }) : options.base;
41
43
  this._base = base ?? null;
42
44
  this.subscription = connection.events$.subscribe((e) => this.onEvent(e));
@@ -74,9 +76,43 @@ export class SmartCubeSession {
74
76
  set.add(listener);
75
77
  return () => set.delete(listener);
76
78
  }
77
- /** "Held as shown now" for the gyro. */
78
- calibrate() {
79
- this.gyro.calibrate();
79
+ /**
80
+ * "Held as shown now" for the gyro: as drawn (U up, F front), or as
81
+ * `shown` — the orientation of the view the cube is shown in.
82
+ */
83
+ calibrate(shown) {
84
+ this.gyro.calibrate(shown);
85
+ }
86
+ /** The gyro's orientation now (calibrated), or null (none yet / no gyro). */
87
+ get orientation() {
88
+ return this.gyro.current;
89
+ }
90
+ /** The latest gyro reading as the cube sent it (for finding a cube's axes — gyro.ts detectAxes). */
91
+ get rawOrientation() {
92
+ return this.gyro.lastRaw;
93
+ }
94
+ /** Another brand's gyro axes, while connected — the calibration is kept. */
95
+ setAxes(axes) {
96
+ this.gyro.setAxes(typeof axes === "string" ? axesFromSpec(axes) : axes);
97
+ }
98
+ /**
99
+ * The cube is held square now — snap the gyro onto the nearest grip (its
100
+ * yaw drifts over minutes). Nothing when it's more than `maxOffDeg` off
101
+ * any grip. Returns the grip, or null.
102
+ */
103
+ alignToGrip(maxOffDeg = 35) {
104
+ const now = this.gyro.current;
105
+ if (!now)
106
+ return null;
107
+ const { grip, offDeg } = readGrip(now);
108
+ if (offDeg > maxOffDeg)
109
+ return null;
110
+ this.gyro.align(gripQuaternion(grip));
111
+ return grip;
112
+ }
113
+ /** Moves reported so far on this connection (a move event's number is the count before it). */
114
+ get moveCount() {
115
+ return this.moves;
80
116
  }
81
117
  /**
82
118
  * Tell the session (and the cube, if it can) that the cube is solved now.
@@ -147,6 +183,7 @@ export class SmartCubeSession {
147
183
  }
148
184
  const state = applyMove(this._state, move);
149
185
  this._state = state;
186
+ this.moves++;
150
187
  this.emit("move", { move, time, cubeTime: e.cubeTimestamp, late: e.timestamp - time > this.lateMs, state });
151
188
  this.emit("state", { state, reason: "move" });
152
189
  break;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.4",
6
+ "version": "0.1.5",
7
7
  "description": "A 3×3×3 cube library for the web, built around smart (Bluetooth) cubes: state, notation, methods, solvers and scrambles, a three.js renderer with skins, pictures, web components, React bindings.",
8
8
  "license": "MPL-2.0",
9
9
  "author": "Tomasz Wodzikowski <wodziszczakow@gmail.com>",