rubiks_tutor / frontend /src /scene /cube /cubelets.ts
sanggusti's picture
feat: add unit tests for MoveAnimator and Cubelet functionality
5d8d1cc
Raw
History Blame Contribute Delete
7.69 kB
/*
CUBELETS
A Cubelet is one of the 27 small cubes that make up a 3x3 Rubik's Cube.
This module is a *logical* model only — it has no rendering concerns.
The visual cube lives in `cube.ts` (`CubeMesh` / `Cubie`).
Faces are stored in a clockwise spiral, Front -> Back:
Back
5
-----------
/ Up /|
/ 1 / |
----------- Right
| | 2
Left | Front | .
4 | 0 | /
| |/
-----------
Down
3
The `faces` array is mapped to convenience accessors:
cubelet.faces[0] === cubelet.front
cubelet.faces[1] === cubelet.up
cubelet.faces[2] === cubelet.right
cubelet.faces[3] === cubelet.down
cubelet.faces[4] === cubelet.left
cubelet.faces[5] === cubelet.back
Each Cubelet has an `id` assigned at creation and an `address` that changes
as it moves around the Cube. `addressX/Y/Z` are derived from the address and
locate the Cubelet relative to the Cube's core, each in the range -1..+1.
*/
import type { FaceKey } from '../../core/state';
/** Axis of rotation for a quarter turn. */
export type Axis = 'x' | 'y' | 'z';
/** Direction of a quarter turn: +1 clockwise, -1 counter-clockwise (looking from +axis). */
export type Direction = 1 | -1;
/** Classification of a Cubelet by how many stickered (exposed) faces it has. */
export type CubeletType = 'core' | 'center' | 'edge' | 'corner';
/** Face slot names in clockwise-spiral order: Front, Up, Right, Down, Left, Back. */
export const FACE_NAMES = ['front', 'up', 'right', 'down', 'left', 'back'] as const;
export type FaceName = (typeof FACE_NAMES)[number];
/** Solved-orientation normal label for each face slot. */
const FACE_NORMALS: readonly FaceKey[] = ['F', 'U', 'R', 'D', 'L', 'B'];
/** Normalize signed zero (-0 -> 0) so coordinates compare and print cleanly. */
const nz = (n: number): number => (n === 0 ? 0 : n);
export interface CubeletFace {
/** Original slot id 0-5 at construction; stable identity used for solving. */
readonly id: number;
/** Sticker color, or `null` when the face is interior (hidden) to the Cube. */
color: FaceKey | null;
/** Solved-orientation normal label captured at construction. */
readonly normal: FaceKey;
}
// Quarter-turn face permutations, expressed in current-accessor order
// [front, up, right, down, left, back]: each entry names the OLD face that ends
// up in that slot after the turn. Derived as the inverse of the coordinate
// rotation in `rotateAddress`, so face remapping and lattice motion stay in sync
// (and match the visual animator's `rotateCoord`).
const FACE_PERMUTATIONS: Record<Axis, Record<Direction, readonly FaceName[]>> = {
x: {
[1]: ['up', 'back', 'right', 'front', 'left', 'down'],
[-1]: ['down', 'front', 'right', 'back', 'left', 'up']
},
y: {
[1]: ['left', 'up', 'front', 'down', 'back', 'right'],
[-1]: ['right', 'up', 'back', 'down', 'front', 'left']
},
z: {
[1]: ['front', 'right', 'down', 'left', 'up', 'back'],
[-1]: ['front', 'left', 'up', 'right', 'down', 'back']
}
};
export class Cubelet {
/** Unique number 0-26 assigned when the Cube is built. */
readonly id: number;
/** Current location on the Cube (0-26). Changes as the Cubelet moves. */
address: number;
/** Lattice coordinates relative to the Cube core, each in -1..+1. */
addressX = 0;
addressY = 0;
addressZ = 0;
/** The six faces in clockwise-spiral order (see module header). */
faces: CubeletFace[];
/** Derived from the number of stickered faces. */
readonly type: CubeletType;
/**
* @param id Unique cubelet id 0-26 (also the initial address).
* @param colors Up to six sticker colors in slot order [front, up, right, down, left, back].
* Use `null`/`undefined` for interior faces.
*/
constructor(id = 0, colors: ReadonlyArray<FaceKey | null | undefined> = []) {
this.id = id;
this.address = id;
this.setAddress(id);
let extrovertedFaces = 0;
this.faces = FACE_NAMES.map((_, i): CubeletFace => {
const color = colors[i] ?? null;
if (color !== null) extrovertedFaces++;
return { id: i, color, normal: FACE_NORMALS[i] };
});
const types: readonly CubeletType[] = ['core', 'center', 'edge', 'corner'];
this.type = types[extrovertedFaces] ?? 'core';
}
get front(): CubeletFace { return this.faces[0]; }
get up(): CubeletFace { return this.faces[1]; }
get right(): CubeletFace { return this.faces[2]; }
get down(): CubeletFace { return this.faces[3]; }
get left(): CubeletFace { return this.faces[4]; }
get back(): CubeletFace { return this.faces[5]; }
/** Concatenated face-color initials in slot order, '-' for interior faces. */
get colors(): string {
return this.faces.map(face => face.color ?? '-').join('');
}
/**
* Assign a new address (0-26) and recompute the lattice coordinates.
* Called during Cube remapping after moves.
*/
setAddress(address = 0): void {
this.address = address;
this.addressX = nz((address % 3) - 1);
this.addressY = nz((Math.floor((address % 9) / 3) - 1) * -1);
this.addressZ = nz((Math.floor(address / 9) - 1) * -1);
}
/**
* Apply a logical quarter turn about `axis`, remapping the faces (orientation)
* and rotating the lattice coordinates (position).
*/
rotate(axis: Axis, dir: Direction): void {
const order = FACE_PERMUTATIONS[axis][dir];
this.faces = order.map(name => this[name]);
this.rotateAddress(axis, dir);
}
/** Rotate the lattice coordinates by a quarter turn about `axis`. */
private rotateAddress(axis: Axis, dir: Direction): void {
const { addressX: x, addressY: y, addressZ: z } = this;
if (axis === 'x') {
if (dir === 1) { this.addressY = nz(-z); this.addressZ = nz(y); }
else { this.addressY = nz(z); this.addressZ = nz(-y); }
} else if (axis === 'y') {
if (dir === 1) { this.addressX = nz(z); this.addressZ = nz(-x); }
else { this.addressX = nz(-z); this.addressZ = nz(x); }
} else {
if (dir === 1) { this.addressX = nz(-y); this.addressY = nz(x); }
else { this.addressX = nz(y); this.addressY = nz(-x); }
}
}
/**
* Find which face carries `color`.
* @returns the face name, or `null` if the Cubelet has no such color.
*/
hasColor(color: FaceKey): FaceName | null {
const index = this.faces.findIndex(face => face.color === color);
return index === -1 ? null : FACE_NAMES[index];
}
/** True only if the Cubelet carries every one of the given colors. */
hasColors(...colors: FaceKey[]): boolean {
return colors.every(color => this.hasColor(color) !== null);
}
/** Plain, serializable snapshot of the Cubelet — handy for logging/tests. */
inspect(): {
id: number;
type: CubeletType;
address: number;
addressX: number;
addressY: number;
addressZ: number;
colors: string;
} {
return {
id: this.id,
type: this.type,
address: this.address,
addressX: this.addressX,
addressY: this.addressY,
addressZ: this.addressZ,
colors: this.colors
};
}
}