Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ let package = Package(
),
.testTarget(
name: "CubeUITests",
dependencies: ["CubeUI", "CubeCore"],
dependencies: ["CubeUI", "CubeCore", "CubeScanner"],
path: "Tests/CubeUITests"
),
]
Expand Down
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,76 @@ ARKit and RealityKit integration (iOS only)
- **Modular Design**: Clean dependencies
- **Protocol-Oriented**: Flexible abstractions

## 🔄 Scan -> Solve Flow (New)

This repository now includes an additive scan pipeline and guided solve flow with DI-friendly boundaries.

### Main Types

- `CubeCore`:
- `FaceId`, `CubeFaceGrid`, `CubeStateAssembler`
- `KociembaCodec`, `MoveNotationCodec`
- `CubeStateValidator` with structured `ValidationError`
- `CubeSolving` protocol + `KociembaCompatibleCubeSolver`
- `CubeScanner`:
- `CameraFrameSource`, `FaceQuadDetecting`, `StickerColorClassifying`
- `DefaultFaceScanner`, `FaceWarpSampler`, `HSVStickerClassifier`
- `VisionFaceQuadDetector` and `CameraSessionFrameSource` adapters
- `SimulatedFaceScanner` for tests and demos
- `CubeUI`:
- `CubeScanSolveFlowViewModel`
- `ScanWizardView`, `FaceConfirmView`, `CubeManualEditView`, `SolveStepsView`

### Running The Flow

Use dependency injection to choose live camera or simulated inputs.

```swift
import CubeCore
import CubeScanner
import CubeUI

let scanner = SimulatedFaceScanner(scriptedFaces: [
.up: ScannedFaceData(id: .up, grid: CubeFaceGrid(repeating: .white), confidence: 1),
.right: ScannedFaceData(id: .right, grid: CubeFaceGrid(repeating: .blue), confidence: 1),
.front: ScannedFaceData(id: .front, grid: CubeFaceGrid(repeating: .red), confidence: 1),
.down: ScannedFaceData(id: .down, grid: CubeFaceGrid(repeating: .yellow), confidence: 1),
.left: ScannedFaceData(id: .left, grid: CubeFaceGrid(repeating: .green), confidence: 1),
.back: ScannedFaceData(id: .back, grid: CubeFaceGrid(repeating: .orange), confidence: 1)
])

let vm = CubeScanSolveFlowViewModel(scanner: scanner)
let view = ScanWizardView(viewModel: vm)
```

### Testing The Flow

Run all tests:

```bash
swift test --parallel
```

Run scanner-flow focused tests:

```bash
swift test --filter ScanSolvePipelineTests
swift test --filter ScanSolveFlowIntegrationTests
```

### Known Limitations

- `KociembaCompatibleCubeSolver` currently delegates to the built-in search solver (clean swap point for a true two-phase backend).
- `DefaultFaceScanner` depends on provided frame source quality; production camera UX tuning is still needed for low light and motion blur.
- Perspective handling uses bilinear face interpolation; a full homography/OpenCV warp can further improve edge cases.

### Next Improvements

- Add adaptive per-device color calibration from center stickers over multiple frames.
- Replace bilinear warping with true homography and temporal smoothing.
- Plug in a dedicated Kociemba/two-phase backend behind `CubeSolving`.
- Add UI test automation for scan wizard and manual edit conflict flows.

## 🎨 Glassmorphism Design

The app features a modern glassmorphic design inspired by macOS Big Sur and later:
Expand Down
164 changes: 164 additions & 0 deletions Sources/CubeCore/ScanSolveFlow/CubeScanDomain.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import Foundation

/// Canonical face IDs used throughout the scan -> solve flow.
public enum FaceId: String, CaseIterable, Codable, Sendable {
case up = "U"
case right = "R"
case front = "F"
case down = "D"
case left = "L"
case back = "B"

/// Guided scan order shown to users while capturing faces.
public static let guidedScanOrder: [FaceId] = [.up, .right, .front, .down, .left, .back]

public var face: Face {
switch self {
case .up: return .up
case .right: return .right
case .front: return .front
case .down: return .down
case .left: return .left
case .back: return .back
}
}

public init(face: Face) {
switch face {
case .up: self = .up
case .right: self = .right
case .front: self = .front
case .down: self = .down
case .left: self = .left
case .back: self = .back
}
}

public var displayName: String {
switch self {
case .up: return "Up"
case .right: return "Right"
case .front: return "Front"
case .down: return "Down"
case .left: return "Left"
case .back: return "Back"
}
}
}

public enum CubeFaceGridError: Error, LocalizedError, Equatable, Sendable {
case invalidStickerCount(expected: Int, actual: Int)

public var errorDescription: String? {
switch self {
case .invalidStickerCount(let expected, let actual):
return "Each face requires \(expected) stickers. Found \(actual)."
}
}
}

/// 3x3 grid for a single cube face.
public struct CubeFaceGrid: Equatable, Codable, Sendable {
public static let stickerCount = 9

public var stickers: [CubeColor]

public init(stickers: [CubeColor]) throws {
guard stickers.count == Self.stickerCount else {
throw CubeFaceGridError.invalidStickerCount(expected: Self.stickerCount, actual: stickers.count)
}
self.stickers = stickers
}

public init(repeating color: CubeColor) {
self.stickers = Array(repeating: color, count: Self.stickerCount)
}

public subscript(index: Int) -> CubeColor {
get { stickers[index] }
set { stickers[index] = newValue }
}

public subscript(row: Int, column: Int) -> CubeColor {
get {
stickers[Self.flatten(row: row, column: column)]
}
set {
stickers[Self.flatten(row: row, column: column)] = newValue
}
}

public var center: CubeColor {
stickers[4]
}

public var rows: [[CubeColor]] {
[
Array(stickers[0...2]),
Array(stickers[3...5]),
Array(stickers[6...8])
]
}

private static func flatten(row: Int, column: Int) -> Int {
max(0, min(2, row)) * 3 + max(0, min(2, column))
}
}

