From 48ca8fa7886b39e6981dd2b0f896b2ddf9309a22 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Tue, 10 Nov 2026 06:05:37 +0000 Subject: [PATCH] fix(facelets): reject unrepresentable center twists Reject nonzero center orientations instead of silently dropping picture-cube state. Co-Authored-By: Codex --- packages/core/README.md | 14 +++++++ packages/core/src/domain/CubeFacelets.res | 6 ++- packages/core/src/domain/CubeFacelets.resi | 7 +++- .../core/src/domain/CubeFacelets_test.res | 16 ++++++++ src/adapters/cubing/patternReconciler.test.ts | 38 +++++++++++++++++++ src/adapters/cubing/utils.ts | 4 ++ 6 files changed, 82 insertions(+), 3 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index f2b0852..ad26307 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -59,3 +59,17 @@ Push a package-specific tag matching the version in `package.json`, such as `cor release workflow tests the core, builds and smoke-tests one tarball, retains it as a workflow artifact for download, and does not publish it to a package registry. Use the matching section of [CHANGELOG.md](CHANGELOG.md) for the GitHub Release body. + +### Center orientation in color facelets + +`CubeFacelets.patternDataToFacelets` returns an error for nonzero center +orientations; `patternDataToFaceletsExn` throws. A 54-letter color facelet string +cannot carry picture-cube or supercube center twists. Canonical center positions +are required as before. + +Decoding color facelets sets center `orientationMod` to 1, meaning that center +orientation is untracked, rather than known to be zero. Ordinary cubing.js +3×3 default patterns use this convention too, so standard face turns remain +convertible. Keep the original pattern when tracked center orientation matters; +this API does not provide a lossless picture-cube round trip. The Console's +CP/CO/EP/EO and Orbit64 color-state exports likewise do not encode center twists. diff --git a/packages/core/src/domain/CubeFacelets.res b/packages/core/src/domain/CubeFacelets.res index 9bef679..d4bab4a 100644 --- a/packages/core/src/domain/CubeFacelets.res +++ b/packages/core/src/domain/CubeFacelets.res @@ -203,7 +203,7 @@ let validateCubieState = (cornerPieces, cornerOri, edgePieces, edgeOri): result< Ok() } -let centersOriented = (pd: patternData) => +let centerPositionsCanonical = (pd: patternData) => pd.centers.pieces->Array.length == 6 && pd.centers.orientation->Array.length == 6 && pd.centers.pieces->Array.everyWithIndex((p, i) => p == i) @@ -213,8 +213,10 @@ let toReid333Struct = (pd: patternData): result< (array, array, array), string, > => - if !centersOriented(pd) { + if !centerPositionsCanonical(pd) { Error("non-oriented puzzles are not supported") + } else if pd.centers.orientation->Array.some(value => value != 0) { + Error("center twists cannot be represented by color facelets") } else { switch validateCubieState( pd.corners.pieces, diff --git a/packages/core/src/domain/CubeFacelets.resi b/packages/core/src/domain/CubeFacelets.resi index ac31718..767c100 100644 --- a/packages/core/src/domain/CubeFacelets.resi +++ b/packages/core/src/domain/CubeFacelets.resi @@ -33,7 +33,12 @@ let solvedFacelets: string @genType let isSolvedFacelets: string => bool -/** Encode validated pattern data, returning `Error` for malformed or impossible orbit data. */ +/** +Encode canonical-center color facelets. Returns `Error` for malformed or impossible +cubie data and nonzero center orientations, which a color string cannot represent. +Zero values with `orientationMod: [1, ...]` mean untracked centers, not known twists. +This is not a lossless picture-cube/supercube state encoding. +*/ @genType let patternDataToFacelets: patternData => result diff --git a/packages/core/src/domain/CubeFacelets_test.res b/packages/core/src/domain/CubeFacelets_test.res index b1aec6d..bc7d34c 100644 --- a/packages/core/src/domain/CubeFacelets_test.res +++ b/packages/core/src/domain/CubeFacelets_test.res @@ -73,6 +73,22 @@ describe("patternDataToFacelets", () => { t->expect(CubeFacelets.patternDataToFacelets(pd)->Result.isError)->Expect.toBe(true) }) + test("rejects every center twist instead of dropping picture-cube state", t => { + for center in 0 to 5 { + for twist in 1 to 3 { + let orientation = Array.make(~length=6, 0) + orientation->Array.setUnsafe(center, twist) + let pd = { + ...solvedPatternData, + centers: {...solvedPatternData.centers, orientation}, + } + t + ->expect(CubeFacelets.patternDataToFacelets(pd)) + ->Expect.toEqual(Error("center twists cannot be represented by color facelets")) + } + } + }) + test("returns an error instead of throwing for malformed orbit arrays", t => { let malformed: CubeFacelets.patternData = { corners: {pieces: [], orientation: []}, diff --git a/src/adapters/cubing/patternReconciler.test.ts b/src/adapters/cubing/patternReconciler.test.ts index 41b61ed..1d0edda 100644 --- a/src/adapters/cubing/patternReconciler.test.ts +++ b/src/adapters/cubing/patternReconciler.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { KPattern } from 'cubing/kpuzzle'; +import { cube3x3x3 } from 'cubing/puzzles'; import * as CubeFacelets from '@wstein/regrip-core/domain/CubeFacelets'; import { createPatternReconciler } from './patternReconciler'; @@ -38,6 +40,42 @@ describe('Cubing pattern reconciler', () => { } }); + it('keeps ordinary cubing.js moves with untracked centers convertible', async () => { + const puzzle = await cube3x3x3.kpuzzle(); + await kpuzzleReady; + for (const initial of [puzzle.defaultPattern(), faceletsToPattern(solvedFacelets)]) { + for (const face of ['U', 'R', 'F', 'D', 'L', 'B']) { + for (const suffix of ['', "'", '2']) { + const moved = initial.applyMove(face + suffix); + expect(moved.patternData.CENTERS.orientationMod).toEqual([1, 1, 1, 1, 1, 1]); + expect(moved.patternData.CENTERS.orientation).toEqual([0, 0, 0, 0, 0, 0]); + expect(patternToFacelets(moved)).toBe( + CubeFacelets.patternDataToFaceletsExn( + CubeFacelets.applyMove( + CubeFacelets.faceletsToPatternDataExn(solvedFacelets), + face + suffix, + )!, + ), + ); + } + } + } + }); + + it('rejects a known picture-cube center twist produced by a face turn', async () => { + const puzzle = await cube3x3x3.kpuzzle(); + const data = puzzle.defaultPattern().patternData; + const picture = new KPattern(puzzle, { + ...data, + CENTERS: { pieces: [...data.CENTERS.pieces], orientation: [0, 0, 0, 0, 0, 0] }, + }); + const moved = picture.applyMove('R'); + expect(moved.patternData.CENTERS.orientation).toEqual([0, 0, 0, 1, 0, 0]); + expect(() => patternToFacelets(moved)).toThrow( + 'center twists cannot be represented by color facelets', + ); + }); + it('does not request a player reset when a facelet snapshot matches applied moves', async () => { await kpuzzleReady; const reconciler = createPatternReconciler(); diff --git a/src/adapters/cubing/utils.ts b/src/adapters/cubing/utils.ts index 9b0e242..e57d934 100644 --- a/src/adapters/cubing/utils.ts +++ b/src/adapters/cubing/utils.ts @@ -24,7 +24,11 @@ const kpuzzleReady: Promise = cube3x3x3.kpuzzle().then((v) => { /** * Convert cubing.js KPattern object to the facelets string in the Kociemba notation * @param pattern Source KPattern object + * Default and facelet-derived cubing.js patterns have untracked centers + * (`orientationMod: 1`); ordinary face turns remain supported. Known center + * twists in picture-cube patterns cannot be encoded as color facelets. * @returns String representing cube facelets in the Kociemba notation + * @throws if center positions are noncanonical or a center orientation is nonzero */ function patternToFacelets(pattern: KPattern): string { return CubeFacelets.patternDataToFaceletsExn(asCorePatternData(pattern.patternData));