@wodzik/cubecore 0.1.10 → 0.1.12

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
@@ -13,8 +13,8 @@ headless packages also run in Node / Bun / workers.
13
13
 
14
14
  | package | what |
15
15
  |---|---|
16
- | `@wodzik/cubecore/core` | state (sticker permutation + centre spins), notation (parse / format / invert / simplify / mirror, `(…)3`, `[A, B]`, `.` pauses, comments, source ranges), metrics, 24 frames and colour neutrality, check primitives, method engine (`Method`, `MethodTracker`), masks, pieces view (Kociemba), 15-char state codec, facelet strings, face turns as a smart cube reports them (`toFaceTurns`, `OrientationTracker`), live move log (`MoveCollapser`: R R → R2, R L' → M), following a scramble / algorithm (`SequenceTracker`) |
17
- | `@wodzik/cubecore/cfop`, `roux`, `zz`, `petrus`, `lbl` | methods: stages, their checks, masks, trainer stages; CFOP: OLL / PLL recognition (colour neutral, speedcubedb numbering and names; which case came up after F2L / OLL in a solve) |
16
+ | `@wodzik/cubecore/core` | state (sticker permutation + centre spins), notation (parse / format / invert / simplify / mirror, `(…)3`, `[A, B]`, `.` pauses, comments, source ranges), metrics, 24 frames and colour neutrality, check primitives, method engine (`Method`, `MethodTracker`), masks, pieces view (Kociemba), 15-char state codec, facelet strings, face turns as a smart cube reports them (`toFaceTurns`, `OrientationTracker`), live move log (`MoveCollapser`: R R → R2, R L' → M), following a scramble / algorithm (`SequenceTracker`), which algorithm moves did — by effect, any notation (`AlgMatcher`) |
17
+ | `@wodzik/cubecore/cfop`, `roux`, `zz`, `petrus`, `lbl` | methods: stages, their checks, masks, trainer stages; CFOP: F2L / OLL / PLL recognition (colour neutral, speedcubedb numbering and names; which case came up after F2L / OLL in a solve), F2L tables from any set (`F2LCaseTable` — e.g. advanced F2L, a piece stuck in another slot) |
18
18
  | `@wodzik/cubecore/methods` | all methods together, masks by name |
19
19
  | `@wodzik/cubecore/solve` | two-phase solver (≤ 21 moves, ms), optimal stage solvers (cross, EOCross, xcross, xxcross, slot, Roux blocks), random-state scrambles with presets, trainer scrambles (a stage in exactly N moves), from the cube's current state, any face; Web Worker with a Promise API; tables kept in IndexedDB |
20
20
  | `@wodzik/cubecore/timeline` | recordings, replay clock (moves end at their recorded time), stage timings, sections for progress bars, pause compression, recording and share codecs |
package/cfop/f2l.d.ts CHANGED
@@ -26,3 +26,33 @@ export declare function isStandardF2L(s: State, slot: F2LSlot): boolean;
26
26
  * (not one of the 41).
27
27
  */
28
28
  export declare function recognizeF2L(s: State, slot: F2LSlot): F2LMatch | "solved" | null;
29
+ /** A case of an F2L set, for one slot: its algorithm as held (cross on D, inserting into that slot). */
30
+ export interface F2LCaseSource {
31
+ id: string;
32
+ alg: string;
33
+ }
34
+ export interface F2LSetMatch {
35
+ id: string;
36
+ slot: F2LSlot;
37
+ /** The U turn before the algorithm (moves only pieces in the top layer). */
38
+ preAuf: string;
39
+ alg: string;
40
+ }
41
+ /**
42
+ * Recognition for any set of F2L cases — e.g. "advanced F2L", where a piece
43
+ * of the pair is stuck in another slot. The case's state is its algorithm
44
+ * undone on a solved cube, and a case is told by where the slot's pair is
45
+ * (anywhere — top layer or any slot) and how it's turned, up to a U turn
46
+ * (only pieces in the top layer move with it). Cases the set lists twice
47
+ * under the same pair position keep the first; `duplicates` names the rest.
48
+ */
49
+ export declare class F2LCaseTable {
50
+ readonly slot: F2LSlot;
51
+ private readonly table;
52
+ readonly duplicates: string[];
53
+ constructor(slot: F2LSlot, cases: readonly F2LCaseSource[]);
54
+ get size(): number;
55
+ recognize(s: State): F2LSetMatch | null;
56
+ }
57
+ /** Is a piece of the slot's pair in another slot (not the top layer, not its own) — an "advanced F2L" case? */
58
+ export declare function isTrappedF2L(s: State, slot: F2LSlot): boolean;
package/cfop/f2l.js CHANGED
@@ -86,3 +86,55 @@ export function recognizeF2L(s, slot) {
86
86
  }) ?? "";
87
87
  return { id: e.kase.id, group: e.kase.group, slot, preAuf, alg: e.kase.algs[slot] };
88
88
  }
