@wodzik/cubecore 0.1.3 → 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 +1 -1
- package/bluetooth/grips.d.ts +72 -0
- package/bluetooth/grips.js +156 -0
- package/bluetooth/gyro.d.ts +44 -5
- package/bluetooth/gyro.js +107 -11
- package/bluetooth/index.d.ts +2 -0
- package/bluetooth/index.js +2 -0
- package/bluetooth/rotations.d.ts +120 -0
- package/bluetooth/rotations.js +267 -0
- package/bluetooth/session.d.ts +23 -4
- package/bluetooth/session.js +42 -5
- package/package.json +1 -1
- package/render/renderer.js +7 -1
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}`;
|
package/bluetooth/gyro.d.ts
CHANGED
|
@@ -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
|
|
52
|
+
private axes;
|
|
23
53
|
private basis;
|
|
24
|
-
private
|
|
54
|
+
private raw;
|
|
55
|
+
private shown;
|
|
56
|
+
private anchor;
|
|
25
57
|
constructor(axes?: AxisMap);
|
|
26
|
-
/** A raw reading → the orientation to show
|
|
58
|
+
/** A raw reading → the orientation to show. The first reading calibrates. */
|
|
27
59
|
orientation(raw: Quat): Quat;
|
|
28
|
-
/**
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
100
|
+
/** A raw reading → the orientation to show. The first reading calibrates. */
|
|
31
101
|
orientation(raw) {
|
|
32
|
-
|
|
33
|
-
this.
|
|
34
|
-
this.
|
|
35
|
-
|
|
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"
|
|
38
|
-
calibrate() {
|
|
39
|
-
if (this.
|
|
40
|
-
this.basis =
|
|
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.
|
|
138
|
+
this.raw = null;
|
|
139
|
+
this.shown = IDENTITY;
|
|
140
|
+
this.anchor = IDENTITY;
|
|
45
141
|
}
|
|
46
142
|
}
|
package/bluetooth/index.d.ts
CHANGED
package/bluetooth/index.js
CHANGED
|
@@ -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
|
+
}
|
package/bluetooth/session.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
113
|
-
|
|
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
|
package/bluetooth/session.js
CHANGED
|
@@ -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
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
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>",
|
package/render/renderer.js
CHANGED
|
@@ -223,12 +223,18 @@ export class CubeRenderer {
|
|
|
223
223
|
disposeArrows(this.arrows.group);
|
|
224
224
|
this.resizeObserver.disconnect();
|
|
225
225
|
this.finishAll(true);
|
|
226
|
-
|
|
226
|
+
// Scene resources first, while the context is still there to free them in…
|
|
227
227
|
for (const m of this.materials.values())
|
|
228
228
|
m.dispose();
|
|
229
229
|
this.envMap?.dispose();
|
|
230
230
|
this.kit?.dispose();
|
|
231
231
|
this.attachments?.dispose();
|
|
232
|
+
this.renderer.dispose();
|
|
233
|
+
// …then give the WebGL context back now. dispose() alone leaves it alive
|
|
234
|
+
// until garbage collection, and a page that mounts cubes as they scroll
|
|
235
|
+
// into view soon passes the browser's limit (~16): "Too many active WebGL
|
|
236
|
+
// contexts. Oldest context will be lost."
|
|
237
|
+
this.renderer.forceContextLoss();
|
|
232
238
|
this.canvas.remove();
|
|
233
239
|
}
|
|
234
240
|
// ─── scene ───
|