public struct ScannedFaceData: Equatable, Codable, Sendable {
public let id: FaceId
public var grid: CubeFaceGrid
public let confidence: Float
public let debugImagePath: String?

public init(id: FaceId, grid: CubeFaceGrid, confidence: Float, debugImagePath: String? = nil) {
self.id = id
self.grid = grid
self.confidence = confidence
self.debugImagePath = debugImagePath
}
}

public enum CubeStateAssemblyError: Error, LocalizedError, Equatable, Sendable {
case missingFaces([FaceId])

public var errorDescription: String? {
switch self {
case .missingFaces(let missing):
let names = missing.map(\.rawValue).joined(separator: ", ")
return "Missing scanned faces: \(names)."
}
}
}

public struct CubeStateAssembler: Sendable {
public init() {}

public func assemble(from scannedFaces: [FaceId: CubeFaceGrid]) throws -> CubeState {
let missing = FaceId.guidedScanOrder.filter { scannedFaces[$0] == nil }
guard missing.isEmpty else {
throw CubeStateAssemblyError.missingFaces(missing)
}

var faces: [Face: [CubeColor]] = [:]
for faceId in FaceId.guidedScanOrder {
guard let grid = scannedFaces[faceId] else { continue }
faces[faceId.face] = grid.stickers
}

return CubeState(faces: faces)
}
}

public extension CubeState {
func faceGrid(_ id: FaceId) -> CubeFaceGrid? {
guard let stickers = faces[id.face], stickers.count == CubeFaceGrid.stickerCount else {
return nil
}
return try? CubeFaceGrid(stickers: stickers)
}

mutating func setFaceGrid(_ grid: CubeFaceGrid, for id: FaceId) {
faces[id.face] = grid.stickers
}
}
121 changes: 121 additions & 0 deletions Sources/CubeCore/ScanSolveFlow/CubeSolving.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import Foundation

/// Async solver abstraction used by the scan -> solve flow.
///
/// Note: A legacy class named `CubeSolver` already exists in this module,
/// so this protocol uses a distinct name to preserve backward compatibility.
public protocol CubeSolving: Sendable {
func solve(state: CubeState) async throws -> [Move]
}

public enum CubeSolvingError: Error, LocalizedError, Equatable, Sendable {
case validationFailed(ValidationError)
case invalidMoveFormat(String)

public var errorDescription: String? {
switch self {
case .validationFailed(let error):
return error.message
case .invalidMoveFormat(let token):
return "Solver returned an invalid move token: \(token)."
}
}
}

public struct AnyCubeSolver: CubeSolving {
private let solveHandler: @Sendable (CubeState) async throws -> [Move]

public init<S: CubeSolving>(_ solver: S) {
self.solveHandler = solver.solve
}

public init(_ solveHandler: @escaping @Sendable (CubeState) async throws -> [Move]) {
self.solveHandler = solveHandler
}

public func solve(state: CubeState) async throws -> [Move] {
try await solveHandler(state)
}
}

public struct EnhancedSearchCubeSolver: CubeSolving {
public let validationMode: CubeValidationMode

public init(validationMode: CubeValidationMode = .basic) {
self.validationMode = validationMode
}

public func solve(state: CubeState) async throws -> [Move] {
try await EnhancedCubeSolver.solveCubeAsync(from: state, validationMode: validationMode)
}
}

/// Kociemba-compatible wrapper.
///
/// This currently delegates to `EnhancedSearchCubeSolver` to keep the project
/// dependency-free. Swap the internals with a two-phase engine later without
/// changing callers.
public struct KociembaCompatibleCubeSolver: CubeSolving {
private let fallback: AnyCubeSolver
private let notationCodec = MoveNotationCodec()

public init(fallback: AnyCubeSolver = AnyCubeSolver(EnhancedSearchCubeSolver())) {
self.fallback = fallback
}

public func solve(state: CubeState) async throws -> [Move] {
let moves = try await fallback.solve(state: state)

// Defensive check that all moves remain valid standard notation.
for move in moves {
guard Move(notation: move.notation) != nil else {
throw CubeSolvingError.invalidMoveFormat(move.notation)
}
}

_ = notationCodec.encode(moves)
return moves
}
}

public struct SolutionInstruction: Equatable, Sendable {
public let index: Int
public let total: Int
public let move: Move

public init(index: Int, total: Int, move: Move) {
self.index = index
self.total = total
self.move = move
}

public var progressText: String {
"\(index)/\(total)"
}

public var headline: String {
move.notation
}

public var explanation: String {
switch move.amount {
case .clockwise:
return "Turn the \(faceName) face clockwise."
case .counter:
return "Turn the \(faceName) face counter-clockwise."
case .double:
return "Turn the \(faceName) face twice (180 degrees)."
}
}

private var faceName: String {
switch move.turn {
case .U: return "top"
case .D: return "bottom"
case .L: return "left"
case .R: return "right"
case .F: return "front"
case .B: return "back"
}
}
}
Loading
Loading