89
+ /**
90
+ * Recognition for any set of F2L cases — e.g. "advanced F2L", where a piece
91
+ * of the pair is stuck in another slot. The case's state is its algorithm
92
+ * undone on a solved cube, and a case is told by where the slot's pair is
93
+ * (anywhere — top layer or any slot) and how it's turned, up to a U turn
94
+ * (only pieces in the top layer move with it). Cases the set lists twice
95
+ * under the same pair position keep the first; `duplicates` names the rest.
96
+ */
97
+ export class F2LCaseTable {
98
+ slot;
99
+ table = new Map();
100
+ duplicates = [];
101
+ constructor(slot, cases) {
102
+ this.slot = slot;
103
+ for (const source of cases) {
104
+ let s;
105
+ try {
106
+ s = applyMoves(solvedState(), invert(parseAlg(source.alg)));
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ const k = key(s, slot);
112
+ const p = pairOf(s, slot);
113
+ if (!k || !p)
114
+ continue;
115
+ if (this.table.has(k))
116
+ this.duplicates.push(source.id);
117
+ else
118
+ this.table.set(k, { source, raw: pairText(p) });
119
+ }
120
+ }
121
+ get size() {
122
+ return this.table.size;
123
+ }
124
+ recognize(s) {
125
+ const k = key(s, this.slot);
126
+ const e = k ? this.table.get(k) : undefined;
127
+ if (!e)
128
+ return null;
129
+ const preAuf = AUFS.find((u) => {
130
+ const q = pairOf(u ? applyMoves(s, u) : s, this.slot);
131
+ return q !== null && pairText(q) === e.raw;
132
+ }) ?? "";
133
+ return { id: e.source.id, slot: this.slot, preAuf, alg: e.source.alg };
134
+ }
135
+ }
136
+ /** Is a piece of the slot's pair in another slot (not the top layer, not its own) — an "advanced F2L" case? */
137
+ export function isTrappedF2L(s, slot) {
138
+ const p = pairOf(s, slot);
139
+ return !!p && !isStandardF2L(s, slot);
140
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Which algorithm was done — by what it does, not how it's written.
3
+ *
4
+ * An algorithm's EFFECT is the permutation its face turns make, relative to
5
+ * the centres (as a smart cube reports them — toFaceTurns): M' = r R' =
6
+ * R' L; L R' = R' L (turns on one axis commute); U U = U2 = U2';
7
+ * rotations don't count (only where the pieces go relative to the
8
+ * centres). So two writings of one algorithm are one effect, and a solve's
9
+ * moves match it however they were done — with slips undone, in any order
10
+ * on an axis.
11
+ *
12
+ * const m = new AlgMatcher<string>();
13
+ * m.add("R U R' U' R' F R2 U' R' U' R U R' F'", "T-perm");
14
+ * m.matchSuffix(solveMoves) // { data: "T-perm", start: 12, preAuf: "U", postAuf: "" } — moves 0–11 were a setup
15
+ *
16
+ * Up to a turn of the AUF face (U) before and after: an algorithm done from
17
+ * another angle, or finished with an AUF, is the same algorithm.
18
+ */
19
+ import { type Face } from "./geometry.js";
20
+ import { type Move } from "./moves.js";
21
+ export interface AlgMatch<T> {
22
+ data: T;
23
+ /** Index of the first move of the match; the moves before it are a setup. */
24
+ start: number;
25
+ /** The AUF turn done before the algorithm ("" / "U" / "U2" / "U'"), as the moves were held. */
26
+ preAuf: string;
27
+ /** The AUF turn after it. */
28
+ postAuf: string;
29
+ }
30
+ /** The effect of `moves` (any notation) as a key: where every sticker goes, relative to the centres. */
31
+ export declare function effectKey(moves: readonly Move[] | string): string;
32
+ export declare class AlgMatcher<T> {
33
+ private readonly effects;
34
+ private readonly aufs;
35
+ private readonly longest;
36
+ /** @param aufFace the face turned for AUF (U for a last layer, or F2L's top). */
37
+ constructor(aufFace?: Face);
38
+ /** An algorithm and what it stands for; the first added wins a shared effect. Returns false when it can't be read. */
39
+ add(alg: string, data: T): boolean;
40
+ get size(): number;
41
+ /** The algorithm whose effect is exactly that of `moves` (any notation), or null. */
42
+ match(moves: readonly Move[] | string): Omit<AlgMatch<T>, "start"> | null;
43
+ /**
44
+ * The longest ending of `moves` that is one of the algorithms — what was
45
+ * done last, and where it began (the moves before it: a setup, an
46
+ * extraction…). Endings up to a few moves longer than the longest
47
+ * algorithm are tried (slips, AUFs inside). Null when none is.
48
+ */
49
+ matchSuffix(moves: readonly Move[] | string, slack?: number): AlgMatch<T> | null;
50
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Which algorithm was done — by what it does, not how it's written.
3
+ *
4
+ * An algorithm's EFFECT is the permutation its face turns make, relative to
5
+ * the centres (as a smart cube reports them — toFaceTurns): M' = r R' =
6
+ * R' L; L R' = R' L (turns on one axis commute); U U = U2 = U2';
7
+ * rotations don't count (only where the pieces go relative to the
8
+ * centres). So two writings of one algorithm are one effect, and a solve's
9
+ * moves match it however they were done — with slips undone, in any order
10
+ * on an axis.
11
+ *
12
+ * const m = new AlgMatcher<string>();
13
+ * m.add("R U R' U' R' F R2 U' R' U' R U R' F'", "T-perm");
14
+ * m.matchSuffix(solveMoves) // { data: "T-perm", start: 12, preAuf: "U", postAuf: "" } — moves 0–11 were a setup
15
+ *
16
+ * Up to a turn of the AUF face (U) before and after: an algorithm done from
17
+ * another angle, or finished with an AUF, is the same algorithm.
18
+ */
19
+ import { formatMove } from "./moves.js";
20
+ import { parseAlg } from "./notation.js";
21
+ import { OrientationTracker, toFaceTurns } from "./physical.js";
22
+ import { applyMoves, solvedState } from "./state.js";
23
+ const key = (s) => String.fromCharCode(...s);
24
+ /** The effect of `moves` (any notation) as a key: where every sticker goes, relative to the centres. */
25
+ export function effectKey(moves) {
26
+ return key(applyMoves(solvedState(), toFaceTurns(moves).moves));
27
+ }
28
+ export class AlgMatcher {
29
+ effects = new Map();
30
+ aufs;
31
+ longest = { moves: 0 };
32
+ /** @param aufFace the face turned for AUF (U for a last layer, or F2L's top). */
33
+ constructor(aufFace = "U") {
34
+ this.aufs = ["", aufFace, `${aufFace}2`, `${aufFace}'`];
35
+ }
36
+ /** An algorithm and what it stands for; the first added wins a shared effect. Returns false when it can't be read. */
37
+ add(alg, data) {
38
+ let faceTurns;
39
+ try {
40
+ faceTurns = toFaceTurns(parseAlg(alg.replace(/[()]/g, " "))).moves;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ if (faceTurns.length === 0)
46
+ return false;
47
+ this.longest.moves = Math.max(this.longest.moves, faceTurns.length);
48
+ const body = faceTurns.map(formatMove).join(" ");
49
+ for (const preAuf of this.aufs)
50
+ for (const postAuf of this.aufs) {
51
+ const k = key(applyMoves(solvedState(), [preAuf, body, postAuf].filter(Boolean).join(" ")));
52
+ if (!this.effects.has(k))
53
+ this.effects.set(k, { data, preAuf, postAuf });
54
+ }
55
+ return true;
56
+ }
57
+ get size() {
58
+ return this.effects.size;
59
+ }
60
+ /** The algorithm whose effect is exactly that of `moves` (any notation), or null. */
61
+ match(moves) {
62
+ const e = this.effects.get(effectKey(moves));
63
+ return e ? { ...e } : null;
64
+ }
65
+ /**
66
+ * The longest ending of `moves` that is one of the algorithms — what was
67
+ * done last, and where it began (the moves before it: a setup, an
68
+ * extraction…). Endings up to a few moves longer than the longest
69
+ * algorithm are tried (slips, AUFs inside). Null when none is.
70
+ */
71
+ matchSuffix(moves, slack = 6) {
72
+ const list = typeof moves === "string" ? parseAlg(moves) : moves;
73
+ // One tracker over the whole list: a rotation or wide move keeps holding for the moves after it.
74
+ const t = new OrientationTracker();
75
+ const faceTurns = list.map((m) => t.push(m));
76
+ const from = Math.max(0, list.length - (this.longest.moves + slack));
77
+ for (let start = from; start < list.length; start++) {
78
+ const tail = faceTurns.slice(start).flat();
79
+ const e = this.effects.get(key(applyMoves(solvedState(), tail)));
80
+ if (e)
81
+ return { ...e, start };
82
+ }
83
+ return null;
84
+ }
85
+ }
package/core/index.d.ts CHANGED
@@ -17,3 +17,4 @@ export * from "./sequence.js";
17
17
  export * from "./practice.js";
18
18
  export * from "./arrows.js";
19
19
  export * from "./stages.js";
20
+ export * from "./algMatch.js";
package/core/index.js CHANGED
@@ -17,3 +17,4 @@ export * from "./sequence.js";
17
17
  export * from "./practice.js";
18
18
  export * from "./arrows.js";
19
19
  export * from "./stages.js";
20
+ export * from "./algMatch.js";
@@ -5,7 +5,8 @@
5
5
  * It matches CUBE STATES, not move strings, so everything that reaches the
6
6
  * same state counts:
7
7
  * - a half turn done as two quarter turns, either way (half done = `partial`);
8
- * - opposite faces in the other order (R L done as L R);
8
+ * - moves on one axis in any order, even interleaved (R2 L2 done as
9
+ * L R L R, or L2 R2): they commute — each face just has to get there;
9
10
  * - an algorithm written with rotations, slices and wide moves (converted
10
11
  * with toFaceTurns: what the smart cube will actually report).
11
12
  * Off the path, `undo` is the way back to the last matching point (the
@@ -13,7 +14,7 @@
13
14
  */
14
15
  import { type TurnArrow } from "./arrows.js";
15
16
  import type { Frame } from "./frames.js";
16
- import type { Move } from "./moves.js";
17
+ import { type Move } from "./moves.js";
17
18
  import { type State } from "./state.js";
18
19
  export interface SequenceStep {
19
20
  /** The face turn the cube will report. */
@@ -27,7 +28,10 @@ export interface SequenceProgress {
27
28
  /** Face-turn steps fully done. */
28
29
  done: number;
29
30
  total: number;
30
- /** The next step is a half turn and half of it is done. */
31
+ /**
32
+ * Part way through the next steps: half of a half turn, or moves on one
33
+ * axis (R2 L2) done out of order / each part of the way.
34
+ */
31
35
  partial: boolean;
32
36
  /** Status of each written move (for display). */
33
37
  tokens: TokenStatus[];
@@ -42,11 +46,13 @@ export interface SequenceProgress {
42
46
  /** What to turn now — for arrows on a 3D cube (see arrows.ts). */
43
47
  export interface NextTurn {
44
48
  /**
45
- * "next": the next move of the sequence; "undo": the first move back after
46
- * a slip; "wrong-way": the same, when the slip was the right face turned
47
- * the wrong way (or too far) — worth a colour of its own.
49
+ * "next": the next move of the sequence; "finish": the rest of a half turn
50
+ * already started (with R2 L2 both at once, when both are half done);
51
+ * "undo": the first move back after a slip; "wrong-way": the same, when
52
+ * the slip was the right face turned the wrong way (or too far) — worth a
53
+ * colour of its own.
48
54
  */
49
- kind: "next" | "undo" | "wrong-way";
55
+ kind: "next" | "finish" | "undo" | "wrong-way";
50
56
  /** In the cube's own coordinates. Usually one; the rest of an M done half-way can be two. */
51
57
  arrows: TurnArrow[];
52
58
  /** The written move it belongs to (null for undo). */
@@ -69,8 +75,11 @@ export declare class SequenceTracker {
69
75
  readonly frames: Frame[];
70
76
  private readonly path;
71
77
  private cur;
78
+ private readonly groups;
79
+ private readonly groupOf;
72
80
  private anchor;
73
- private anchorPartial;
81
+ /** At the anchor, inside an axis group: how far each of its faces got (quarters). */
82
+ private anchorGroup;
74
83
  private offPath;
75
84
  private readonly maxCorrection;
76
85
  constructor(target: string | readonly Move[], start: State, options?: SequenceOptions);
@@ -78,6 +87,13 @@ export declare class SequenceTracker {
78
87
  /** A face turn reported by the cube. */
79
88
  push(move: Move): SequenceProgress;
80
89
  get progress(): SequenceProgress;
90
+ /** A step at the anchor: done, part way (half of a half turn), or not started. */
91
+ private stepStatus;
92
+ /**
93
+ * The only slip is a face due (the next step, or one on its axis still to
94
+ * do) turned the wrong way or too far — the step it belongs to, else null.
95
+ */
96
+ private wrongWayStep;
81
97
  /**
82
98
  * What to turn now, for an arrow: after a slip the first undo move; half way
83
99
  * through a half turn the remaining quarter (the way it was started); else
@@ -85,6 +101,10 @@ export declare class SequenceTracker {
85
101
  * middle one — placed where it is on the cube as now held. Null when complete.
86
102
  */
87
103
  get nextTurn(): NextTurn | null;
88
- /** Where the current state is on the path, if anywhere: after `done` steps (+ half of the next one). */
104
+ /**
105
+ * Where the current state is on the path, if anywhere: after `done` steps —
106
+ * or inside an axis group, each of its faces some way to its target (a
107
+ * half turn: any part, either way; a quarter: not yet or done).
108
+ */
89
109
  private locate;
90
110
  }
package/core/sequence.js CHANGED
@@ -5,13 +5,15 @@
5
5
  * It matches CUBE STATES, not move strings, so everything that reaches the
6
6
  * same state counts:
7
7
  * - a half turn done as two quarter turns, either way (half done = `partial`);
8
- * - opposite faces in the other order (R L done as L R);
8
+ * - moves on one axis in any order, even interleaved (R2 L2 done as
9
+ * L R L R, or L2 R2): they commute — each face just has to get there;
9
10
  * - an algorithm written with rotations, slices and wide moves (converted
10
11
  * with toFaceTurns: what the smart cube will actually report).
11
12
  * Off the path, `undo` is the way back to the last matching point (the
12
13
  * slip, simplified: R R' cancels) and `needsReset` says it's too long.
13
14
  */
14
15
  import { turnArrow } from "./arrows.js";
16
+ import { amountQuarters } from "./moves.js";
15
17
  import { invert, parseAlg, simplify } from "./notation.js";
16
18
  import { OrientationTracker } from "./physical.js";
17
19
  import { applyMove, statesEqual } from "./state.js";
@@ -22,8 +24,11 @@ export class SequenceTracker {
22
24
  frames;
23
25
  path; // state after k steps
24
26
  cur;
27
+ groups = [];
28
+ groupOf = []; // step index → its group
25
29
  anchor = 0; // last matching step index
26
- anchorPartial = false;
30
+ /** At the anchor, inside an axis group: how far each of its faces got (quarters). */
31
+ anchorGroup = null;
27
32
  offPath = []; // moves since the last matching point
28
33
  maxCorrection;
29
34
  constructor(target, start, options = {}) {
@@ -39,6 +44,22 @@ export class SequenceTracker {
39
44
  for (const s of this.steps)
40
45
  this.path.push(applyMove(this.path[this.path.length - 1], s.move));
41
46
  this.cur = new Uint8Array(start);
47
+ for (let i = 0; i < this.steps.length;) {
48
+ let j = i + 1;
49
+ while (j < this.steps.length && sameAxis(this.steps[i].move, this.steps[j].move))
50
+ j++;
51
+ const group = { start: i, end: j, faces: [], target: [] };
52
+ for (let k = i; k < j; k++) {
53
+ const { family, amount } = this.steps[k].move;
54
+ let f = group.faces.indexOf(family);
55
+ if (f < 0)
56
+ f = group.faces.push(family) - 1;
57
+ group.target[f] = ((group.target[f] ?? 0) + amountQuarters(amount)) % 4;
58
+ this.groupOf[k] = this.groups.length;
59
+ }
60
+ this.groups.push(group);
61
+ i = j;
62
+ }
42
63
  }
43
64
  get state() {
44
65
  return this.cur;
@@ -49,7 +70,7 @@ export class SequenceTracker {
49
70
  const on = this.locate();
50
71
  if (on) {
51
72
  this.anchor = on.done;
52
- this.anchorPartial = on.partial;
73
+ this.anchorGroup = on.group;
53
74
  this.offPath = [];
54
75
  }
55
76
  else {
@@ -61,24 +82,46 @@ export class SequenceTracker {
61
82
  const total = this.steps.length;
62
83
  const undo = this.offPath.length ? invert(simplify(this.offPath)) : [];
63
84
  const done = this.anchor;
64
- const partial = this.anchorPartial && !this.offPath.length;
85
+ const partial = this.anchorGroup !== null && !this.offPath.length;
65
86
  const next = done < total ? this.steps[done].token : this.written.length;
66
- const expected = this.steps[done]?.move;
67
- const wrongWay = undo.length === 1 && expected !== undefined && undo[0].family === expected.family;
87
+ const ww = this.wrongWayStep(undo);
88
+ const status = (i) => (this.offPath.length ? (i < done ? "done" : "todo") : this.stepStatus(i));
68
89
  const tokens = this.written.map((_, t) => {
69
90
  const idx = this.steps.map((s, i) => (s.token === t ? i : -1)).filter((i) => i >= 0);
70
91
  if (!idx.length)
71
92
  return t < next ? "done" : "todo"; // a rotation: done once the cube gets past it
72
- const first = idx[0], last = idx[idx.length - 1];
73
- if (last < done)
93
+ const st = idx.map(status);
94
+ if (st.every((x) => x === "done"))
74
95
  return "done";
75
- if (first < done || (first === done && partial))
76
- return "partial"; // M half done, or half of an R2
77
- if (first === done && wrongWay)
96
+ if (ww !== null && idx.includes(ww))
78
97
  return "wrong-way";
79
- return first === done ? "current" : "todo";
98
+ if (st.some((x) => x !== "todo"))
99
+ return "partial"; // half of an R2, R2 L2 started either way, M half done
100
+ return idx[0] === done ? "current" : "todo";
80
101
  });
81
- return { done, total, partial, tokens, undo, needsReset: undo.length > this.maxCorrection, wrongWay, complete: done === total && !this.offPath.length };
102
+ return { done, total, partial, tokens, undo, needsReset: undo.length > this.maxCorrection, wrongWay: ww !== null, complete: done === total && !this.offPath.length };
103
+ }
104
+ /** A step at the anchor: done, part way (half of a half turn), or not started. */
105
+ stepStatus(i) {
106
+ const g = this.anchorGroup;
107
+ if (g && i >= g.group.start && i < g.group.end) {
108
+ const f = g.group.faces.indexOf(this.steps[i].move.family);
109
+ return g.got[f] === g.group.target[f] ? "done" : g.got[f] === 0 ? "todo" : "partial";
110
+ }
111
+ return i < this.anchor ? "done" : "todo";
112
+ }
113
+ /**
114
+ * The only slip is a face due (the next step, or one on its axis still to
115
+ * do) turned the wrong way or too far — the step it belongs to, else null.
116
+ */
117
+ wrongWayStep(undo) {
118
+ if (undo.length !== 1 || this.anchor >= this.steps.length)
119
+ return null;
120
+ const end = this.groups[this.groupOf[this.anchor]].end;
121
+ for (let i = this.anchor; i < end; i++)
122
+ if (this.steps[i].move.family === undo[0].family && this.stepStatus(i) !== "done")
123
+ return i;
124
+ return null;
82
125
  }
83
126
  /**
84
127
  * What to turn now, for an arrow: after a slip the first undo move; half way
@@ -90,17 +133,18 @@ export class SequenceTracker {
90
133
  const p = this.progress;
91
134
  if (p.complete)
92
135
  return null;
93
- if (p.wrongWay) {
136
+ const ww = this.wrongWayStep(p.undo);
137
+ if (ww !== null) {
94
138
  // The face due went the wrong way (or too far): the turn to where it should be —
95
139
  // back and on in one go (U instead of U': a U2 the way U' goes).
96
- const expected = this.steps[p.done].move;
140
+ const expected = this.steps[ww].move;
97
141
  const back = p.undo[0];
98
142
  const signed = (m) => (m.amount === 2 ? 2 * Math.sign(m.written ?? 2) : m.amount);
99
143
  let q = signed(back) + (expected.amount === 2 ? 2 * (Math.sign(signed(back)) || 1) : expected.amount);
100
144
  q = ((q % 4) + 4) % 4;
101
145
  const amount = (q === 3 ? -1 : q);
102
146
  const fix = amount === 2 ? { family: expected.family, amount: 2, written: 2 * (Math.sign(signed(expected)) || 1) } : { family: expected.family, amount };
103
- return { kind: "wrong-way", arrows: [turnArrow(fix)], token: this.steps[p.done].token };
147
+ return { kind: "wrong-way", arrows: [turnArrow(fix)], token: this.steps[ww].token };
104
148
  }
105
149
  if (p.undo.length) {
106
150
  return { kind: "undo", arrows: [turnArrow(p.undo[0])], token: null };
@@ -110,15 +154,18 @@ export class SequenceTracker {
110
154
  if (!step)
111
155
  return null;
112
156
  const next = (moves) => ({ kind: "next", arrows: moves.map((m) => turnArrow(m)), token: step.token });
113
- if (p.partial) {
114
- if (step.move.amount === 2) {
115
- for (const amount of [1, -1]) {
116
- const half = { family: step.move.family, amount };
117
- if (statesEqual(applyMove(this.path[k], half), this.cur))
118
- return next([half]);
119
- }
157
+ const g = this.anchorGroup;
158
+ if (g) {
159
+ // Half turns started (R2 L2: one or both half done): the rest of each, the way it was started.
160
+ const halves = g.group.faces.flatMap((family, f) => {
161
+ const got = g.got[f];
162
+ return got !== 0 && got !== g.group.target[f] ? [{ family, amount: got === 1 ? 1 : -1 }] : [];
163
+ });
164
+ if (halves.length) {
165
+ const first = this.steps.findIndex((s, i) => i >= g.group.start && s.move.family === halves[0].family);
166
+ return { kind: "finish", arrows: halves.map((m) => turnArrow(m)), token: this.steps[first].token };
120
167
  }
121
- return next([step.move]); // the opposite face went first: this one is left
168
+ // Else a face of the axis went first (R L done as L R): the next one left, as written.
122
169
  }
123
170
  const first = this.steps.findIndex((s) => s.token === step.token);
124
171
  if (first < k) {
@@ -127,31 +174,45 @@ export class SequenceTracker {
127
174
  }
128
175
  return { kind: "next", arrows: [turnArrow(this.written[step.token], this.frames[step.token])], token: step.token };
129
176
  }
130
- /** Where the current state is on the path, if anywhere: after `done` steps (+ half of the next one). */
177
+ /**
178
+ * Where the current state is on the path, if anywhere: after `done` steps —
179
+ * or inside an axis group, each of its faces some way to its target (a
180
+ * half turn: any part, either way; a quarter: not yet or done).
181
+ */
131
182
  locate() {
132
183
  for (let k = this.path.length - 1; k >= 0; k--)
133
184
  if (statesEqual(this.path[k], this.cur))
134
- return { done: k, partial: false };
135
- for (let k = 0; k < this.steps.length; k++) {
136
- const s = this.steps[k].move;
137
- // Half of a half turn, either way.
138
- if (s.amount === 2) {
139
- for (const amount of [1, -1]) {
140
- if (statesEqual(applyMove(this.path[k], { family: s.family, amount }), this.cur))
141
- return { done: k, partial: true };
185
+ return { done: k, group: null };
186
+ for (let gi = this.groups.length - 1; gi >= 0; gi--) {
187
+ const group = this.groups[gi];
188
+ const options = group.target.map((t) => (t === 2 ? [0, 1, 2, 3] : t === 0 ? [0] : [0, t]));
189
+ for (const got of combinations(options)) {
190
+ if (got.every((q) => q === 0) || got.every((q, f) => q === group.target[f]))
191
+ continue; // on the path: found above
192
+ let s = this.path[group.start];
193
+ got.forEach((q, f) => {
194
+ if (q)
195
+ s = applyMove(s, { family: group.faces[f], amount: q === 3 ? -1 : q });
196
+ });
197
+ if (!statesEqual(s, this.cur))
198
+ continue;
199
+ const at = { group, got };
200
+ let done = group.start;
201
+ while (done < group.end) {
202
+ const f = group.faces.indexOf(this.steps[done].move.family);
203
+ if (got[f] !== group.target[f])
204
+ break;
205
+ done++;
142
206
  }
143
- }
144
- // The next step on the opposite face done first (R L done as L R): count as progress on k.
145
- const n = this.steps[k + 1];
146
- if (n && opposite(s, n.move)) {
147
- if (statesEqual(applyMove(this.path[k], n.move), this.cur))
148
- return { done: k, partial: true };
207
+ return { done, group: at };
149
208
  }
150
209
  }
151
210
  return null;
152
211
  }
153
212
  }
154
213
  const AXIS = { U: 1, D: 1, R: 0, L: 0, F: 2, B: 2 };
155
- function opposite(a, b) {
156
- return a.family !== b.family && AXIS[a.family] !== undefined && AXIS[a.family] === AXIS[b.family];
214
+ const sameAxis = (a, b) => AXIS[a.family] !== undefined && AXIS[a.family] === AXIS[b.family];
215
+ /** Every pick of one value from each list. */
216
+ function combinations(options) {
217
+ return options.reduce((acc, xs) => acc.flatMap((a) => xs.map((x) => [...a, x])), [[]]);
157
218
  }
@@ -25,7 +25,9 @@
25
25
  * or the `player` property), the 3D cube shows the next turn — one arrow per
26
26
  * turning layer (two for a wide r), as many heads as quarter turns, in
27
27
  * --cc-arrow; after a slip the undo move in --cc-arrow-undo, or in
28
- * --cc-arrow-wrong-way when the right face went the wrong way.
28
+ * --cc-arrow-wrong-way when the right face went the wrong way; the rest of
29
+ * a half turn already started (R2 L2 half done, in any order) in
30
+ * --cc-arrow-finish.
29
31
  * `arrow-shape="circle"` for arcs instead of ribbons along the faces.
30
32
  *
31
33
  * After a slip the moves give way to the way back: the undo moves in their
@@ -65,7 +67,7 @@ export interface SequenceMessages {
65
67
  reset: string;
66
68
  complete: string;
67
69
  }
68
- export declare const SEQUENCE_STYLES = "\n:host {\n --cc-seq-font: ui-monospace, \"SF Mono\", Menlo, monospace;\n --cc-seq-size: 22px;\n --cc-seq-gap: 0.35em;\n --cc-seq-done: color-mix(in srgb, currentColor 35%, transparent);\n --cc-seq-current: currentColor;\n --cc-seq-current-bg: color-mix(in srgb, var(--cc-accent) 24%, transparent);\n --cc-seq-partial: var(--cc-accent);\n --cc-seq-todo: color-mix(in srgb, currentColor 82%, transparent);\n --cc-seq-hidden: color-mix(in srgb, currentColor 45%, transparent);\n --cc-seq-undo: #ff5a5a;\n --cc-seq-wrong-way: #ff9a1f;\n --cc-seq-decoration: color-mix(in srgb, currentColor 70%, transparent);\n --cc-arrow: #2f8bff;\n --cc-arrow-undo: #ff4545;\n --cc-arrow-wrong-way: #ff9a1f;\n --cc-accent: #4f8cff;\n --cc-control-bg: color-mix(in srgb, currentColor 8%, transparent);\n --cc-control-bg-hover: color-mix(in srgb, currentColor 15%, transparent);\n --cc-control-radius: 8px;\n display: block;\n}\n:host([headless]) .container { display: none; }\n.group { display: inline-flex; align-items: baseline; }\n.decoration { color: var(--cc-seq-decoration); }\n.container { display: flex; flex-direction: column; gap: 0.5em; }\n.container.slip .moves { display: none; }\n.container.slip .undo { font-size: var(--cc-seq-size); line-height: 1.5; }\n.moves { display: flex; flex-wrap: wrap; gap: 0.1em var(--cc-seq-gap); font: 600 var(--cc-seq-size) / 1.5 var(--cc-seq-font); }\n.move { padding: 0 0.18em; border-radius: 0.25em; transition: color 0.15s ease, background 0.15s ease; }\n.done { color: var(--cc-seq-done); }\n.current { color: var(--cc-seq-current); background: var(--cc-seq-current-bg); }\n.partial { color: var(--cc-seq-partial); background: var(--cc-seq-current-bg); }\n.wrong-way { color: var(--cc-seq-wrong-way); background: var(--cc-seq-current-bg); }\n.todo { color: var(--cc-seq-todo); }\n.hidden { color: var(--cc-seq-hidden); }\n.undo { display: none; color: var(--cc-seq-undo); gap: var(--cc-seq-gap); flex-wrap: wrap; align-items: baseline; font: 600 calc(var(--cc-seq-size) * 0.85) / 1.4 var(--cc-seq-font); }\n.undo.on { display: flex; }\n.undo-label { font: 600 0.65em/1 system-ui, sans-serif; text-transform: uppercase; letter-spacing: 0.06em; margin-right: 0.3em; }\n.message { display: none; font: 500 13px/1.4 system-ui, sans-serif; opacity: 0.85; }\n.message.on { display: block; }\n.controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }\n.controls:empty { display: none; }\n:host([controls=\"none\"]) .controls { display: none; }\nbutton {\n border: 0; border-radius: var(--cc-control-radius);\n background: var(--cc-control-bg); color: inherit;\n padding: 6px 11px; font: 600 12px/1 system-ui, sans-serif; cursor: pointer;\n}\nbutton:hover { background: var(--cc-control-bg-hover); }\nbutton:focus-visible, input:focus-visible { outline: 2px solid var(--cc-accent); outline-offset: 2px; }\nbutton[aria-pressed=\"true\"] { background: color-mix(in srgb, var(--cc-accent) 25%, transparent); }\n";
70
+ export declare const SEQUENCE_STYLES = "\n:host {\n --cc-seq-font: ui-monospace, \"SF Mono\", Menlo, monospace;\n --cc-seq-size: 22px;\n --cc-seq-gap: 0.35em;\n --cc-seq-done: color-mix(in srgb, currentColor 35%, transparent);\n --cc-seq-current: currentColor;\n --cc-seq-current-bg: color-mix(in srgb, var(--cc-accent) 24%, transparent);\n --cc-seq-partial: var(--cc-accent);\n --cc-seq-todo: color-mix(in srgb, currentColor 82%, transparent);\n --cc-seq-hidden: color-mix(in srgb, currentColor 45%, transparent);\n --cc-seq-undo: #ff5a5a;\n --cc-seq-wrong-way: #ff9a1f;\n --cc-seq-decoration: color-mix(in srgb, currentColor 70%, transparent);\n --cc-arrow: #2f8bff;\n --cc-arrow-undo: #ff4545;\n --cc-arrow-wrong-way: #ff9a1f;\n --cc-arrow-finish: #ff9a1f;\n --cc-accent: #4f8cff;\n --cc-control-bg: color-mix(in srgb, currentColor 8%, transparent);\n --cc-control-bg-hover: color-mix(in srgb, currentColor 15%, transparent);\n --cc-control-radius: 8px;\n display: block;\n}\n:host([headless]) .container { display: none; }\n.group { display: inline-flex; align-items: baseline; }\n.decoration { color: var(--cc-seq-decoration); }\n.container { display: flex; flex-direction: column; gap: 0.5em; }\n.container.slip .moves { display: none; }\n.container.slip .undo { font-size: var(--cc-seq-size); line-height: 1.5; }\n.moves { display: flex; flex-wrap: wrap; gap: 0.1em var(--cc-seq-gap); font: 600 var(--cc-seq-size) / 1.5 var(--cc-seq-font); }\n.move { padding: 0 0.18em; border-radius: 0.25em; transition: color 0.15s ease, background 0.15s ease; }\n.done { color: var(--cc-seq-done); }\n.current { color: var(--cc-seq-current); background: var(--cc-seq-current-bg); }\n.partial { color: var(--cc-seq-partial); background: var(--cc-seq-current-bg); }\n.wrong-way { color: var(--cc-seq-wrong-way); background: var(--cc-seq-current-bg); }\n.todo { color: var(--cc-seq-todo); }\n.hidden { color: var(--cc-seq-hidden); }\n.undo { display: none; color: var(--cc-seq-undo); gap: var(--cc-seq-gap); flex-wrap: wrap; align-items: baseline; font: 600 calc(var(--cc-seq-size) * 0.85) / 1.4 var(--cc-seq-font); }\n.undo.on { display: flex; }\n.undo-label { font: 600 0.65em/1 system-ui, sans-serif; text-transform: uppercase; letter-spacing: 0.06em; margin-right: 0.3em; }\n.message { display: none; font: 500 13px/1.4 system-ui, sans-serif; opacity: 0.85; }\n.message.on { display: block; }\n.controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }\n.controls:empty { display: none; }\n:host([controls=\"none\"]) .controls { display: none; }\nbutton {\n border: 0; border-radius: var(--cc-control-radius);\n background: var(--cc-control-bg); color: inherit;\n padding: 6px 11px; font: 600 12px/1 system-ui, sans-serif; cursor: pointer;\n}\nbutton:hover { background: var(--cc-control-bg-hover); }\nbutton:focus-visible, input:focus-visible { outline: 2px solid var(--cc-accent); outline-offset: 2px; }\nbutton[aria-pressed=\"true\"] { background: color-mix(in srgb, var(--cc-accent) 25%, transparent); }\n";
69
71
  export declare abstract class CubeSequenceElement extends ElementBase {
70
72
  protected readonly root: ShadowRoot;
71
73
  protected source: MoveSource | null;
@@ -25,7 +25,9 @@
25
25
  * or the `player` property), the 3D cube shows the next turn — one arrow per
26
26
  * turning layer (two for a wide r), as many heads as quarter turns, in
27
27
  * --cc-arrow; after a slip the undo move in --cc-arrow-undo, or in
28
- * --cc-arrow-wrong-way when the right face went the wrong way.
28
+ * --cc-arrow-wrong-way when the right face went the wrong way; the rest of
29
+ * a half turn already started (R2 L2 half done, in any order) in
30
+ * --cc-arrow-finish.
29
31
  * `arrow-shape="circle"` for arcs instead of ribbons along the faces.
30
32
  *
31
33
  * After a slip the moves give way to the way back: the undo moves in their
@@ -53,6 +55,7 @@ export const SEQUENCE_STYLES = /* css */ `
53
55
  --cc-arrow: #2f8bff;
54
56
  --cc-arrow-undo: #ff4545;
55
57
  --cc-arrow-wrong-way: #ff9a1f;
58
+ --cc-arrow-finish: #ff9a1f;
56
59
  --cc-accent: #4f8cff;
57
60
  --cc-control-bg: color-mix(in srgb, currentColor 8%, transparent);
58
61
  --cc-control-bg-hover: color-mix(in srgb, currentColor 15%, transparent);
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.10",
6
+ "version": "0.1.12",
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>",