From 16f9203c0485ac90dcee7c3a28b27f25fa38e0f8 Mon Sep 17 00:00:00 2001 From: Mark Coleman Date: Sun, 22 Feb 2026 12:07:58 -0500 Subject: [PATCH 1/4] fixes --- .../CubeScanner/CubeCamCapturePipeline.swift | 47 +++++- Sources/CubeScanner/CubeCamViewModel.swift | 21 ++- .../CubeScanner/CubeScanErrorDetector.swift | 21 +-- .../EnhancedCubeCamViewModel.swift | 16 ++ Sources/CubeScanner/FaceScanState.swift | 10 +- Sources/CubeUI/CubeCamSharedComponents.swift | 149 +++++++++++++++--- Sources/CubeUI/CubeCamView.swift | 13 +- Sources/CubeUI/EnhancedCubeCamView.swift | 18 ++- Sources/CubeUI/HomeView.swift | 2 +- 9 files changed, 232 insertions(+), 65 deletions(-) diff --git a/Sources/CubeScanner/CubeCamCapturePipeline.swift b/Sources/CubeScanner/CubeCamCapturePipeline.swift index 7674b9d..076696a 100644 --- a/Sources/CubeScanner/CubeCamCapturePipeline.swift +++ b/Sources/CubeScanner/CubeCamCapturePipeline.swift @@ -72,6 +72,9 @@ public final class CubeCamCapturePipeline: ObservableObject { /// PROMPT 8: Current scan error (if any) @Published public var currentError: CubeScanErrorDetector.ScanError? + + /// Warning message shown when a face appears to be a duplicate of one already captured. + @Published public var duplicateFaceWarning: String? /// Single source of truth for capture state @Published public var captureState: CaptureState = .idle @@ -98,6 +101,12 @@ public final class CubeCamCapturePipeline: ObservableObject { /// Delay before resetting state after successful capture (seconds) public var captureResetDelay: TimeInterval = 0.5 + + /// Stability threshold used to count a frame toward capture readiness. + public var stableFrameThreshold: Float = 0.7 + + /// Whether capture order is mandatory (`true`) or only used as guidance (`false`). + public var enforceCaptureOrder: Bool = false // MARK: - Private Properties @@ -317,9 +326,12 @@ public final class CubeCamCapturePipeline: ObservableObject { public func reset() { logDebug("[CubeCam] 🔄 Resetting pipeline") capturedFaces = [:] + capturedPatterns = [:] pendingFace = nil stability = 0 lastDetection = nil + currentError = nil + duplicateFaceWarning = nil detectionHistory = [] currentFaceEstimate = nil faceEstimateConfidence = 0 @@ -348,8 +360,7 @@ public final class CubeCamCapturePipeline: ObservableObject { let oldState = captureState // PROMPT 1: Track consecutive stable frames - // Threshold aligned with auto-capture threshold to ensure frames counted as stable will trigger capture - if stability >= 0.85 && lightingStable { + if stability >= stableFrameThreshold && lightingStable { consecutiveStableFrames += 1 isScanning = consecutiveStableFrames >= requiredStableFrames @@ -581,7 +592,7 @@ public final class CubeCamCapturePipeline: ObservableObject { logDebug("[CubeCam] ⏭️ No capture: No pending face") return false } - guard face == targetFace else { + guard !enforceCaptureOrder || face == targetFace else { logDebug("[CubeCam] ⏭️ No capture: Waiting for \(targetFace), currently seeing \(face)") return false } @@ -615,9 +626,9 @@ public final class CubeCamCapturePipeline: ObservableObject { return false } - // Check stability - lowered threshold to match consecutive frame threshold - guard stability >= 0.85 else { - logDebug("[CubeCam] ⏭️ No capture: Stability too low (\(stability) < 0.85)") + // Check stability against configured frame threshold. + guard stability >= stableFrameThreshold else { + logDebug("[CubeCam] ⏭️ No capture: Stability too low (\(stability) < \(stableFrameThreshold))") return false } @@ -673,6 +684,7 @@ public final class CubeCamCapturePipeline: ObservableObject { // Mark as capturing to prevent duplicates hasCapturedThisCycle = true captureState = .capturing + duplicateFaceWarning = nil // PROMPT 8: Validate lighting let brightness = detectionHistory.last?.brightness ?? 0.5 @@ -710,6 +722,7 @@ public final class CubeCamCapturePipeline: ObservableObject { // PROMPT 2: Check for duplicate patterns if let duplicateFace = findDuplicatePattern(colors: colors, excluding: face) { logWarning("[CubeCam] Duplicate pattern detected - matches face: \(duplicateFace)") + duplicateFaceWarning = "That looks like the \(faceDisplayName(duplicateFace)) face you already scanned. Rotate to a different side." // This pattern was already scanned for a different face // Skip this capture and reset resetAfterFailedCapture(face: face, timestamp: timestamp) @@ -726,6 +739,7 @@ public final class CubeCamCapturePipeline: ObservableObject { // Clear any errors currentError = nil + duplicateFaceWarning = nil // Update state to captured captureState = .captured @@ -760,15 +774,23 @@ public final class CubeCamCapturePipeline: ObservableObject { lastDetection = nil hasCapturedThisCycle = false captureState = .idle + duplicateFaceWarning = nil } /// PROMPT 2: Find if this color pattern matches an already-captured face /// Returns the face that has this pattern, or nil if unique private func findDuplicatePattern(colors: [CubeColor], excluding: Face) -> Face? { - let tolerance = 2 // Allow up to 2 sticker differences for tolerance + guard colors.count == 9 else { + return nil + } + + // Require center sticker match and near-identical full pattern to avoid false positives. + let tolerance = 1 for (face, pattern) in capturedPatterns { guard face != excluding else { continue } + guard pattern.count == colors.count else { continue } + guard pattern[4] == colors[4] else { continue } // Count differences var differences = 0 @@ -787,6 +809,17 @@ public final class CubeCamCapturePipeline: ObservableObject { return nil } + private func faceDisplayName(_ face: Face) -> String { + switch face { + case .up: return "top" + case .down: return "bottom" + case .left: return "left" + case .right: return "right" + case .front: return "front" + case .back: return "back" + } + } + private static func normalizedCaptureOrder(from order: [Face]) -> [Face] { var seen: Set = [] var normalized: [Face] = [] diff --git a/Sources/CubeScanner/CubeCamViewModel.swift b/Sources/CubeScanner/CubeCamViewModel.swift index b333750..2e685a7 100644 --- a/Sources/CubeScanner/CubeCamViewModel.swift +++ b/Sources/CubeScanner/CubeCamViewModel.swift @@ -13,6 +13,7 @@ import Combine import CubeCore import UIKit import AVFoundation +import CoreVideo /// View model for Cube Cam auto-scanning experience @MainActor @@ -67,6 +68,9 @@ public class CubeCamViewModel: ObservableObject { /// Frame metadata for debugging @Published public var frameMetadata: FrameMetadata? + + /// Current video frame size used for camera overlay coordinate mapping. + @Published public var videoFrameSize: CGSize = .zero // MARK: - Private Properties @@ -223,6 +227,9 @@ public class CubeCamViewModel: ObservableObject { capturePipeline.$lastDetection .assign(to: &$detectionResult) + + capturePipeline.$duplicateFaceWarning + .assign(to: &$duplicateFaceWarning) } private func startFrameProcessing() { @@ -239,6 +246,11 @@ public class CubeCamViewModel: ObservableObject { continue } + self.videoFrameSize = CGSize( + width: CVPixelBufferGetWidth(videoFrame), + height: CVPixelBufferGetHeight(videoFrame) + ) + let depthFrame = await self.cameraSession.lastDepthFrame let timestamp = Date().timeIntervalSince1970 @@ -274,17 +286,18 @@ public class CubeCamViewModel: ObservableObject { private func updateProgressText() { // PROMPT 5: Enhanced step-by-step guidance if capturedFaceCount == 0 { - captureProgressText = "Step 1: Position cube so a face fills the frame" + captureProgressText = "Step 1: Center any face in the guide and hold steady" } else if capturedFaceCount < 6 { if let nextFace = capturePipeline.getNextFaceToCapture() { let nextFaceDisplayName = faceDisplayName(nextFace) - captureProgressText = "Step \(capturedFaceCount + 1): Scan the \(nextFaceDisplayName) face" + captureProgressText = + "Step \(capturedFaceCount + 1): Scan any new face (suggested: \(nextFaceDisplayName))" // PROMPT 3: Add wrong face warning if detected if let detectedFace = currentFace, - detectedFace != nextFace, capturedFaces.contains(detectedFace) { - wrongFaceWarning = "This is the \(faceDisplayName(detectedFace)) face (already scanned). Please scan the \(nextFaceDisplayName) face next." + wrongFaceWarning = + "The \(faceDisplayName(detectedFace)) face is already scanned. Rotate to any unscanned side." } else { wrongFaceWarning = nil } diff --git a/Sources/CubeScanner/CubeScanErrorDetector.swift b/Sources/CubeScanner/CubeScanErrorDetector.swift index af64665..2f5e8e8 100644 --- a/Sources/CubeScanner/CubeScanErrorDetector.swift +++ b/Sources/CubeScanner/CubeScanErrorDetector.swift @@ -83,23 +83,12 @@ public actor CubeScanErrorDetector { /// Validate that colors are readable public func validateColors(_ colors: [CubeColor]) -> ScanError? { - // Check if all colors are the same (likely misread) - let uniqueColors = Set(colors) - if uniqueColors.count == 1 { - return .unreadableColors - } - - // Check if we have too many of the same color - let colorCounts = colors.reduce(into: [:]) { counts, color in - counts[color, default: 0] += 1 - } - - // In a valid face, no color should appear more than 9 times - // and the center color should appear at least once - if let maxCount = colorCounts.values.max(), maxCount > 5 { - return .unreadableColors + // A face can legitimately be monochrome or color-dominant (for example near-solved cubes), + // so we only reject obviously invalid payloads here. + guard colors.count == 9 else { + return .invalidFaceLayout } - + return nil } diff --git a/Sources/CubeScanner/EnhancedCubeCamViewModel.swift b/Sources/CubeScanner/EnhancedCubeCamViewModel.swift index fb83b02..0e2d950 100644 --- a/Sources/CubeScanner/EnhancedCubeCamViewModel.swift +++ b/Sources/CubeScanner/EnhancedCubeCamViewModel.swift @@ -13,6 +13,7 @@ import Combine import CubeCore import UIKit import AVFoundation +import CoreVideo /// Enhanced view model for CubeCam with improved UX and step-by-step guidance @MainActor @@ -49,6 +50,12 @@ public final class EnhancedCubeCamViewModel: ObservableObject { /// Frame metadata @Published public var frameMetadata: FrameMetadata? + + /// Current video frame size used for camera overlay coordinate mapping. + @Published public var videoFrameSize: CGSize = .zero + + /// Warning when the detected face likely duplicates one already captured. + @Published public var duplicateFaceWarning: String? // MARK: - Private Properties @@ -108,6 +115,7 @@ public final class EnhancedCubeCamViewModel: ObservableObject { isComplete = false lastScanResult = nil currentError = nil + duplicateFaceWarning = nil updateGuidance() } @@ -227,6 +235,9 @@ public final class EnhancedCubeCamViewModel: ObservableObject { // Monitor detection results capturePipeline.$lastDetection .assign(to: &$detectionResult) + + capturePipeline.$duplicateFaceWarning + .assign(to: &$duplicateFaceWarning) // Monitor errors capturePipeline.$currentError @@ -280,6 +291,11 @@ public final class EnhancedCubeCamViewModel: ObservableObject { continue } + self.videoFrameSize = CGSize( + width: CVPixelBufferGetWidth(videoFrame), + height: CVPixelBufferGetHeight(videoFrame) + ) + let depthFrame = await self.cameraSession.lastDepthFrame let timestamp = Date().timeIntervalSince1970 diff --git a/Sources/CubeScanner/FaceScanState.swift b/Sources/CubeScanner/FaceScanState.swift index 8b26f78..355d42a 100644 --- a/Sources/CubeScanner/FaceScanState.swift +++ b/Sources/CubeScanner/FaceScanState.swift @@ -97,16 +97,16 @@ public struct ScanStepGuidance: Equatable { switch stepNumber { case 1: - instruction = "Position cube so the \(faceName) face fills the frame" + instruction = "Position any face in the frame (suggested: \(faceName))" hint = "Hold steady when the outline turns green" iconName = "1.circle.fill" case 2...5: - instruction = "Rotate cube to show the \(faceName) face" - hint = "Previous: \(capturedFacesList(upTo: stepNumber - 1))" + instruction = "Show any unscanned face (suggested: \(faceName))" + hint = "Captured: \(capturedFacesList(upTo: stepNumber - 1))" iconName = "\(stepNumber).circle.fill" case 6: - instruction = "Final face! Scan the \(faceName) face" - hint = "Almost done!" + instruction = "Final face! Scan the last unscanned side" + hint = "Suggested final side: \(faceName)" iconName = "6.circle.fill" default: instruction = "Scan the \(faceName) face" diff --git a/Sources/CubeUI/CubeCamSharedComponents.swift b/Sources/CubeUI/CubeCamSharedComponents.swift index 4dc5e75..7d59b0a 100644 --- a/Sources/CubeUI/CubeCamSharedComponents.swift +++ b/Sources/CubeUI/CubeCamSharedComponents.swift @@ -66,47 +66,148 @@ class CameraPreviewUIView: UIView { struct DetectionOverlay: View { let detection: CubeFaceDetectionResult let stability: Float + let sourceImageSize: CGSize + var showsPreciseBounds: Bool = false var body: some View { GeometryReader { geometry in - let rect = convertNormalizedRect(detection.boundingBox, in: geometry.size) - - Rectangle() - .stroke( - stability > 0.7 ? Color.green : Color.yellow, - lineWidth: 3 - ) - .frame(width: rect.width, height: rect.height) - .position(x: rect.midX, y: rect.midY) - - // Corner markers - ForEach(0..<4, id: \.self) { index in - let corner = detection.corners[index] - let point = convertNormalizedPoint(corner, in: geometry.size) - + let guideRect = framingRect(in: geometry.size) + let detectionRect = convertNormalizedRect(detection.boundingBox, in: geometry.size) + let overlap = overlapRatio(between: guideRect, and: detectionRect) + let isAligned = overlap >= 0.45 && stability >= 0.7 + + ZStack { + // Primary user-facing guide. This remains stable and avoids the "jumping box" UX. + RoundedRectangle(cornerRadius: 18) + .stroke( + isAligned ? Color.green : Color.white.opacity(0.75), + style: StrokeStyle(lineWidth: 3, dash: [10, 6]) + ) + .frame(width: guideRect.width, height: guideRect.height) + .position(x: guideRect.midX, y: guideRect.midY) + Circle() - .fill(stability > 0.7 ? Color.green : Color.yellow) - .frame(width: 12, height: 12) - .position(point) + .fill(isAligned ? Color.green.opacity(0.35) : Color.white.opacity(0.2)) + .frame(width: 16, height: 16) + .position(x: guideRect.midX, y: guideRect.midY) + + // Optional precise bounds for debug sessions. + if showsPreciseBounds { + Rectangle() + .stroke( + isAligned ? Color.green : Color.yellow, + lineWidth: 2 + ) + .frame(width: detectionRect.width, height: detectionRect.height) + .position(x: detectionRect.midX, y: detectionRect.midY) + + ForEach(Array(detection.corners.enumerated()), id: \.offset) { _, corner in + let point = convertNormalizedPoint(corner, in: geometry.size) + + Circle() + .fill(isAligned ? Color.green : Color.yellow) + .frame(width: 10, height: 10) + .position(point) + } + } } } + .allowsHitTesting(false) } private func convertNormalizedRect(_ rect: CGRect, in size: CGSize) -> CGRect { + guard sourceImageSize.width > 0, sourceImageSize.height > 0 else { + return CGRect( + x: rect.minX * size.width, + y: (1 - rect.maxY) * size.height, + width: rect.width * size.width, + height: rect.height * size.height + ) + } + + // Vision uses a bottom-left origin; UI uses top-left. + let normalizedRect = CGRect( + x: rect.minX, + y: 1 - rect.maxY, + width: rect.width, + height: rect.height + ) + + let source = normalizedSourceSize(for: size) + let scale = max(size.width / source.width, size.height / source.height) + let scaledWidth = source.width * scale + let scaledHeight = source.height * scale + let xInset = (scaledWidth - size.width) / 2 + let yInset = (scaledHeight - size.height) / 2 + return CGRect( - x: rect.minX * size.width, - y: (1 - rect.maxY) * size.height, - width: rect.width * size.width, - height: rect.height * size.height + x: (normalizedRect.minX * scaledWidth) - xInset, + y: (normalizedRect.minY * scaledHeight) - yInset, + width: normalizedRect.width * scaledWidth, + height: normalizedRect.height * scaledHeight ) } private func convertNormalizedPoint(_ point: CGPoint, in size: CGSize) -> CGPoint { + guard sourceImageSize.width > 0, sourceImageSize.height > 0 else { + return CGPoint( + x: point.x * size.width, + y: (1 - point.y) * size.height + ) + } + + let source = normalizedSourceSize(for: size) + let scale = max(size.width / source.width, size.height / source.height) + let scaledWidth = source.width * scale + let scaledHeight = source.height * scale + let xInset = (scaledWidth - size.width) / 2 + let yInset = (scaledHeight - size.height) / 2 + return CGPoint( - x: point.x * size.width, - y: (1 - point.y) * size.height + x: (point.x * scaledWidth) - xInset, + y: ((1 - point.y) * scaledHeight) - yInset + ) + } + + private func framingRect(in size: CGSize) -> CGRect { + let side = min(size.width, size.height) * 0.62 + return CGRect( + x: (size.width - side) / 2, + y: (size.height - side) / 2, + width: side, + height: side ) } + + private func overlapRatio(between lhs: CGRect, and rhs: CGRect) -> CGFloat { + guard !lhs.isEmpty, !rhs.isEmpty else { + return 0 + } + + let intersection = lhs.intersection(rhs) + guard !intersection.isNull else { + return 0 + } + + let lhsArea = lhs.width * lhs.height + guard lhsArea > 0 else { + return 0 + } + + return (intersection.width * intersection.height) / lhsArea + } + + private func normalizedSourceSize(for viewSize: CGSize) -> CGSize { + // If frame orientation disagrees with the view orientation, swap dimensions. + let sourceIsPortrait = sourceImageSize.height >= sourceImageSize.width + let viewIsPortrait = viewSize.height >= viewSize.width + + if sourceIsPortrait == viewIsPortrait { + return sourceImageSize + } + + return CGSize(width: sourceImageSize.height, height: sourceImageSize.width) + } } // MARK: - Stability Indicator diff --git a/Sources/CubeUI/CubeCamView.swift b/Sources/CubeUI/CubeCamView.swift index b4713f3..bd4f2c1 100644 --- a/Sources/CubeUI/CubeCamView.swift +++ b/Sources/CubeUI/CubeCamView.swift @@ -33,15 +33,14 @@ public struct CubeCamView: View { CubeCamCameraPreviewView(viewModel: viewModel) .ignoresSafeArea() - // PROMPT 9: Alignment guide (shown in manual mode) - if !viewModel.capturePipeline.autoCaptureEnabled { - AlignmentGuide(isAligned: viewModel.stability > 0.7) - .allowsHitTesting(false) - } - // Detection overlay if let detection = viewModel.detectionResult { - DetectionOverlay(detection: detection, stability: viewModel.stability) + DetectionOverlay( + detection: detection, + stability: viewModel.stability, + sourceImageSize: viewModel.videoFrameSize, + showsPreciseBounds: viewModel.debugModeEnabled + ) } // Face capture flash animation diff --git a/Sources/CubeUI/EnhancedCubeCamView.swift b/Sources/CubeUI/EnhancedCubeCamView.swift index 7aa607f..588a636 100644 --- a/Sources/CubeUI/EnhancedCubeCamView.swift +++ b/Sources/CubeUI/EnhancedCubeCamView.swift @@ -35,7 +35,11 @@ public struct EnhancedCubeCamView: View { // Detection overlay (bounding box and corners) if let detection = viewModel.detectionResult { - DetectionOverlay(detection: detection, stability: viewModel.stability) + DetectionOverlay( + detection: detection, + stability: viewModel.stability, + sourceImageSize: viewModel.videoFrameSize + ) } // Main UI overlay @@ -131,6 +135,18 @@ public struct EnhancedCubeCamView: View { viewModel.lastScanResult = nil } } + + if let warning = viewModel.duplicateFaceWarning { + VStack { + DuplicateFaceWarning(message: warning) { + viewModel.duplicateFaceWarning = nil + } + .padding(.top, 100) + + Spacer() + } + .transition(.move(edge: .top).combined(with: .opacity)) + } // Error overlay if let errorType = viewModel.currentError { diff --git a/Sources/CubeUI/HomeView.swift b/Sources/CubeUI/HomeView.swift index af96802..ab7c9dd 100644 --- a/Sources/CubeUI/HomeView.swift +++ b/Sources/CubeUI/HomeView.swift @@ -103,7 +103,7 @@ public struct HomeView: View { VStack(spacing: 16) { // Cube Cam - Auto-scan with video #if canImport(AVFoundation) && os(iOS) - NavigationLink(destination: CubeCamView { cubeState in + NavigationLink(destination: EnhancedCubeCamView { cubeState in // Handle completed cube state sessionViewModel.setCubeStateFromScan(cubeState) cubeViewModel.cube = cubeState.toRubiksCube() From 599b428637892f0229aac2e2bc93488e746ff9f4 Mon Sep 17 00:00:00 2001 From: Mark Coleman Date: Sun, 22 Feb 2026 14:23:29 -0500 Subject: [PATCH 2/4] sort of working --- Package.swift | 2 +- README.md | 70 +++ .../ScanSolveFlow/CubeScanDomain.swift | 164 ++++++ .../CubeCore/ScanSolveFlow/CubeSolving.swift | 121 +++++ .../ScanSolveFlow/CubeStateValidator.swift | 467 ++++++++++++++++++ .../ScanSolveFlow/KociembaCodec.swift | 132 +++++ .../CubeScanner/CubeCamCapturePipeline.swift | 7 + Sources/CubeScanner/CubeCamViewModel.swift | 17 +- .../CameraSessionFrameSource.swift | 29 ++ .../ScanSolveFlow/DefaultFaceScanner.swift | 88 ++++ .../ScanSolveFlow/FaceWarpSampler.swift | 90 ++++ .../ScanSolveFlow/HSVStickerClassifier.swift | 127 +++++ .../ScanningPipelineModels.swift | 225 +++++++++ .../ScanSolveFlow/SimulatedFaceScanner.swift | 32 ++ .../VisionFaceQuadDetector.swift | 45 ++ Sources/CubeUI/HomeView.swift | 11 + .../ScanSolveFlow/CubeManualEditView.swift | 136 +++++ .../CubeScanSolveComponents.swift | 74 +++ .../CubeScanSolveFlowViewModel.swift | 265 ++++++++++ .../ScanSolveFlow/FaceConfirmView.swift | 46 ++ .../LiveScanWizardContainerView.swift | 224 +++++++++ .../ScanSolveFlow/RotatingScanCubeView.swift | 347 +++++++++++++ .../ScanSolveFlow/ScanFaceGuidanceView.swift | 135 +++++ .../CubeUI/ScanSolveFlow/ScanWizardView.swift | 155 ++++++ .../CubeUI/ScanSolveFlow/SolveStepsView.swift | 104 ++++ .../CubeSolvingAbstractionTests.swift | 40 ++ .../CubeStateValidatorEngineTests.swift | 103 ++++ .../CubeCoreTests/ScanSolveDomainTests.swift | 75 +++ .../ScanSolvePipelineTests.swift | 104 ++++ .../ScanSolveFlowIntegrationTests.swift | 72 +++ 30 files changed, 3505 insertions(+), 2 deletions(-) create mode 100644 Sources/CubeCore/ScanSolveFlow/CubeScanDomain.swift create mode 100644 Sources/CubeCore/ScanSolveFlow/CubeSolving.swift create mode 100644 Sources/CubeCore/ScanSolveFlow/CubeStateValidator.swift create mode 100644 Sources/CubeCore/ScanSolveFlow/KociembaCodec.swift create mode 100644 Sources/CubeScanner/ScanSolveFlow/CameraSessionFrameSource.swift create mode 100644 Sources/CubeScanner/ScanSolveFlow/DefaultFaceScanner.swift create mode 100644 Sources/CubeScanner/ScanSolveFlow/FaceWarpSampler.swift create mode 100644 Sources/CubeScanner/ScanSolveFlow/HSVStickerClassifier.swift create mode 100644 Sources/CubeScanner/ScanSolveFlow/ScanningPipelineModels.swift create mode 100644 Sources/CubeScanner/ScanSolveFlow/SimulatedFaceScanner.swift create mode 100644 Sources/CubeScanner/ScanSolveFlow/VisionFaceQuadDetector.swift create mode 100644 Sources/CubeUI/ScanSolveFlow/CubeManualEditView.swift create mode 100644 Sources/CubeUI/ScanSolveFlow/CubeScanSolveComponents.swift create mode 100644 Sources/CubeUI/ScanSolveFlow/CubeScanSolveFlowViewModel.swift create mode 100644 Sources/CubeUI/ScanSolveFlow/FaceConfirmView.swift create mode 100644 Sources/CubeUI/ScanSolveFlow/LiveScanWizardContainerView.swift create mode 100644 Sources/CubeUI/ScanSolveFlow/RotatingScanCubeView.swift create mode 100644 Sources/CubeUI/ScanSolveFlow/ScanFaceGuidanceView.swift create mode 100644 Sources/CubeUI/ScanSolveFlow/ScanWizardView.swift create mode 100644 Sources/CubeUI/ScanSolveFlow/SolveStepsView.swift create mode 100644 Tests/CubeCoreTests/CubeSolvingAbstractionTests.swift create mode 100644 Tests/CubeCoreTests/CubeStateValidatorEngineTests.swift create mode 100644 Tests/CubeCoreTests/ScanSolveDomainTests.swift create mode 100644 Tests/CubeScannerTests/ScanSolvePipelineTests.swift create mode 100644 Tests/CubeUITests/ScanSolveFlowIntegrationTests.swift diff --git a/Package.swift b/Package.swift index 7bff016..0a59f47 100644 --- a/Package.swift +++ b/Package.swift @@ -81,7 +81,7 @@ let package = Package( ), .testTarget( name: "CubeUITests", - dependencies: ["CubeUI", "CubeCore"], + dependencies: ["CubeUI", "CubeCore", "CubeScanner"], path: "Tests/CubeUITests" ), ] diff --git a/README.md b/README.md index 5c8707c..a3753a5 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/Sources/CubeCore/ScanSolveFlow/CubeScanDomain.swift b/Sources/CubeCore/ScanSolveFlow/CubeScanDomain.swift new file mode 100644 index 0000000..20525e1 --- /dev/null +++ b/Sources/CubeCore/ScanSolveFlow/CubeScanDomain.swift @@ -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 + } +} diff --git a/Sources/CubeCore/ScanSolveFlow/CubeSolving.swift b/Sources/CubeCore/ScanSolveFlow/CubeSolving.swift new file mode 100644 index 0000000..a30c79e --- /dev/null +++ b/Sources/CubeCore/ScanSolveFlow/CubeSolving.swift @@ -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(_ 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" + } + } +} diff --git a/Sources/CubeCore/ScanSolveFlow/CubeStateValidator.swift b/Sources/CubeCore/ScanSolveFlow/CubeStateValidator.swift new file mode 100644 index 0000000..8c9922b --- /dev/null +++ b/Sources/CubeCore/ScanSolveFlow/CubeStateValidator.swift @@ -0,0 +1,467 @@ +import Foundation + +public enum ValidationErrorType: String, Codable, Sendable { + case countMismatch + case nonUniqueCenters + case invalidFace + case missingPiece + case duplicatePiece + case invalidEdgeOrientation + case invalidCornerOrientation + case impossibleParity +} + +public struct ValidationError: Error, LocalizedError, Equatable, Sendable { + public let type: ValidationErrorType + public let message: String + public let suggestedFix: String + public let likelyFaces: [FaceId] + + public init( + type: ValidationErrorType, + message: String, + suggestedFix: String, + likelyFaces: [FaceId] = [] + ) { + self.type = type + self.message = message + self.suggestedFix = suggestedFix + self.likelyFaces = likelyFaces + } + + public var errorDescription: String? { + message + } +} + +public protocol CubeStateValidating: Sendable { + func validate(state: CubeState) -> Result +} + +public struct CubeStateValidator: CubeStateValidating { + public init() {} + + public func validate(state: CubeState) -> Result { + if let failure = validateFaceConfiguration(state) { + return .failure(failure) + } + if let failure = validateColorCounts(state) { + return .failure(failure) + } + if let failure = validateCenters(state) { + return .failure(failure) + } + if let failure = validatePieceConstraints(state) { + return .failure(failure) + } + + return .success(()) + } + + public func validateOrThrow(state: CubeState) throws { + switch validate(state: state) { + case .success: + return + case .failure(let error): + throw error + } + } + + private func validateFaceConfiguration(_ state: CubeState) -> ValidationError? { + for face in Face.allCases { + guard let stickers = state.faces[face] else { + return ValidationError( + type: .invalidFace, + message: "Face \(face.rawValue) is missing.", + suggestedFix: "Re-scan this face or fill it in manually.", + likelyFaces: [FaceId(face: face)] + ) + } + guard stickers.count == CubeFaceGrid.stickerCount else { + return ValidationError( + type: .invalidFace, + message: "Face \(face.rawValue) has \(stickers.count) stickers; expected 9.", + suggestedFix: "Use manual edit to correct this face.", + likelyFaces: [FaceId(face: face)] + ) + } + } + return nil + } + + private func validateColorCounts(_ state: CubeState) -> ValidationError? { + var counts: [CubeColor: Int] = [:] + for color in CubeColor.allCases { + counts[color] = 0 + } + + for face in Face.allCases { + guard let stickers = state.faces[face] else { continue } + for sticker in stickers { + counts[sticker, default: 0] += 1 + } + } + + for color in CubeColor.allCases { + let count = counts[color, default: 0] + if count != 9 { + let direction = count > 9 ? "too many" : "too few" + return ValidationError( + type: .countMismatch, + message: "Color \(color.rawValue) has \(count) stickers; this is \(direction) (expected 9).", + suggestedFix: "Open manual edit and rebalance sticker colors to exactly 9 each." + ) + } + } + + return nil + } + + private func validateCenters(_ state: CubeState) -> ValidationError? { + var centers: Set = [] + for face in Face.allCases { + guard let center = state.centerColor(of: face) else { + return ValidationError( + type: .invalidFace, + message: "Face \(face.rawValue) has no center sticker.", + suggestedFix: "Re-scan or edit that face center.", + likelyFaces: [FaceId(face: face)] + ) + } + centers.insert(center) + } + + if centers.count != Face.allCases.count { + return ValidationError( + type: .nonUniqueCenters, + message: "Center colors must be unique across all six faces.", + suggestedFix: "Check center stickers first; one face center is likely misclassified." + ) + } + + return nil + } + + private func validatePieceConstraints(_ state: CubeState) -> ValidationError? { + let centerColors = Face.allCases.reduce(into: [Face: CubeColor]()) { result, face in + if let center = state.centerColor(of: face) { + result[face] = center + } + } + + guard centerColors.count == Face.allCases.count else { + return ValidationError( + type: .invalidFace, + message: "Cannot evaluate piece constraints without all center colors.", + suggestedFix: "Ensure each scanned face includes a center sticker." + ) + } + + let edgeValidation = validateEdges(state: state, centers: centerColors) + if let error = edgeValidation.error { + return error + } + + let cornerValidation = validateCorners(state: state, centers: centerColors) + if let error = cornerValidation.error { + return error + } + + guard edgeValidation.orientationSum % 2 == 0 else { + return ValidationError( + type: .invalidEdgeOrientation, + message: "Edge orientation is impossible for a physical 3x3 cube.", + suggestedFix: "A flipped edge is likely mis-scanned. Re-scan front/back adjacent edges." + ) + } + + guard cornerValidation.orientationSum % 3 == 0 else { + return ValidationError( + type: .invalidCornerOrientation, + message: "Corner orientation is impossible for a physical 3x3 cube.", + suggestedFix: "A twisted corner is likely mis-scanned. Re-scan top-layer corners or edit manually." + ) + } + + let edgeParity = parity(of: edgeValidation.permutation) + let cornerParity = parity(of: cornerValidation.permutation) + guard edgeParity == cornerParity else { + return ValidationError( + type: .impossibleParity, + message: "Piece permutation parity is impossible on a real cube.", + suggestedFix: "At least one piece is incorrect. Re-scan the most uncertain face and validate again." + ) + } + + return nil + } +} + +private extension CubeStateValidator { + struct StickerRef { + let face: Face + let index: Int + } + + struct EdgeDefinition { + let name: String + let stickers: [StickerRef] + } + + struct CornerDefinition { + let name: String + let stickers: [StickerRef] + } + + struct PieceValidation { + let permutation: [Int] + let orientationSum: Int + let error: ValidationError? + } + + static let edgeDefinitions: [EdgeDefinition] = [ + EdgeDefinition(name: "UR", stickers: [StickerRef(face: .up, index: 5), StickerRef(face: .right, index: 1)]), + EdgeDefinition(name: "UF", stickers: [StickerRef(face: .up, index: 7), StickerRef(face: .front, index: 1)]), + EdgeDefinition(name: "UL", stickers: [StickerRef(face: .up, index: 3), StickerRef(face: .left, index: 1)]), + EdgeDefinition(name: "UB", stickers: [StickerRef(face: .up, index: 1), StickerRef(face: .back, index: 1)]), + EdgeDefinition(name: "DR", stickers: [StickerRef(face: .down, index: 5), StickerRef(face: .right, index: 7)]), + EdgeDefinition(name: "DF", stickers: [StickerRef(face: .down, index: 1), StickerRef(face: .front, index: 7)]), + EdgeDefinition(name: "DL", stickers: [StickerRef(face: .down, index: 3), StickerRef(face: .left, index: 7)]), + EdgeDefinition(name: "DB", stickers: [StickerRef(face: .down, index: 7), StickerRef(face: .back, index: 7)]), + EdgeDefinition(name: "FR", stickers: [StickerRef(face: .front, index: 5), StickerRef(face: .right, index: 3)]), + EdgeDefinition(name: "FL", stickers: [StickerRef(face: .front, index: 3), StickerRef(face: .left, index: 5)]), + EdgeDefinition(name: "BL", stickers: [StickerRef(face: .back, index: 5), StickerRef(face: .left, index: 3)]), + EdgeDefinition(name: "BR", stickers: [StickerRef(face: .back, index: 3), StickerRef(face: .right, index: 5)]) + ] + + static let cornerDefinitions: [CornerDefinition] = [ + CornerDefinition(name: "URF", stickers: [StickerRef(face: .up, index: 8), StickerRef(face: .right, index: 0), StickerRef(face: .front, index: 2)]), + CornerDefinition(name: "UFL", stickers: [StickerRef(face: .up, index: 6), StickerRef(face: .front, index: 0), StickerRef(face: .left, index: 2)]), + CornerDefinition(name: "ULB", stickers: [StickerRef(face: .up, index: 0), StickerRef(face: .left, index: 0), StickerRef(face: .back, index: 2)]), + CornerDefinition(name: "UBR", stickers: [StickerRef(face: .up, index: 2), StickerRef(face: .back, index: 0), StickerRef(face: .right, index: 2)]), + CornerDefinition(name: "DFR", stickers: [StickerRef(face: .down, index: 2), StickerRef(face: .front, index: 8), StickerRef(face: .right, index: 6)]), + CornerDefinition(name: "DLF", stickers: [StickerRef(face: .down, index: 0), StickerRef(face: .left, index: 8), StickerRef(face: .front, index: 6)]), + CornerDefinition(name: "DBL", stickers: [StickerRef(face: .down, index: 6), StickerRef(face: .back, index: 8), StickerRef(face: .left, index: 6)]), + CornerDefinition(name: "DRB", stickers: [StickerRef(face: .down, index: 8), StickerRef(face: .right, index: 8), StickerRef(face: .back, index: 6)]) + ] + + func validateEdges(state: CubeState, centers: [Face: CubeColor]) -> PieceValidation { + var expectedPieceIndexByKey: [String: Int] = [:] + + for (index, definition) in Self.edgeDefinitions.enumerated() { + let colors = definition.stickers.compactMap { centers[$0.face] } + expectedPieceIndexByKey[pieceKey(colors)] = index + } + + var seenIndices = Set() + var permutation: [Int] = [] + permutation.reserveCapacity(Self.edgeDefinitions.count) + var orientationSum = 0 + + for definition in Self.edgeDefinitions { + let observed = observedColors(for: definition.stickers, from: state) + let key = pieceKey(observed.colors) + + guard let pieceIndex = expectedPieceIndexByKey[key] else { + return PieceValidation( + permutation: permutation, + orientationSum: orientationSum, + error: ValidationError( + type: .missingPiece, + message: "Edge piece at \(definition.name) has impossible colors \(rawKey(observed.colors)).", + suggestedFix: "Re-scan the faces around \(definition.name) or edit those stickers manually.", + likelyFaces: observed.faces.map(FaceId.init(face:)) + ) + ) + } + + if seenIndices.contains(pieceIndex) { + return PieceValidation( + permutation: permutation, + orientationSum: orientationSum, + error: ValidationError( + type: .duplicatePiece, + message: "Duplicate edge piece detected near \(definition.name).", + suggestedFix: "Open manual edit and correct one of the duplicate edge colors.", + likelyFaces: observed.faces.map(FaceId.init(face:)) + ) + ) + } + + seenIndices.insert(pieceIndex) + permutation.append(pieceIndex) + orientationSum += edgeOrientation(for: observed, centers: centers) + } + + if seenIndices.count != Self.edgeDefinitions.count { + return PieceValidation( + permutation: permutation, + orientationSum: orientationSum, + error: ValidationError( + type: .missingPiece, + message: "At least one edge piece is missing from the scanned pattern.", + suggestedFix: "Re-scan uncertain faces, especially where colors look noisy." + ) + ) + } + + return PieceValidation(permutation: permutation, orientationSum: orientationSum, error: nil) + } + + func validateCorners(state: CubeState, centers: [Face: CubeColor]) -> PieceValidation { + var expectedPieceIndexByKey: [String: Int] = [:] + + for (index, definition) in Self.cornerDefinitions.enumerated() { + let colors = definition.stickers.compactMap { centers[$0.face] } + expectedPieceIndexByKey[pieceKey(colors)] = index + } + + var seenIndices = Set() + var permutation: [Int] = [] + permutation.reserveCapacity(Self.cornerDefinitions.count) + var orientationSum = 0 + + for definition in Self.cornerDefinitions { + let observed = observedColors(for: definition.stickers, from: state) + let key = pieceKey(observed.colors) + + guard let pieceIndex = expectedPieceIndexByKey[key] else { + return PieceValidation( + permutation: permutation, + orientationSum: orientationSum, + error: ValidationError( + type: .missingPiece, + message: "Corner piece at \(definition.name) has impossible colors \(rawKey(observed.colors)).", + suggestedFix: "Re-scan or manually fix the corner touching \(definition.name).", + likelyFaces: observed.faces.map(FaceId.init(face:)) + ) + ) + } + + if seenIndices.contains(pieceIndex) { + return PieceValidation( + permutation: permutation, + orientationSum: orientationSum, + error: ValidationError( + type: .duplicatePiece, + message: "Duplicate corner piece detected near \(definition.name).", + suggestedFix: "Use manual edit on the corner around \(definition.name).", + likelyFaces: observed.faces.map(FaceId.init(face:)) + ) + ) + } + + seenIndices.insert(pieceIndex) + permutation.append(pieceIndex) + orientationSum += cornerOrientation(for: observed, centers: centers) + } + + if seenIndices.count != Self.cornerDefinitions.count { + return PieceValidation( + permutation: permutation, + orientationSum: orientationSum, + error: ValidationError( + type: .missingPiece, + message: "At least one corner piece is missing from the scanned pattern.", + suggestedFix: "Re-scan uncertain corners and validate again." + ) + ) + } + + return PieceValidation(permutation: permutation, orientationSum: orientationSum, error: nil) + } + + func observedColors( + for stickers: [StickerRef], + from state: CubeState + ) -> (colors: [CubeColor], faces: [Face]) { + var colors: [CubeColor] = [] + var faces: [Face] = [] + + for sticker in stickers { + let color = state.getSticker(face: sticker.face, index: sticker.index) ?? .white + colors.append(color) + faces.append(sticker.face) + } + + return (colors, faces) + } + + func edgeOrientation( + for observed: (colors: [CubeColor], faces: [Face]), + centers: [Face: CubeColor] + ) -> Int { + guard let upColor = centers[.up], + let downColor = centers[.down], + let frontColor = centers[.front], + let backColor = centers[.back] else { + return 0 + } + + if let index = observed.colors.firstIndex(where: { $0 == upColor || $0 == downColor }) { + let face = observed.faces[index] + return (face == .up || face == .down) ? 0 : 1 + } + + if let index = observed.colors.firstIndex(where: { $0 == frontColor || $0 == backColor }) { + let face = observed.faces[index] + return (face == .front || face == .back) ? 0 : 1 + } + + return 0 + } + + func cornerOrientation( + for observed: (colors: [CubeColor], faces: [Face]), + centers: [Face: CubeColor] + ) -> Int { + guard let upColor = centers[.up], let downColor = centers[.down] else { + return 0 + } + + guard let index = observed.colors.firstIndex(where: { $0 == upColor || $0 == downColor }) else { + return 0 + } + + switch observed.faces[index] { + case .up, .down: + return 0 + case .left, .right: + return 1 + case .front, .back: + return 2 + } + } + + func parity(of permutation: [Int]) -> Int { + guard !permutation.isEmpty else { return 0 } + + var visited = Array(repeating: false, count: permutation.count) + var swaps = 0 + + for index in permutation.indices where !visited[index] { + var cycleLength = 0 + var cursor = index + + while !visited[cursor] { + visited[cursor] = true + cycleLength += 1 + cursor = permutation[cursor] + } + + if cycleLength > 1 { + swaps += cycleLength - 1 + } + } + + return swaps % 2 + } + + func pieceKey(_ colors: [CubeColor]) -> String { + colors.map(\.rawValue).sorted().joined(separator: "") + } + + func rawKey(_ colors: [CubeColor]) -> String { + colors.map(\.rawValue).joined(separator: "") + } +} diff --git a/Sources/CubeCore/ScanSolveFlow/KociembaCodec.swift b/Sources/CubeCore/ScanSolveFlow/KociembaCodec.swift new file mode 100644 index 0000000..335c258 --- /dev/null +++ b/Sources/CubeCore/ScanSolveFlow/KociembaCodec.swift @@ -0,0 +1,132 @@ +import Foundation + +public enum KociembaCodecError: Error, LocalizedError, Equatable, Sendable { + case invalidLength(expected: Int, actual: Int) + case invalidCharacter(Character) + case invalidFace(Face) + case unknownCenterColor(CubeColor) + + public var errorDescription: String? { + switch self { + case .invalidLength(let expected, let actual): + return "Kociemba string must be \(expected) characters, got \(actual)." + case .invalidCharacter(let character): + return "Unsupported Kociemba character: \(character)." + case .invalidFace(let face): + return "Face \(face.rawValue) does not contain exactly 9 stickers." + case .unknownCenterColor(let color): + return "Center mapping missing for color \(color.rawValue)." + } + } +} + +public struct KociembaCodec: Sendable { + private static let faceOrder: [FaceId] = [.up, .right, .front, .down, .left, .back] + + public init() {} + + /// Encodes a cube state into URFDLB sticker-letter notation expected by Kociemba style solvers. + public func encode(_ state: CubeState) throws -> String { + var colorToFaceLetter: [CubeColor: Character] = [:] + + for id in Self.faceOrder { + guard let center = state.centerColor(of: id.face) else { + throw KociembaCodecError.invalidFace(id.face) + } + colorToFaceLetter[center] = Character(id.rawValue) + } + + var encoded = "" + encoded.reserveCapacity(54) + + for id in Self.faceOrder { + guard let stickers = state.faces[id.face], stickers.count == 9 else { + throw KociembaCodecError.invalidFace(id.face) + } + + for color in stickers { + guard let faceLetter = colorToFaceLetter[color] else { + throw KociembaCodecError.unknownCenterColor(color) + } + encoded.append(faceLetter) + } + } + + return encoded + } + + /// Decodes URFDLB sticker notation to a cube state using standard color mapping. + public func decode(_ kociemba: String) throws -> CubeState { + let symbols = Array(kociemba) + guard symbols.count == 54 else { + throw KociembaCodecError.invalidLength(expected: 54, actual: symbols.count) + } + + let standardColors: [Character: CubeColor] = [ + "U": .white, + "R": .blue, + "F": .red, + "D": .yellow, + "L": .green, + "B": .orange + ] + + var faces: [Face: [CubeColor]] = [:] + var cursor = 0 + + for id in Self.faceOrder { + var stickers: [CubeColor] = [] + stickers.reserveCapacity(9) + + for _ in 0..<9 { + let symbol = symbols[cursor] + cursor += 1 + guard let color = standardColors[symbol] else { + throw KociembaCodecError.invalidCharacter(symbol) + } + stickers.append(color) + } + + faces[id.face] = stickers + } + + return CubeState(faces: faces) + } +} + +public enum MoveNotationCodecError: Error, LocalizedError, Equatable, Sendable { + case invalidMoveToken(String) + + public var errorDescription: String? { + switch self { + case .invalidMoveToken(let token): + return "Invalid move token: \(token)." + } + } +} + +public struct MoveNotationCodec: Sendable { + public init() {} + + public func encode(_ moves: [Move]) -> String { + moves.map(\.notation).joined(separator: " ") + } + + public func decode(_ sequence: String) throws -> [Move] { + let tokens = sequence + .split(whereSeparator: { $0.isWhitespace }) + .map(String.init) + + var moves: [Move] = [] + moves.reserveCapacity(tokens.count) + + for token in tokens { + guard let move = Move(notation: token) else { + throw MoveNotationCodecError.invalidMoveToken(token) + } + moves.append(move) + } + + return moves + } +} diff --git a/Sources/CubeScanner/CubeCamCapturePipeline.swift b/Sources/CubeScanner/CubeCamCapturePipeline.swift index 076696a..72e0eaf 100644 --- a/Sources/CubeScanner/CubeCamCapturePipeline.swift +++ b/Sources/CubeScanner/CubeCamCapturePipeline.swift @@ -63,6 +63,9 @@ public final class CubeCamCapturePipeline: ObservableObject { /// Current face being tracked/captured @Published public var pendingFace: Face? + + /// Face currently estimated as visible in the camera feed. + @Published public private(set) var visibleFaceEstimate: Face? /// Stability indicator (0-1, 1 = fully stable) @Published public var stability: Float = 0 @@ -292,6 +295,7 @@ public final class CubeCamCapturePipeline: ObservableObject { lastEstimatedFace = nil consistentFaceEstimateCount = 0 smoothedFaceEstimateConfidence = 0 + visibleFaceEstimate = nil pendingFace = getNextFaceToCapture() // Reset to idle or detecting @@ -328,6 +332,7 @@ public final class CubeCamCapturePipeline: ObservableObject { capturedFaces = [:] capturedPatterns = [:] pendingFace = nil + visibleFaceEstimate = nil stability = 0 lastDetection = nil currentError = nil @@ -539,6 +544,7 @@ public final class CubeCamCapturePipeline: ObservableObject { // Map center color to expected face (Rubik's cube centers are fixed) let expectedFace = faceFromCenterColor(centerColor) + visibleFaceEstimate = expectedFace updateSmoothedFaceConfidence(face: expectedFace, detectionConfidence: detection.confidence) let targetFace = getNextFaceToCapture() @@ -767,6 +773,7 @@ public final class CubeCamCapturePipeline: ObservableObject { private func resetForNextFace() { logDebug("[CubeCam] 🔄 Resetting for next face") currentFaceEstimate = nil + visibleFaceEstimate = nil pendingFace = getNextFaceToCapture() detectionHistory = [] consecutiveStableFrames = 0 diff --git a/Sources/CubeScanner/CubeCamViewModel.swift b/Sources/CubeScanner/CubeCamViewModel.swift index 2e685a7..43e0dea 100644 --- a/Sources/CubeScanner/CubeCamViewModel.swift +++ b/Sources/CubeScanner/CubeCamViewModel.swift @@ -116,6 +116,7 @@ public class CubeCamViewModel: ObservableObject { public init() { setupBindings() + currentFace = capturePipeline.getNextFaceToCapture() } // MARK: - Public Methods @@ -124,6 +125,7 @@ public class CubeCamViewModel: ObservableObject { public func start() async { detectionStatus = .preparing captureProgressText = "Requesting camera permission..." + currentFace = capturePipeline.getNextFaceToCapture() // Request camera permission let authorized = await cameraSession.requestPermission() @@ -165,6 +167,7 @@ public class CubeCamViewModel: ObservableObject { capturedFaceCount = 0 detectionStatus = .detecting captureProgressText = "Position your cube in the frame" + currentFace = capturePipeline.getNextFaceToCapture() lastErrorMessage = nil duplicateFaceWarning = nil wrongFaceWarning = nil @@ -219,8 +222,20 @@ public class CubeCamViewModel: ObservableObject { } .store(in: &cancellables) + capturePipeline.$visibleFaceEstimate + .sink { [weak self] visibleFace in + guard let self else { return } + self.currentFace = visibleFace ?? self.capturePipeline.pendingFace ?? self.capturePipeline.getNextFaceToCapture() + } + .store(in: &cancellables) + capturePipeline.$pendingFace - .assign(to: &$currentFace) + .sink { [weak self] pendingFace in + guard let self else { return } + guard self.capturePipeline.visibleFaceEstimate == nil else { return } + self.currentFace = pendingFace ?? self.capturePipeline.getNextFaceToCapture() + } + .store(in: &cancellables) capturePipeline.$stability .assign(to: &$stability) diff --git a/Sources/CubeScanner/ScanSolveFlow/CameraSessionFrameSource.swift b/Sources/CubeScanner/ScanSolveFlow/CameraSessionFrameSource.swift new file mode 100644 index 0000000..fa5f202 --- /dev/null +++ b/Sources/CubeScanner/ScanSolveFlow/CameraSessionFrameSource.swift @@ -0,0 +1,29 @@ +#if canImport(AVFoundation) && canImport(CoreVideo) + +import Foundation +import AVFoundation +import CubeCore + +public actor CameraSessionFrameSource: CameraFrameSource { + private let cameraSession: CameraSession + public var maxPollingAttempts = 60 + public var pollingIntervalNanos: UInt64 = 33_000_000 + + public init(cameraSession: CameraSession) { + self.cameraSession = cameraSession + } + + public func nextFrame() async throws -> RGBFrame { + for _ in 0.. FaceQuadrilateral? { + _ = frame + return .centered + } +} + +public actor DefaultFaceScanner: FaceScanner { + public var maxScanAttempts: Int + public var minimumMeanConfidence: Float + + private let frameSource: CameraFrameSource + private let quadDetector: FaceQuadDetecting + private let warpSampler: FaceWarpSampler + private let classifier: StickerColorClassifying + + public init( + frameSource: CameraFrameSource, + quadDetector: FaceQuadDetecting = CenteredFaceQuadDetector(), + warpSampler: FaceWarpSampler = FaceWarpSampler(), + classifier: StickerColorClassifying = HSVStickerClassifier(), + maxScanAttempts: Int = 30, + minimumMeanConfidence: Float = 0.45 + ) { + self.frameSource = frameSource + self.quadDetector = quadDetector + self.warpSampler = warpSampler + self.classifier = classifier + self.maxScanAttempts = max(1, maxScanAttempts) + self.minimumMeanConfidence = max(0, min(1, minimumMeanConfidence)) + } + + public func scanFace(for face: FaceId) async throws -> ScannedFaceData { + var bestResult: FaceSamplingResult? + + for _ in 0..= minimumMeanConfidence { + return ScannedFaceData( + id: face, + grid: sampled.face, + confidence: sampled.meanConfidence + ) + } + + if let currentBest = bestResult { + if sampled.meanConfidence > currentBest.meanConfidence { + bestResult = sampled + } + } else { + bestResult = sampled + } + } + + if let fallback = bestResult { + return ScannedFaceData( + id: face, + grid: fallback.face, + confidence: fallback.meanConfidence + ) + } + + throw FaceScannerError.noStableFaceDetected(face: face) + } +} + +public actor StaticFrameSource: CameraFrameSource { + private let frame: RGBFrame + + public init(frame: RGBFrame) { + self.frame = frame + } + + public func nextFrame() async throws -> RGBFrame { + frame + } +} diff --git a/Sources/CubeScanner/ScanSolveFlow/FaceWarpSampler.swift b/Sources/CubeScanner/ScanSolveFlow/FaceWarpSampler.swift new file mode 100644 index 0000000..b7dc407 --- /dev/null +++ b/Sources/CubeScanner/ScanSolveFlow/FaceWarpSampler.swift @@ -0,0 +1,90 @@ +import Foundation +import CubeCore + +public struct FaceWarpSampler: Sendable { + /// Samples each sticker using this inset factor to avoid edge glare. + public var cellInset: Double + + public init(cellInset: Double = 0.18) { + self.cellInset = max(0, min(0.4, cellInset)) + } + + public func sample( + frame: RGBFrame, + quad: FaceQuadrilateral, + classifier: StickerColorClassifying + ) throws -> FaceSamplingResult { + var colors: [CubeColor] = [] + var confidences: [Float] = [] + + colors.reserveCapacity(9) + confidences.reserveCapacity(9) + + for row in 0..<3 { + for column in 0..<3 { + let representative = sampleCell( + frame: frame, + quad: quad, + row: row, + column: column + ) + + let classified = classifier.classify(pixel: representative) + colors.append(classified.color) + confidences.append(classified.confidence) + } + } + + let face = try CubeFaceGrid(stickers: colors) + let averageConfidence = confidences.reduce(0, +) / Float(max(1, confidences.count)) + + return FaceSamplingResult( + face: face, + stickerConfidences: confidences, + meanConfidence: averageConfidence, + quadrilateral: quad + ) + } + + public func sampleCell( + frame: RGBFrame, + quad: FaceQuadrilateral, + row: Int, + column: Int + ) -> RGBPixel { + let gridOriginU = Double(column) / 3 + let gridOriginV = Double(row) / 3 + let sampleRange = [cellInset, 0.5, 1 - cellInset] + + var samples: [RGBPixel] = [] + samples.reserveCapacity(sampleRange.count * sampleRange.count) + + for sampleV in sampleRange { + for sampleU in sampleRange { + let u = gridOriginU + sampleU / 3 + let v = gridOriginV + sampleV / 3 + let point = quad.point(u: u, v: v) + let x = Int((point.x * Double(frame.width - 1)).rounded()) + let y = Int((point.y * Double(frame.height - 1)).rounded()) + samples.append(frame.pixel(x: x, y: y)) + } + } + + return average(samples) + } + + public func average(_ pixels: [RGBPixel]) -> RGBPixel { + guard !pixels.isEmpty else { return .black } + + let sum = pixels.reduce((red: Float(0), green: Float(0), blue: Float(0))) { partial, pixel in + ( + red: partial.red + pixel.red, + green: partial.green + pixel.green, + blue: partial.blue + pixel.blue + ) + } + + let count = Float(pixels.count) + return RGBPixel(red: sum.red / count, green: sum.green / count, blue: sum.blue / count) + } +} diff --git a/Sources/CubeScanner/ScanSolveFlow/HSVStickerClassifier.swift b/Sources/CubeScanner/ScanSolveFlow/HSVStickerClassifier.swift new file mode 100644 index 0000000..c873d04 --- /dev/null +++ b/Sources/CubeScanner/ScanSolveFlow/HSVStickerClassifier.swift @@ -0,0 +1,127 @@ +import Foundation +import CubeCore + +public struct StickerClassifierCalibration: Sendable { + public var hueOffsets: [CubeColor: Double] + public var saturationScale: Double + public var valueScale: Double + public var whiteSaturationThreshold: Double + public var whiteMinimumValue: Double + + public init( + hueOffsets: [CubeColor: Double] = [:], + saturationScale: Double = 1, + valueScale: Double = 1, + whiteSaturationThreshold: Double = 0.20, + whiteMinimumValue: Double = 0.45 + ) { + self.hueOffsets = hueOffsets + self.saturationScale = saturationScale + self.valueScale = valueScale + self.whiteSaturationThreshold = whiteSaturationThreshold + self.whiteMinimumValue = whiteMinimumValue + } +} + +public struct HSVStickerClassifier: StickerColorClassifying, Sendable { + public struct Profile: Sendable { + public let hueCenter: Double + public let saturation: Double + public let value: Double + + public init(hueCenter: Double, saturation: Double, value: Double) { + self.hueCenter = hueCenter + self.saturation = saturation + self.value = value + } + } + + public var calibration: StickerClassifierCalibration + + private let profiles: [CubeColor: Profile] = [ + .red: Profile(hueCenter: 0, saturation: 0.85, value: 0.78), + .orange: Profile(hueCenter: 28, saturation: 0.82, value: 0.85), + .yellow: Profile(hueCenter: 58, saturation: 0.75, value: 0.92), + .green: Profile(hueCenter: 120, saturation: 0.75, value: 0.62), + .blue: Profile(hueCenter: 220, saturation: 0.76, value: 0.70) + ] + + public init(calibration: StickerClassifierCalibration = .init()) { + self.calibration = calibration + } + + public func classify(pixel: RGBPixel) -> StickerClassification { + let hsvRaw = rgbToHSV(pixel) + let hsv = ( + hue: hsvRaw.hue, + saturation: max(0, min(1, hsvRaw.saturation * calibration.saturationScale)), + value: max(0, min(1, hsvRaw.value * calibration.valueScale)) + ) + + if hsv.saturation <= calibration.whiteSaturationThreshold, + hsv.value >= calibration.whiteMinimumValue { + let saturationScore = 1 - (hsv.saturation / max(calibration.whiteSaturationThreshold, 0.0001)) + let valueScore = min(1, hsv.value) + let confidence = Float(max(0.3, (saturationScore * 0.65) + (valueScore * 0.35))) + return StickerClassification(color: .white, confidence: confidence) + } + + var bestColor: CubeColor = .white + var bestScore = -Double.infinity + + for (color, profile) in profiles { + let shiftedCenter = wrappedHue(profile.hueCenter + calibration.hueOffsets[color, default: 0]) + let hueDistance = circularHueDistance(hsv.hue, shiftedCenter) / 180 + let satDistance = abs(hsv.saturation - profile.saturation) + let valueDistance = abs(hsv.value - profile.value) + + let score = 1.0 - ((hueDistance * 0.65) + (satDistance * 0.2) + (valueDistance * 0.15)) + if score > bestScore { + bestScore = score + bestColor = color + } + } + + let confidence = Float(max(0.1, min(1, bestScore))) + return StickerClassification(color: bestColor, confidence: confidence) + } + + private func rgbToHSV(_ pixel: RGBPixel) -> (hue: Double, saturation: Double, value: Double) { + let red = Double(pixel.red) + let green = Double(pixel.green) + let blue = Double(pixel.blue) + + let maxComponent = max(red, max(green, blue)) + let minComponent = min(red, min(green, blue)) + let delta = maxComponent - minComponent + + var hue = 0.0 + if delta != 0 { + if maxComponent == red { + hue = 60 * (((green - blue) / delta).truncatingRemainder(dividingBy: 6)) + } else if maxComponent == green { + hue = 60 * (((blue - red) / delta) + 2) + } else { + hue = 60 * (((red - green) / delta) + 4) + } + } + + if hue < 0 { + hue += 360 + } + + let saturation = maxComponent == 0 ? 0 : delta / maxComponent + return (hue: hue, saturation: saturation, value: maxComponent) + } + + private func circularHueDistance(_ a: Double, _ b: Double) -> Double { + let delta = abs(a - b) + return min(delta, 360 - delta) + } + + private func wrappedHue(_ hue: Double) -> Double { + var value = hue.truncatingRemainder(dividingBy: 360) + if value < 0 { value += 360 } + return value + } +} diff --git a/Sources/CubeScanner/ScanSolveFlow/ScanningPipelineModels.swift b/Sources/CubeScanner/ScanSolveFlow/ScanningPipelineModels.swift new file mode 100644 index 0000000..e1ffed4 --- /dev/null +++ b/Sources/CubeScanner/ScanSolveFlow/ScanningPipelineModels.swift @@ -0,0 +1,225 @@ +import Foundation +import CubeCore + +#if canImport(CoreVideo) +import CoreVideo +#endif + +public struct RGBPixel: Equatable, Sendable { + public let red: Float + public let green: Float + public let blue: Float + + public init(red: Float, green: Float, blue: Float) { + self.red = max(0, min(1, red)) + self.green = max(0, min(1, green)) + self.blue = max(0, min(1, blue)) + } + + public static let black = RGBPixel(red: 0, green: 0, blue: 0) +} + +public enum RGBFrameError: Error, LocalizedError, Equatable, Sendable { + case invalidDimensions(width: Int, height: Int) + case invalidPixelCount(expected: Int, actual: Int) + + public var errorDescription: String? { + switch self { + case .invalidDimensions(let width, let height): + return "Invalid frame dimensions \(width)x\(height)." + case .invalidPixelCount(let expected, let actual): + return "Frame expected \(expected) pixels but received \(actual)." + } + } +} + +public struct RGBFrame: Sendable { + public let width: Int + public let height: Int + public let pixels: [RGBPixel] +#if canImport(CoreVideo) + public let pixelBuffer: CVPixelBuffer? +#endif + + public init(width: Int, height: Int, pixels: [RGBPixel]) throws { + try Self.validate(width: width, height: height, pixels: pixels) + self.width = width + self.height = height + self.pixels = pixels +#if canImport(CoreVideo) + self.pixelBuffer = nil +#endif + } + +#if canImport(CoreVideo) + public init(width: Int, height: Int, pixels: [RGBPixel], pixelBuffer: CVPixelBuffer?) throws { + try Self.validate(width: width, height: height, pixels: pixels) + self.width = width + self.height = height + self.pixels = pixels + self.pixelBuffer = pixelBuffer + } +#endif + + private static func validate(width: Int, height: Int, pixels: [RGBPixel]) throws { + guard width > 0 && height > 0 else { + throw RGBFrameError.invalidDimensions(width: width, height: height) + } + + let expectedCount = width * height + guard pixels.count == expectedCount else { + throw RGBFrameError.invalidPixelCount(expected: expectedCount, actual: pixels.count) + } + } + + public func pixel(x: Int, y: Int) -> RGBPixel { + guard x >= 0, x < width, y >= 0, y < height else { + return .black + } + return pixels[y * width + x] + } +} + +extension RGBFrame: Equatable { + public static func == (lhs: RGBFrame, rhs: RGBFrame) -> Bool { + lhs.width == rhs.width && lhs.height == rhs.height && lhs.pixels == rhs.pixels + } +} + +#if canImport(CoreVideo) +public extension RGBFrame { + static func make(from pixelBuffer: CVPixelBuffer) -> RGBFrame? { + CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) } + + let width = CVPixelBufferGetWidth(pixelBuffer) + let height = CVPixelBufferGetHeight(pixelBuffer) + let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) + + guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { + return nil + } + + var pixels: [RGBPixel] = [] + pixels.reserveCapacity(width * height) + + for y in 0.. NormalizedPoint { + let top = interpolate(from: topLeft, to: topRight, t: u) + let bottom = interpolate(from: bottomLeft, to: bottomRight, t: u) + return interpolate(from: top, to: bottom, t: v) + } + + public static let centered = FaceQuadrilateral( + topLeft: NormalizedPoint(x: 0.2, y: 0.2), + topRight: NormalizedPoint(x: 0.8, y: 0.2), + bottomRight: NormalizedPoint(x: 0.8, y: 0.8), + bottomLeft: NormalizedPoint(x: 0.2, y: 0.8) + ) + + private func interpolate(from start: NormalizedPoint, to end: NormalizedPoint, t: Double) -> NormalizedPoint { + let clamped = max(0, min(1, t)) + return NormalizedPoint( + x: start.x + (end.x - start.x) * clamped, + y: start.y + (end.y - start.y) * clamped + ) + } +} + +public struct StickerClassification: Equatable, Sendable { + public let color: CubeColor + public let confidence: Float + + public init(color: CubeColor, confidence: Float) { + self.color = color + self.confidence = max(0, min(1, confidence)) + } +} + +public struct FaceSamplingResult: Equatable, Sendable { + public let face: CubeFaceGrid + public let stickerConfidences: [Float] + public let meanConfidence: Float + public let quadrilateral: FaceQuadrilateral + + public init(face: CubeFaceGrid, stickerConfidences: [Float], meanConfidence: Float, quadrilateral: FaceQuadrilateral) { + self.face = face + self.stickerConfidences = stickerConfidences + self.meanConfidence = meanConfidence + self.quadrilateral = quadrilateral + } +} + +public enum FaceScannerError: Error, LocalizedError, Equatable, Sendable { + case noStableFaceDetected(face: FaceId) + case scannerUnavailable + + public var errorDescription: String? { + switch self { + case .noStableFaceDetected(let face): + return "Could not detect a stable \(face.displayName) face." + case .scannerUnavailable: + return "Scanner input is unavailable." + } + } +} + +public protocol CameraFrameSource: Sendable { + func nextFrame() async throws -> RGBFrame +} + +public protocol FaceQuadDetecting: Sendable { + func detectQuadrilateral(in frame: RGBFrame) async throws -> FaceQuadrilateral? +} + +public protocol StickerColorClassifying: Sendable { + func classify(pixel: RGBPixel) -> StickerClassification +} + +public protocol FaceScanner: Sendable { + func scanFace(for face: FaceId) async throws -> ScannedFaceData +} diff --git a/Sources/CubeScanner/ScanSolveFlow/SimulatedFaceScanner.swift b/Sources/CubeScanner/ScanSolveFlow/SimulatedFaceScanner.swift new file mode 100644 index 0000000..27a545a --- /dev/null +++ b/Sources/CubeScanner/ScanSolveFlow/SimulatedFaceScanner.swift @@ -0,0 +1,32 @@ +import Foundation +import CubeCore + +public actor SimulatedFaceScanner: FaceScanner { + private var scriptedFaces: [FaceId: ScannedFaceData] + private var delayNanos: UInt64 + + public init(scriptedFaces: [FaceId: ScannedFaceData], delayNanos: UInt64 = 0) { + self.scriptedFaces = scriptedFaces + self.delayNanos = delayNanos + } + + public func setDelayNanos(_ nanos: UInt64) { + delayNanos = nanos + } + + public func setFace(_ face: ScannedFaceData) { + scriptedFaces[face.id] = face + } + + public func scanFace(for face: FaceId) async throws -> ScannedFaceData { + if delayNanos > 0 { + try await Task.sleep(nanoseconds: delayNanos) + } + + guard let scripted = scriptedFaces[face] else { + throw FaceScannerError.noStableFaceDetected(face: face) + } + + return scripted + } +} diff --git a/Sources/CubeScanner/ScanSolveFlow/VisionFaceQuadDetector.swift b/Sources/CubeScanner/ScanSolveFlow/VisionFaceQuadDetector.swift new file mode 100644 index 0000000..e702965 --- /dev/null +++ b/Sources/CubeScanner/ScanSolveFlow/VisionFaceQuadDetector.swift @@ -0,0 +1,45 @@ +#if canImport(Vision) && canImport(CoreVideo) + +import Foundation +import Vision +import CoreVideo +import CubeCore + +public actor VisionFaceQuadDetector: FaceQuadDetecting { + private let detectionService = CubeFaceDetectionService() + + public init() {} + + public func detectQuadrilateral(in frame: RGBFrame) async throws -> FaceQuadrilateral? { + guard let pixelBuffer = frame.pixelBuffer else { + return nil + } + + guard let detection = await detectionService.detectCubeFace(in: pixelBuffer), + detection.corners.count == 4 else { + return nil + } + + let topLeft = normalizedPoint(fromVisionPoint: detection.corners[0]) + let topRight = normalizedPoint(fromVisionPoint: detection.corners[1]) + let bottomRight = normalizedPoint(fromVisionPoint: detection.corners[2]) + let bottomLeft = normalizedPoint(fromVisionPoint: detection.corners[3]) + + return FaceQuadrilateral( + topLeft: topLeft, + topRight: topRight, + bottomRight: bottomRight, + bottomLeft: bottomLeft + ) + } + + /// Vision coordinates are bottom-left origin; scanner sampling is top-left. + private func normalizedPoint(fromVisionPoint point: CGPoint) -> NormalizedPoint { + NormalizedPoint( + x: Double(max(0, min(1, point.x))), + y: Double(max(0, min(1, 1 - point.y))) + ) + } +} + +#endif diff --git a/Sources/CubeUI/HomeView.swift b/Sources/CubeUI/HomeView.swift index ab7c9dd..338d1be 100644 --- a/Sources/CubeUI/HomeView.swift +++ b/Sources/CubeUI/HomeView.swift @@ -116,6 +116,17 @@ public struct HomeView: View { ) } #endif + + #if canImport(AVFoundation) && os(iOS) + NavigationLink(destination: LiveScanWizardContainerView()) { + ActionCard( + icon: "square.grid.3x3.fill", + title: "Scan Wizard", + subtitle: "Scan -> validate -> edit -> solve", + color: .indigo + ) + } + #endif // Photo Capture - Manual single-shot mode with debug #if os(iOS) diff --git a/Sources/CubeUI/ScanSolveFlow/CubeManualEditView.swift b/Sources/CubeUI/ScanSolveFlow/CubeManualEditView.swift new file mode 100644 index 0000000..d667418 --- /dev/null +++ b/Sources/CubeUI/ScanSolveFlow/CubeManualEditView.swift @@ -0,0 +1,136 @@ +#if canImport(SwiftUI) + +import SwiftUI +import CubeCore + +public struct CubeManualEditView: View { + @ObservedObject private var viewModel: CubeScanSolveFlowViewModel + @Environment(\.dismiss) private var dismiss + + @State private var selectedFace: FaceId = .up + @State private var selectedColor: CubeColor = .white + + public init(viewModel: CubeScanSolveFlowViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Picker("Face", selection: $selectedFace) { + ForEach(viewModel.scanOrder, id: \.self) { face in + Text(face.displayName).tag(face) + } + } + .pickerStyle(.segmented) + + if let scanned = viewModel.scannedFaces[selectedFace] { + Text("Tap a sticker to set \(selectedColor.rawValue)") + .font(.caption) + .foregroundStyle(.secondary) + + FaceGridView( + grid: scanned.grid, + highlightedIndices: conflictIndices(for: selectedFace) + ) { index in + viewModel.updateSticker(face: selectedFace, index: index, color: selectedColor) + } + + if let error = viewModel.validationError { + VStack(alignment: .leading, spacing: 6) { + Text(error.message) + .font(.subheadline.weight(.semibold)) + Text(error.suggestedFix) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(10) + .background(Color.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + } + } else { + Text("Face not scanned yet.") + .foregroundStyle(.secondary) + } + + colorPalette + + HStack(spacing: 12) { + Button("Reset Face", systemImage: "arrow.counterclockwise") { + viewModel.resetFace(selectedFace) + } + .buttonStyle(.bordered) + + Button("Re-scan Face", systemImage: "camera.rotate") { + viewModel.markFaceForRescan(selectedFace) + dismiss() + } + .buttonStyle(.bordered) + } + } + .padding() + } + .navigationTitle("Manual Edit") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + viewModel.resumeWizard() + dismiss() + } + } + } + } + } + + private var colorPalette: some View { + HStack(spacing: 10) { + ForEach(CubeColor.allCases, id: \.self) { color in + Circle() + .fill(swiftUIColor(for: color)) + .frame(width: 34, height: 34) + .overlay( + Circle() + .stroke(selectedColor == color ? Color.primary : Color.clear, lineWidth: 3) + ) + .onTapGesture { + selectedColor = color + } + .accessibilityLabel("Set color \(color.rawValue)") + } + } + } + + private func conflictIndices(for face: FaceId) -> Set { + guard let scanned = viewModel.scannedFaces[face] else { return [] } + + let counts = totalCounts() + let overflowColors = Set( + counts + .filter { $0.value > 9 } + .map(\.key) + ) + + var indices = Set() + for index in 0.. [CubeColor: Int] { + var counts: [CubeColor: Int] = [:] + for color in CubeColor.allCases { + counts[color] = 0 + } + + for scannedFace in viewModel.scannedFaces.values { + for color in scannedFace.grid.stickers { + counts[color, default: 0] += 1 + } + } + + return counts + } +} + +#endif diff --git a/Sources/CubeUI/ScanSolveFlow/CubeScanSolveComponents.swift b/Sources/CubeUI/ScanSolveFlow/CubeScanSolveComponents.swift new file mode 100644 index 0000000..23befaa --- /dev/null +++ b/Sources/CubeUI/ScanSolveFlow/CubeScanSolveComponents.swift @@ -0,0 +1,74 @@ +#if canImport(SwiftUI) + +import SwiftUI +import CubeCore + +struct FaceGridView: View { + let grid: CubeFaceGrid + let highlightedIndices: Set + let onTap: ((Int) -> Void)? + + init(grid: CubeFaceGrid, highlightedIndices: Set = [], onTap: ((Int) -> Void)? = nil) { + self.grid = grid + self.highlightedIndices = highlightedIndices + self.onTap = onTap + } + + var body: some View { + VStack(spacing: 4) { + ForEach(0..<3, id: \.self) { row in + HStack(spacing: 4) { + ForEach(0..<3, id: \.self) { column in + let index = row * 3 + column + Rectangle() + .fill(swiftUIColor(for: grid[index])) + .overlay( + Rectangle() + .stroke(highlightedIndices.contains(index) ? Color.red : Color.black.opacity(0.35), lineWidth: highlightedIndices.contains(index) ? 3 : 1) + ) + .frame(width: 42, height: 42) + .onTapGesture { + onTap?(index) + } + } + } + } + } + } +} + +struct FaceBadgeView: View { + let face: FaceId + let isComplete: Bool + + var body: some View { + HStack(spacing: 6) { + Text(face.rawValue) + .font(.caption.monospaced().bold()) + Image(systemName: isComplete ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isComplete ? Color.green : Color.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(.thinMaterial, in: Capsule()) + } +} + +func swiftUIColor(for color: CubeColor) -> Color { + switch color { + case .white: + return Color.white + case .yellow: + return Color.yellow + case .red: + return Color.red + case .orange: + return Color.orange + case .blue: + return Color.blue + case .green: + return Color.green + } +} + +#endif diff --git a/Sources/CubeUI/ScanSolveFlow/CubeScanSolveFlowViewModel.swift b/Sources/CubeUI/ScanSolveFlow/CubeScanSolveFlowViewModel.swift new file mode 100644 index 0000000..96ccae5 --- /dev/null +++ b/Sources/CubeUI/ScanSolveFlow/CubeScanSolveFlowViewModel.swift @@ -0,0 +1,265 @@ +#if canImport(SwiftUI) + +import Foundation +import SwiftUI +import CubeCore +import CubeScanner + +@MainActor +public final class CubeScanSolveFlowViewModel: ObservableObject { + public enum FlowState: Equatable { + case scanning + case awaitingConfirmation(FaceId) + case editing + case readyToSolve + case solving + case solved + case failed(String) + } + + @Published public private(set) var state: FlowState = .scanning + @Published public private(set) var scannedFaces: [FaceId: ScannedFaceData] = [:] + @Published public private(set) var pendingFace: ScannedFaceData? + @Published public private(set) var validationError: ValidationError? + @Published public private(set) var solvedMoves: [Move] = [] + @Published public private(set) var currentMoveIndex: Int = 0 + @Published public private(set) var isBusy = false + + public let scanOrder: [FaceId] + + public var currentFaceId: FaceId { + if let pending = pendingFace { + return pending.id + } + + if let missing = scanOrder.first(where: { scannedFaces[$0] == nil }) { + return missing + } + + return scanOrder.last ?? .back + } + + public var progressText: String { + "\(scannedFaces.count)/\(scanOrder.count) faces" + } + + public var canStartSolve: Bool { + scannedFaces.count == scanOrder.count && validationError == nil + } + + public var currentInstruction: SolutionInstruction? { + guard !solvedMoves.isEmpty, + currentMoveIndex > 0, + currentMoveIndex <= solvedMoves.count else { + return nil + } + + return SolutionInstruction( + index: currentMoveIndex, + total: solvedMoves.count, + move: solvedMoves[currentMoveIndex - 1] + ) + } + + public var solutionText: String { + MoveNotationCodec().encode(solvedMoves) + } + + private let scanner: FaceScanner + private let validator: CubeStateValidating + private let solver: CubeSolving + private let assembler = CubeStateAssembler() + + public init( + scanner: FaceScanner, + validator: CubeStateValidating = CubeStateValidator(), + solver: CubeSolving = KociembaCompatibleCubeSolver(), + scanOrder: [FaceId] = FaceId.guidedScanOrder + ) { + self.scanner = scanner + self.validator = validator + self.solver = solver + self.scanOrder = scanOrder + } + + public func scanCurrentFace() async { + guard !isBusy else { return } + + isBusy = true + defer { isBusy = false } + + do { + let scanned = try await scanner.scanFace(for: currentFaceId) + pendingFace = scanned + validationError = nil + state = .awaitingConfirmation(scanned.id) + } catch { + state = .failed(error.localizedDescription) + } + } + + public func confirmPendingFace() { + guard let pendingFace else { return } + + scannedFaces[pendingFace.id] = pendingFace + self.pendingFace = nil + + _ = revalidateIfComplete() + + if scannedFaces.count < scanOrder.count { + state = .scanning + } + } + + public func rejectPendingFaceAndRescan() { + pendingFace = nil + state = .scanning + } + + public func openManualEdit() { + state = .editing + } + + public func resumeWizard() { + if canStartSolve { + state = .readyToSolve + } else { + state = .scanning + } + } + + public func updateSticker(face: FaceId, index: Int, color: CubeColor) { + guard var scanned = scannedFaces[face] else { return } + guard index >= 0, index < CubeFaceGrid.stickerCount else { return } + + scanned.grid[index] = color + scannedFaces[face] = scanned + _ = revalidateIfComplete() + } + + public func resetFace(_ face: FaceId) { + guard let center = defaultCenterColor(for: face) else { return } + let replacement = CubeFaceGrid(repeating: center) + scannedFaces[face] = ScannedFaceData(id: face, grid: replacement, confidence: 1) + _ = revalidateIfComplete() + } + + public func markFaceForRescan(_ face: FaceId) { + scannedFaces.removeValue(forKey: face) + pendingFace = nil + validationError = nil + solvedMoves = [] + currentMoveIndex = 0 + state = .scanning + } + + public func solve() async { + guard canStartSolve, !isBusy else { return } + + isBusy = true + state = .solving + defer { isBusy = false } + + do { + let cubeState = try assembler.assemble(from: faceGrids()) + + switch validator.validate(state: cubeState) { + case .failure(let validationError): + self.validationError = validationError + state = .editing + return + case .success: + break + } + + solvedMoves = try await solver.solve(state: cubeState) + currentMoveIndex = solvedMoves.isEmpty ? 0 : 1 + state = .solved + } catch { + state = .failed(error.localizedDescription) + } + } + + public func restart() { + state = .scanning + scannedFaces = [:] + pendingFace = nil + validationError = nil + solvedMoves = [] + currentMoveIndex = 0 + } + + public func nextMove() { + guard currentMoveIndex < solvedMoves.count else { return } + currentMoveIndex += 1 + } + + public func previousMove() { + guard currentMoveIndex > 1 else { + currentMoveIndex = min(currentMoveIndex, 1) + return + } + currentMoveIndex -= 1 + } + + public func jumpToMove(_ index: Int) { + guard !solvedMoves.isEmpty else { return } + currentMoveIndex = max(1, min(index, solvedMoves.count)) + } + + private func faceGrids() -> [FaceId: CubeFaceGrid] { + scannedFaces.reduce(into: [FaceId: CubeFaceGrid]()) { result, item in + result[item.key] = item.value.grid + } + } + + @discardableResult + private func revalidateIfComplete() -> Bool { + guard scannedFaces.count == scanOrder.count else { + validationError = nil + state = .scanning + return false + } + + do { + let cubeState = try assembler.assemble(from: faceGrids()) + switch validator.validate(state: cubeState) { + case .success: + validationError = nil + state = .readyToSolve + return true + case .failure(let error): + validationError = error + state = .editing + return false + } + } catch { + validationError = ValidationError( + type: .invalidFace, + message: error.localizedDescription, + suggestedFix: "Finish scanning all six faces, then validate again." + ) + state = .editing + return false + } + } + + private func defaultCenterColor(for face: FaceId) -> CubeColor? { + switch face { + case .up: + return .white + case .right: + return .blue + case .front: + return .red + case .down: + return .yellow + case .left: + return .green + case .back: + return .orange + } + } +} + +#endif diff --git a/Sources/CubeUI/ScanSolveFlow/FaceConfirmView.swift b/Sources/CubeUI/ScanSolveFlow/FaceConfirmView.swift new file mode 100644 index 0000000..a17a37f --- /dev/null +++ b/Sources/CubeUI/ScanSolveFlow/FaceConfirmView.swift @@ -0,0 +1,46 @@ +#if canImport(SwiftUI) + +import SwiftUI +import CubeCore + +public struct FaceConfirmView: View { + let face: ScannedFaceData + let onConfirm: () -> Void + let onRescan: () -> Void + + public init(face: ScannedFaceData, onConfirm: @escaping () -> Void, onRescan: @escaping () -> Void) { + self.face = face + self.onConfirm = onConfirm + self.onRescan = onRescan + } + + public var body: some View { + VStack(spacing: 20) { + Text("Confirm \(face.id.displayName) Face") + .font(.title3.bold()) + + FaceGridView(grid: face.grid) + + Text("Confidence: \(Int(face.confidence * 100))%") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + Button("Re-scan", systemImage: "arrow.counterclockwise") { + onRescan() + } + .buttonStyle(.bordered) + + Button("Looks Good", systemImage: "checkmark") { + onConfirm() + } + .buttonStyle(.borderedProminent) + } + } + .frame(maxWidth: .infinity) + .padding() + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16)) + } +} + +#endif diff --git a/Sources/CubeUI/ScanSolveFlow/LiveScanWizardContainerView.swift b/Sources/CubeUI/ScanSolveFlow/LiveScanWizardContainerView.swift new file mode 100644 index 0000000..4df79f0 --- /dev/null +++ b/Sources/CubeUI/ScanSolveFlow/LiveScanWizardContainerView.swift @@ -0,0 +1,224 @@ +#if os(iOS) && canImport(SwiftUI) && canImport(AVFoundation) + +import SwiftUI +import AVFoundation +import CubeCore +import CubeScanner + +/// Composition root for the live scan wizard using CameraSession + Vision detector. +public struct LiveScanWizardContainerView: View { + @StateObject private var cameraSession: CameraSession + @StateObject private var flowViewModel: CubeScanSolveFlowViewModel + @State private var cameraError: String? + @State private var isStatusPulseOn = false + + public init() { + let session = CameraSession() + let frameSource = CameraSessionFrameSource(cameraSession: session) + let scanner = DefaultFaceScanner( + frameSource: frameSource, + quadDetector: VisionFaceQuadDetector(), + warpSampler: FaceWarpSampler(cellInset: 0.18), + classifier: HSVStickerClassifier(), + maxScanAttempts: 60, + minimumMeanConfidence: 0.45 + ) + + _cameraSession = StateObject(wrappedValue: session) + _flowViewModel = StateObject( + wrappedValue: CubeScanSolveFlowViewModel( + scanner: scanner, + validator: CubeStateValidator(), + solver: KociembaCompatibleCubeSolver() + ) + ) + } + + public var body: some View { + ScanWizardView( + viewModel: flowViewModel, + cameraPreview: AnyView( + LiveCameraPreviewCard( + cameraSession: cameraSession, + isRunning: cameraSession.isRunning, + isBusy: flowViewModel.isBusy, + currentFace: flowViewModel.currentFaceId, + pulse: isStatusPulseOn + ) + ) + ) + .task { + do { + let granted = await cameraSession.requestPermission() + guard granted else { + cameraError = "Camera permission was denied. Enable it in Settings to scan faces." + return + } + try await cameraSession.start() + } catch { + cameraError = error.localizedDescription + } + } + .alert("Camera Error", isPresented: Binding( + get: { cameraError != nil }, + set: { show in + if !show { cameraError = nil } + } + ), actions: { + Button("OK") { cameraError = nil } + }, message: { + Text(cameraError ?? "Unknown camera error") + }) + .onDisappear { + cameraSession.stop() + } + .onAppear { + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + isStatusPulseOn = true + } + } + } +} + +private struct LiveCameraPreviewCard: View { + let cameraSession: CameraSession + let isRunning: Bool + let isBusy: Bool + let currentFace: FaceId + let pulse: Bool + + var body: some View { + ZStack { + LiveCameraPreviewView(cameraSession: cameraSession) + .overlay(Color.black.opacity(isRunning ? 0.14 : 0.28)) + FaceTargetGridOverlay() + .padding(20) + + VStack(spacing: 0) { + LiveCameraStatusBanner( + isRunning: isRunning, + isBusy: isBusy, + currentFace: currentFace, + pulse: pulse + ) + Spacer() + Text("Align the \(currentFace.displayName.lowercased()) face inside the 3x3 guide") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.black.opacity(0.5), in: Capsule()) + Text("Center color: \(currentFace.expectedCenterColorName)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.white.opacity(0.92)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.black.opacity(0.42), in: Capsule()) + } + .padding(10) + } + .frame(maxWidth: .infinity) + .frame(height: 250) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + .accessibilityElement(children: .contain) + .accessibilityLabel("Live camera preview") + .accessibilityHint("Align the target face inside the guide before scanning.") + } +} + +private struct LiveCameraPreviewView: UIViewRepresentable { + let cameraSession: CameraSession + + func makeUIView(context: Context) -> CameraPreviewUIView { + let preview = CameraPreviewUIView() + preview.previewLayer = cameraSession.getPreviewLayer() + return preview + } + + func updateUIView(_ uiView: CameraPreviewUIView, context: Context) { + if uiView.previewLayer == nil { + uiView.previewLayer = cameraSession.getPreviewLayer() + } + } +} + +private struct LiveCameraStatusBanner: View { + let isRunning: Bool + let isBusy: Bool + let currentFace: FaceId + let pulse: Bool + + var body: some View { + HStack(spacing: 10) { + Circle() + .fill(isRunning ? .green : .red) + .frame(width: 10, height: 10) + .scaleEffect(isRunning && isBusy ? (pulse ? 1.25 : 0.9) : 1) + + VStack(alignment: .leading, spacing: 2) { + Text(primaryText) + .font(.caption.weight(.semibold)) + Text("Target face: \(currentFace.displayName) (\(currentFace.rawValue)) - \(currentFace.expectedCenterColorName)") + .font(.caption2) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.25), lineWidth: 1) + ) + } + + private var primaryText: String { + if !isRunning { + return "Camera offline" + } + if isBusy { + return "Scanning in progress" + } + return "Camera live - ready to scan" + } +} + +private struct FaceTargetGridOverlay: View { + var body: some View { + GeometryReader { geometry in + let side = min(geometry.size.width, geometry.size.height) * 0.74 + let cell = side / 3 + + ZStack { + RoundedRectangle(cornerRadius: 12) + .stroke( + Color.white.opacity(0.9), + style: StrokeStyle(lineWidth: 2, dash: [8, 4]) + ) + .frame(width: side, height: side) + + Path { path in + for index in 1..<3 { + let offset = -side / 2 + CGFloat(index) * cell + path.move(to: CGPoint(x: offset, y: -side / 2)) + path.addLine(to: CGPoint(x: offset, y: side / 2)) + path.move(to: CGPoint(x: -side / 2, y: offset)) + path.addLine(to: CGPoint(x: side / 2, y: offset)) + } + } + .stroke(Color.white.opacity(0.65), lineWidth: 1) + } + .frame(width: geometry.size.width, height: geometry.size.height) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + } + .allowsHitTesting(false) + } +} + +#endif diff --git a/Sources/CubeUI/ScanSolveFlow/RotatingScanCubeView.swift b/Sources/CubeUI/ScanSolveFlow/RotatingScanCubeView.swift new file mode 100644 index 0000000..449e588 --- /dev/null +++ b/Sources/CubeUI/ScanSolveFlow/RotatingScanCubeView.swift @@ -0,0 +1,347 @@ +#if canImport(SwiftUI) + +import SwiftUI +import CubeCore + +struct RotatingScanCubeView: View { + let targetFace: FaceId + let scannedFaces: [FaceId: ScannedFaceData] + let isScanning: Bool + var showsFaceLabels: Bool = true + var autoRotate: Bool = true + + @State private var committedYaw: Double = 0 + @State private var committedPitch: Double? + @GestureState private var dragInteraction: DragInteraction = .inactive + + var body: some View { + let liveYawOffset = committedYaw + (Double(dragInteraction.translation.width) * 0.012) + let basePitch = committedPitch ?? defaultPitch + let livePitch = clampedPitch(basePitch - (Double(dragInteraction.translation.height) * 0.010)) + + return TimelineView( + .animation(minimumInterval: 1.0 / 24.0, paused: !autoRotate || dragInteraction.isActive) + ) { timeline in + Canvas { context, size in + drawCube( + in: context, + size: size, + at: timeline.date.timeIntervalSinceReferenceDate, + manualYaw: liveYawOffset, + manualPitch: livePitch + ) + } + } + .contentShape(Rectangle()) + .highPriorityGesture(rotateGesture) + .onTapGesture(count: 2) { + withAnimation(.easeOut(duration: 0.2)) { + committedYaw = 0 + committedPitch = nil + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("3D cube scan preview") + .accessibilityValue(accessibilityValue) + .accessibilityHint("Drag to rotate the cube. Double tap to reset orientation.") + } + + private func drawCube( + in context: GraphicsContext, + size: CGSize, + at timestamp: TimeInterval, + manualYaw: Double, + manualPitch: Double + ) { + let elapsed = timestamp.truncatingRemainder(dividingBy: 18) + let spinYaw = autoRotate ? (elapsed / 18) * (.pi * 2) : 0 + let yaw = spinYaw + manualYaw + let pitch = manualPitch + let pulse = CGFloat(0.5 + 0.5 * sin(timestamp * 3.2)) + + let projections = projectedFaces(size: size, yaw: yaw, pitch: pitch) + + for projection in projections.sorted(by: { $0.depth < $1.depth }) { + let facePath = Path { path in + path.move(to: projection.points[0]) + path.addLine(to: projection.points[1]) + path.addLine(to: projection.points[2]) + path.addLine(to: projection.points[3]) + path.closeSubpath() + } + + let faceGrid = scannedFaces[projection.id]?.grid + drawFaceStickers( + projection: projection, + faceGrid: faceGrid, + in: context + ) + + let borderColor: Color + let borderWidth: CGFloat + + if projection.id == targetFace { + borderColor = .blue + borderWidth = 2.2 + pulse * 1.2 + } else if faceGrid != nil { + borderColor = .green + borderWidth = 1.8 + } else { + borderColor = .secondary.opacity(0.5) + borderWidth = 1.0 + } + + context.stroke(facePath, with: .color(borderColor), lineWidth: borderWidth) + + if showsFaceLabels { + let center = projection.points.centerPoint + context.draw( + Text(projection.id.rawValue) + .font(.caption2.monospaced().weight(.bold)) + .foregroundColor(.white), + at: center + ) + } + } + } + + private func drawFaceStickers( + projection: FaceProjection, + faceGrid: CubeFaceGrid?, + in context: GraphicsContext + ) { + for row in 0..<3 { + for column in 0..<3 { + let u0 = CGFloat(column) / 3 + let u1 = CGFloat(column + 1) / 3 + let v0 = CGFloat(row) / 3 + let v1 = CGFloat(row + 1) / 3 + + let p00 = bilinear(projection.points, u: u0, v: v0) + let p10 = bilinear(projection.points, u: u1, v: v0) + let p11 = bilinear(projection.points, u: u1, v: v1) + let p01 = bilinear(projection.points, u: u0, v: v1) + + let cellPath = Path { path in + path.move(to: p00) + path.addLine(to: p10) + path.addLine(to: p11) + path.addLine(to: p01) + path.closeSubpath() + } + + let index = row * 3 + column + let stickerColor = faceGrid?[index] ?? projection.id.expectedCenterColor + let opacity: CGFloat = faceGrid == nil ? 0.30 : 0.92 + + context.fill(cellPath, with: .color(swiftUIColor(for: stickerColor).opacity(opacity))) + context.stroke(cellPath, with: .color(.black.opacity(0.22)), lineWidth: 0.55) + } + } + } + + private func projectedFaces(size: CGSize, yaw: Double, pitch: Double) -> [FaceProjection] { + let cameraDistance = 4.5 + let scale = min(size.width, size.height) * 0.31 + + func project(_ point: Vec3) -> CGPoint { + let perspective = cameraDistance / (cameraDistance - point.z) + return CGPoint( + x: size.width * 0.5 + CGFloat(point.x * perspective) * scale, + y: size.height * 0.52 - CGFloat(point.y * perspective) * scale + ) + } + + return FaceId.allCases.compactMap { id in + let definition = FaceDefinition.definition(for: id) + let rotatedCorners = definition.corners.map { $0.rotated(yaw: yaw, pitch: pitch) } + let rotatedNormal = definition.normal.rotated(yaw: yaw, pitch: pitch) + + guard rotatedNormal.z > 0.01 else { + return nil + } + + let points = rotatedCorners.map(project) + let depth = rotatedCorners.map(\.z).reduce(0, +) / 4 + + return FaceProjection(id: id, points: points, depth: depth) + } + } + + private func bilinear(_ corners: [CGPoint], u: CGFloat, v: CGFloat) -> CGPoint { + let top = corners[0].lerp(to: corners[1], t: u) + let bottom = corners[3].lerp(to: corners[2], t: u) + return top.lerp(to: bottom, t: v) + } + + private var accessibilityValue: String { + let scanned = scannedFaces.keys.map(\.rawValue).sorted().joined(separator: ", ") + if scanned.isEmpty { + return "No scanned faces yet. Target is \(targetFace.displayName)." + } + return "Scanned faces: \(scanned). Target is \(targetFace.displayName)." + } + + private var defaultPitch: Double { + isScanning ? -0.52 : -0.45 + } + + private var rotateGesture: some Gesture { + DragGesture(minimumDistance: 2, coordinateSpace: .local) + .updating($dragInteraction) { value, state, _ in + state = DragInteraction(translation: value.translation, isActive: true) + } + .onEnded { value in + committedYaw = normalizedAngle(committedYaw + (Double(value.translation.width) * 0.012)) + let startingPitch = committedPitch ?? defaultPitch + committedPitch = clampedPitch(startingPitch - (Double(value.translation.height) * 0.010)) + } + } + + private func clampedPitch(_ value: Double) -> Double { + min(max(value, -1.05), 0.28) + } + + private func normalizedAngle(_ value: Double) -> Double { + let full = Double.pi * 2 + var angle = value.truncatingRemainder(dividingBy: full) + if angle > Double.pi { + angle -= full + } else if angle < -Double.pi { + angle += full + } + return angle + } +} + +private struct DragInteraction { + var translation: CGSize + var isActive: Bool + + static let inactive = DragInteraction(translation: .zero, isActive: false) +} + +private struct FaceProjection { + let id: FaceId + let points: [CGPoint] + let depth: Double +} + +private struct FaceDefinition { + let corners: [Vec3] + let normal: Vec3 + + static func definition(for face: FaceId) -> FaceDefinition { + switch face { + case .up: + return FaceDefinition( + corners: [ + Vec3(-1, 1, -1), + Vec3(1, 1, -1), + Vec3(1, 1, 1), + Vec3(-1, 1, 1) + ], + normal: Vec3(0, 1, 0) + ) + case .down: + return FaceDefinition( + corners: [ + Vec3(-1, -1, 1), + Vec3(1, -1, 1), + Vec3(1, -1, -1), + Vec3(-1, -1, -1) + ], + normal: Vec3(0, -1, 0) + ) + case .front: + return FaceDefinition( + corners: [ + Vec3(-1, 1, 1), + Vec3(1, 1, 1), + Vec3(1, -1, 1), + Vec3(-1, -1, 1) + ], + normal: Vec3(0, 0, 1) + ) + case .back: + return FaceDefinition( + corners: [ + Vec3(1, 1, -1), + Vec3(-1, 1, -1), + Vec3(-1, -1, -1), + Vec3(1, -1, -1) + ], + normal: Vec3(0, 0, -1) + ) + case .right: + return FaceDefinition( + corners: [ + Vec3(1, 1, 1), + Vec3(1, 1, -1), + Vec3(1, -1, -1), + Vec3(1, -1, 1) + ], + normal: Vec3(1, 0, 0) + ) + case .left: + return FaceDefinition( + corners: [ + Vec3(-1, 1, -1), + Vec3(-1, 1, 1), + Vec3(-1, -1, 1), + Vec3(-1, -1, -1) + ], + normal: Vec3(-1, 0, 0) + ) + } + } +} + +private struct Vec3 { + var x: Double + var y: Double + var z: Double + + init(_ x: Double, _ y: Double, _ z: Double) { + self.x = x + self.y = y + self.z = z + } + + func rotated(yaw: Double, pitch: Double) -> Vec3 { + let cosy = cos(yaw) + let siny = sin(yaw) + let x1 = (x * cosy) + (z * siny) + let z1 = (-x * siny) + (z * cosy) + + let cosp = cos(pitch) + let sinp = sin(pitch) + let y2 = (y * cosp) - (z1 * sinp) + let z2 = (y * sinp) + (z1 * cosp) + + return Vec3(x1, y2, z2) + } +} + +private extension Array where Element == CGPoint { + var centerPoint: CGPoint { + guard count == 4 else { + return .zero + } + + let x = (self[0].x + self[1].x + self[2].x + self[3].x) / 4 + let y = (self[0].y + self[1].y + self[2].y + self[3].y) / 4 + return CGPoint(x: x, y: y) + } +} + +private extension CGPoint { + func lerp(to other: CGPoint, t: CGFloat) -> CGPoint { + CGPoint( + x: x + (other.x - x) * t, + y: y + (other.y - y) * t + ) + } +} + +#endif diff --git a/Sources/CubeUI/ScanSolveFlow/ScanFaceGuidanceView.swift b/Sources/CubeUI/ScanSolveFlow/ScanFaceGuidanceView.swift new file mode 100644 index 0000000..9382f96 --- /dev/null +++ b/Sources/CubeUI/ScanSolveFlow/ScanFaceGuidanceView.swift @@ -0,0 +1,135 @@ +#if canImport(SwiftUI) + +import SwiftUI +import CubeCore + +struct ScanFaceGuidanceView: View { + let targetFace: FaceId + let scannedFaces: [FaceId: ScannedFaceData] + let scanOrder: [FaceId] + let isScanning: Bool + + var body: some View { + HStack(spacing: 14) { + RotatingScanCubeView( + targetFace: targetFace, + scannedFaces: scannedFaces, + isScanning: isScanning, + showsFaceLabels: true, + autoRotate: true + ) + .frame(width: 132, height: 112) + + VStack(alignment: .leading, spacing: 6) { + Text("Next: \(targetFace.displayName) (\(targetFace.rawValue))") + .font(.headline) + + HStack(spacing: 8) { + Circle() + .fill(swiftUIColor(for: targetFace.expectedCenterColor)) + .frame(width: 12, height: 12) + .overlay(Circle().stroke(Color.primary.opacity(0.25), lineWidth: 1)) + Text("Center should be \(targetFace.expectedCenterColorName)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Text(turnHint) + .font(.caption) + .foregroundStyle(.secondary) + + Text("Drag cube to rotate. Double-tap to reset.") + .font(.caption2) + .foregroundStyle(.secondary.opacity(0.9)) + } + + Spacer(minLength: 0) + } + .padding(12) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel("Scan guidance") + .accessibilityValue("Target \(targetFace.displayName) face, center color \(targetFace.expectedCenterColorName).") + } + + private var previousCompletedFace: FaceId? { + guard let currentIndex = scanOrder.firstIndex(of: targetFace), + currentIndex > 0 else { + return nil + } + + let completedFaces = Set(scannedFaces.keys) + + for index in stride(from: currentIndex - 1, through: 0, by: -1) { + let candidate = scanOrder[index] + if completedFaces.contains(candidate) { + return candidate + } + } + + return nil + } + + private var turnHint: String { + guard let previous = previousCompletedFace else { + return "Start by showing the \(targetFace.displayName.lowercased()) center directly to the camera." + } + + switch (previous, targetFace) { + case (.up, .right): + return "From Up, rotate the cube so the right side faces the camera." + case (.right, .front): + return "From Right, turn the cube slightly left to bring Front toward the camera." + case (.front, .down): + return "From Front, tilt the cube upward to reveal the Down face." + case (.down, .left): + return "From Down, rotate the cube so the left side faces the camera." + case (.left, .back): + return "From Left, rotate another quarter turn to show the Back face." + default: + return "Rotate until the \(targetFace.expectedCenterColorName.lowercased()) center sticker is in the guide." + } + } +} + +extension FaceId { + var expectedCenterColor: CubeColor { + switch self { + case .up: + return .white + case .right: + return .blue + case .front: + return .red + case .down: + return .yellow + case .left: + return .green + case .back: + return .orange + } + } + + var expectedCenterColorName: String { + switch expectedCenterColor { + case .white: + return "White" + case .yellow: + return "Yellow" + case .red: + return "Red" + case .orange: + return "Orange" + case .blue: + return "Blue" + case .green: + return "Green" + } + } +} + +#endif diff --git a/Sources/CubeUI/ScanSolveFlow/ScanWizardView.swift b/Sources/CubeUI/ScanSolveFlow/ScanWizardView.swift new file mode 100644 index 0000000..c1c97d0 --- /dev/null +++ b/Sources/CubeUI/ScanSolveFlow/ScanWizardView.swift @@ -0,0 +1,155 @@ +#if canImport(SwiftUI) + +import SwiftUI +import CubeCore + +public struct ScanWizardView: View { + @StateObject private var viewModel: CubeScanSolveFlowViewModel + private let cameraPreview: AnyView? + @State private var showingManualEdit = false + @State private var showingSteps = false + + public init(viewModel: CubeScanSolveFlowViewModel, cameraPreview: AnyView? = nil) { + _viewModel = StateObject(wrappedValue: viewModel) + self.cameraPreview = cameraPreview + } + + public var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text("Scan -> Validate -> Edit -> Solve") + .font(.title2.bold()) + + if let cameraPreview { + cameraPreview + } + + ProgressView(value: Double(viewModel.scannedFaces.count), total: Double(viewModel.scanOrder.count)) { + Text(viewModel.progressText) + .font(.subheadline) + } + + ScanFaceGuidanceView( + targetFace: viewModel.currentFaceId, + scannedFaces: viewModel.scannedFaces, + scanOrder: viewModel.scanOrder, + isScanning: viewModel.isBusy + ) + + faceStatusRow + + if let pending = viewModel.pendingFace { + FaceConfirmView( + face: pending, + onConfirm: { viewModel.confirmPendingFace() }, + onRescan: { viewModel.rejectPendingFaceAndRescan() } + ) + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Next face: \(viewModel.currentFaceId.displayName) (\(viewModel.currentFaceId.rawValue))") + .font(.headline) + Text("Keep the \(viewModel.currentFaceId.displayName.lowercased()) face centered, then tap scan.") + .font(.caption) + .foregroundStyle(.secondary) + + Button { + Task { + await viewModel.scanCurrentFace() + } + } label: { + Label( + viewModel.isBusy + ? "Scanning \(viewModel.currentFaceId.displayName)..." + : "Scan \(viewModel.currentFaceId.displayName) Face", + systemImage: viewModel.isBusy ? "camera.aperture" : "camera" + ) + } + .disabled(viewModel.isBusy) + .buttonStyle(.borderedProminent) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16)) + } + + if let validationError = viewModel.validationError { + VStack(alignment: .leading, spacing: 6) { + Text(validationError.message) + .font(.subheadline.weight(.semibold)) + Text(validationError.suggestedFix) + .font(.caption) + .foregroundStyle(.secondary) + Button("Open Manual Edit") { + showingManualEdit = true + } + .buttonStyle(.bordered) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.14), in: RoundedRectangle(cornerRadius: 14)) + } + + if viewModel.canStartSolve { + Button { + Task { + await viewModel.solve() + showingSteps = viewModel.state == .solved + } + } label: { + Label(viewModel.isBusy ? "Solving..." : "Solve Cube", systemImage: "wand.and.stars") + } + .disabled(viewModel.isBusy) + .buttonStyle(.borderedProminent) + } + + if case .failed(let message) = viewModel.state { + Text(message) + .font(.footnote) + .foregroundStyle(.red) + } + + if !viewModel.solvedMoves.isEmpty { + Button("View Step-by-step", systemImage: "list.number") { + showingSteps = true + } + .buttonStyle(.bordered) + } + } + .padding() + } + .navigationTitle("Cube Scanner") + } + .sheet(isPresented: $showingManualEdit) { + CubeManualEditView(viewModel: viewModel) + } + .navigationDestination(isPresented: $showingSteps) { + SolveStepsView(viewModel: viewModel) + } + .onChange(of: viewModel.state) { _, newState in + if newState == .solved { + showingSteps = true + } + } + } + + private var faceStatusRow: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(viewModel.scanOrder, id: \.self) { face in + FaceBadgeView( + face: face, + isComplete: viewModel.scannedFaces[face] != nil + ) + .onTapGesture { + if viewModel.scannedFaces[face] != nil { + showingManualEdit = true + } + } + } + } + } + } +} + +#endif diff --git a/Sources/CubeUI/ScanSolveFlow/SolveStepsView.swift b/Sources/CubeUI/ScanSolveFlow/SolveStepsView.swift new file mode 100644 index 0000000..af0b24d --- /dev/null +++ b/Sources/CubeUI/ScanSolveFlow/SolveStepsView.swift @@ -0,0 +1,104 @@ +#if canImport(SwiftUI) + +import SwiftUI +import CubeCore + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +public struct SolveStepsView: View { + @ObservedObject private var viewModel: CubeScanSolveFlowViewModel + + public init(viewModel: CubeScanSolveFlowViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + VStack(spacing: 16) { + if let instruction = viewModel.currentInstruction { + VStack(spacing: 8) { + Text(instruction.headline) + .font(.system(size: 52, weight: .bold, design: .rounded)) + Text(instruction.explanation) + .font(.subheadline) + .foregroundStyle(.secondary) + Text(instruction.progressText) + .font(.caption.monospacedDigit()) + } + .padding() + .frame(maxWidth: .infinity) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16)) + } + + HStack(spacing: 12) { + Button("Back", systemImage: "chevron.left") { + viewModel.previousMove() + } + .buttonStyle(.bordered) + .disabled(viewModel.currentMoveIndex <= 1) + + Button("Next", systemImage: "chevron.right") { + viewModel.nextMove() + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.currentMoveIndex >= viewModel.solvedMoves.count) + } + + List { + ForEach(Array(viewModel.solvedMoves.enumerated()), id: \.offset) { item in + let rowIndex = item.offset + 1 + HStack { + Text("\(rowIndex).") + .font(.caption.monospacedDigit()) + .frame(width: 34, alignment: .trailing) + .foregroundStyle(.secondary) + + Text(item.element.notation) + .font(.body.monospaced()) + + Spacer() + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.jumpToMove(rowIndex) + } + .listRowBackground( + rowIndex == viewModel.currentMoveIndex ? Color.accentColor.opacity(0.16) : Color.clear + ) + } + } + .listStyle(.plain) + + HStack(spacing: 12) { + Button("Copy Solution", systemImage: "doc.on.doc") { + copyToClipboard(viewModel.solutionText) + } + .buttonStyle(.bordered) + + Button("Restart", systemImage: "arrow.counterclockwise") { + viewModel.restart() + } + .buttonStyle(.borderedProminent) + } + } + .padding() + .navigationTitle("Solve Steps") + } + + private func copyToClipboard(_ text: String) { +#if canImport(UIKit) + UIPasteboard.general.string = text +#elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) +#endif + } +} + +#endif diff --git a/Tests/CubeCoreTests/CubeSolvingAbstractionTests.swift b/Tests/CubeCoreTests/CubeSolvingAbstractionTests.swift new file mode 100644 index 0000000..dfe692d --- /dev/null +++ b/Tests/CubeCoreTests/CubeSolvingAbstractionTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import CubeCore + +final class CubeSolvingAbstractionTests: XCTestCase { + func testKociembaCompatibleSolverReturnsValidSolutionFormat() async throws { + var scrambledState = CubeState() + let scramble: [Move] = [ + Move(turn: .R, amount: .clockwise), + Move(turn: .U, amount: .clockwise), + Move(turn: .F, amount: .counter) + ] + + EnhancedCubeSolver.applyMoves(to: &scrambledState, moves: scramble) + + let solver = KociembaCompatibleCubeSolver( + fallback: AnyCubeSolver(EnhancedSearchCubeSolver(validationMode: .basic)) + ) + + let solution = try await solver.solve(state: scrambledState) + + XCTAssertFalse(solution.isEmpty) + XCTAssertTrue(solution.allSatisfy { Move(notation: $0.notation) != nil }) + + var solved = scrambledState + EnhancedCubeSolver.applyMoves(to: &solved, moves: solution) + XCTAssertTrue(isSolved(solved)) + } + + private func isSolved(_ state: CubeState) -> Bool { + for face in Face.allCases { + guard let stickers = state.faces[face], let first = stickers.first else { + return false + } + if stickers.contains(where: { $0 != first }) { + return false + } + } + return true + } +} diff --git a/Tests/CubeCoreTests/CubeStateValidatorEngineTests.swift b/Tests/CubeCoreTests/CubeStateValidatorEngineTests.swift new file mode 100644 index 0000000..b567ace --- /dev/null +++ b/Tests/CubeCoreTests/CubeStateValidatorEngineTests.swift @@ -0,0 +1,103 @@ +import XCTest +@testable import CubeCore + +final class CubeStateValidatorEngineTests: XCTestCase { + private let validator = CubeStateValidator() + + func testSolvedCubePassesValidation() { + let result = validator.validate(state: CubeState()) + + switch result { + case .success: + XCTAssertTrue(true) + case .failure(let error): + XCTFail("Expected success but got \(error)") + } + } + + func testInvalidColorCountsFailWithCountMismatch() { + var state = CubeState() + state.setSticker(face: .front, index: 0, color: .blue) + + let result = validator.validate(state: state) + + guard case let .failure(error) = result else { + return XCTFail("Expected validation failure") + } + + XCTAssertEqual(error.type, .countMismatch) + XCTAssertTrue(error.message.contains("stickers")) + } + + func testImpossibleParityFails() { + var state = CubeState() + + // Swap two corners to force odd corner parity while keeping color counts valid. + swapCorner(&state, (.up, 8), (.right, 0), (.front, 2), with: (.up, 6), (.front, 0), (.left, 2)) + + let result = validator.validate(state: state) + + guard case let .failure(error) = result else { + return XCTFail("Expected validation failure") + } + + XCTAssertEqual(error.type, .impossibleParity) + } + + func testSwappedEdgeScenarioFailsValidation() { + var state = CubeState() + + // Swap UR and UF edge pieces as whole pieces. + swapEdge(&state, first: (.up, 5), (.right, 1), second: (.up, 7), (.front, 1)) + + let result = validator.validate(state: state) + + guard case let .failure(error) = result else { + return XCTFail("Expected validation failure") + } + + XCTAssertEqual(error.type, .impossibleParity) + } + + private func swapEdge( + _ state: inout CubeState, + first firstA: (Face, Int), _ firstB: (Face, Int), + second secondA: (Face, Int), _ secondB: (Face, Int) + ) { + let firstAColor = state.getSticker(face: firstA.0, index: firstA.1) ?? .white + let firstBColor = state.getSticker(face: firstB.0, index: firstB.1) ?? .white + let secondAColor = state.getSticker(face: secondA.0, index: secondA.1) ?? .white + let secondBColor = state.getSticker(face: secondB.0, index: secondB.1) ?? .white + + state.setSticker(face: firstA.0, index: firstA.1, color: secondAColor) + state.setSticker(face: firstB.0, index: firstB.1, color: secondBColor) + state.setSticker(face: secondA.0, index: secondA.1, color: firstAColor) + state.setSticker(face: secondB.0, index: secondB.1, color: firstBColor) + } + + private func swapCorner( + _ state: inout CubeState, + _ firstA: (Face, Int), _ firstB: (Face, Int), _ firstC: (Face, Int), + with secondA: (Face, Int), _ secondB: (Face, Int), _ secondC: (Face, Int) + ) { + let firstColors = [ + state.getSticker(face: firstA.0, index: firstA.1) ?? .white, + state.getSticker(face: firstB.0, index: firstB.1) ?? .white, + state.getSticker(face: firstC.0, index: firstC.1) ?? .white + ] + + let secondColors = [ + state.getSticker(face: secondA.0, index: secondA.1) ?? .white, + state.getSticker(face: secondB.0, index: secondB.1) ?? .white, + state.getSticker(face: secondC.0, index: secondC.1) ?? .white + ] + + state.setSticker(face: firstA.0, index: firstA.1, color: secondColors[0]) + state.setSticker(face: firstB.0, index: firstB.1, color: secondColors[1]) + state.setSticker(face: firstC.0, index: firstC.1, color: secondColors[2]) + + state.setSticker(face: secondA.0, index: secondA.1, color: firstColors[0]) + state.setSticker(face: secondB.0, index: secondB.1, color: firstColors[1]) + state.setSticker(face: secondC.0, index: secondC.1, color: firstColors[2]) + } +} diff --git a/Tests/CubeCoreTests/ScanSolveDomainTests.swift b/Tests/CubeCoreTests/ScanSolveDomainTests.swift new file mode 100644 index 0000000..2ddf0e6 --- /dev/null +++ b/Tests/CubeCoreTests/ScanSolveDomainTests.swift @@ -0,0 +1,75 @@ +import XCTest +@testable import CubeCore + +final class ScanSolveDomainTests: XCTestCase { + func testCubeFaceGridRejectsInvalidStickerCount() { + XCTAssertThrowsError(try CubeFaceGrid(stickers: [.white])) { error in + guard let gridError = error as? CubeFaceGridError else { + return XCTFail("Expected CubeFaceGridError") + } + XCTAssertEqual(gridError, .invalidStickerCount(expected: 9, actual: 1)) + } + } + + func testCubeStateAssemblerRequiresAllFaces() { + let assembler = CubeStateAssembler() + let partial: [FaceId: CubeFaceGrid] = [ + .up: CubeFaceGrid(repeating: .white) + ] + + XCTAssertThrowsError(try assembler.assemble(from: partial)) { error in + guard case let CubeStateAssemblyError.missingFaces(missing) = error else { + return XCTFail("Expected missingFaces") + } + XCTAssertTrue(missing.contains(.front)) + XCTAssertTrue(missing.contains(.right)) + } + } + + func testCubeStateAssemblerBuildsSolvedState() throws { + let assembler = CubeStateAssembler() + let state = try assembler.assemble(from: solvedFaceGrids()) + + XCTAssertEqual(state.centerColor(of: .up), .white) + XCTAssertEqual(state.centerColor(of: .right), .blue) + XCTAssertEqual(state.centerColor(of: .front), .red) + XCTAssertEqual(state.centerColor(of: .down), .yellow) + XCTAssertEqual(state.centerColor(of: .left), .green) + XCTAssertEqual(state.centerColor(of: .back), .orange) + } + + func testKociembaCodecRoundTripSolvedCube() throws { + let codec = KociembaCodec() + let encoded = try codec.encode(CubeState()) + + XCTAssertEqual(encoded.count, 54) + + let decoded = try codec.decode(encoded) + XCTAssertEqual(decoded, CubeState()) + } + + func testMoveNotationCodecRoundTrip() throws { + let codec = MoveNotationCodec() + let original: [Move] = [ + Move(turn: .R, amount: .clockwise), + Move(turn: .U, amount: .counter), + Move(turn: .F, amount: .double) + ] + + let text = codec.encode(original) + let decoded = try codec.decode(text) + + XCTAssertEqual(decoded, original) + } + + private func solvedFaceGrids() -> [FaceId: CubeFaceGrid] { + [ + .up: CubeFaceGrid(repeating: .white), + .right: CubeFaceGrid(repeating: .blue), + .front: CubeFaceGrid(repeating: .red), + .down: CubeFaceGrid(repeating: .yellow), + .left: CubeFaceGrid(repeating: .green), + .back: CubeFaceGrid(repeating: .orange) + ] + } +} diff --git a/Tests/CubeScannerTests/ScanSolvePipelineTests.swift b/Tests/CubeScannerTests/ScanSolvePipelineTests.swift new file mode 100644 index 0000000..adc5355 --- /dev/null +++ b/Tests/CubeScannerTests/ScanSolvePipelineTests.swift @@ -0,0 +1,104 @@ +import XCTest +import CubeCore +@testable import CubeScanner + +final class ScanSolvePipelineTests: XCTestCase { + func testHSVClassifierMapsRepresentativeSamples() { + let classifier = HSVStickerClassifier() + + XCTAssertEqual(classifier.classify(pixel: RGBPixel(red: 0.95, green: 0.95, blue: 0.95)).color, .white) + XCTAssertEqual(classifier.classify(pixel: RGBPixel(red: 0.95, green: 0.82, blue: 0.15)).color, .yellow) + XCTAssertEqual(classifier.classify(pixel: RGBPixel(red: 0.88, green: 0.14, blue: 0.12)).color, .red) + XCTAssertEqual(classifier.classify(pixel: RGBPixel(red: 0.95, green: 0.44, blue: 0.10)).color, .orange) + XCTAssertEqual(classifier.classify(pixel: RGBPixel(red: 0.12, green: 0.22, blue: 0.85)).color, .blue) + XCTAssertEqual(classifier.classify(pixel: RGBPixel(red: 0.10, green: 0.68, blue: 0.20)).color, .green) + } + + func testFaceWarpSamplerCellExtractionMath() throws { + let frame = try makeSyntheticFrame( + rows: [ + [RGBPixel(red: 1, green: 0, blue: 0), RGBPixel(red: 0, green: 1, blue: 0), RGBPixel(red: 0, green: 0, blue: 1)], + [RGBPixel(red: 1, green: 0.5, blue: 0), RGBPixel(red: 1, green: 1, blue: 1), RGBPixel(red: 1, green: 1, blue: 0)], + [RGBPixel(red: 0, green: 0, blue: 1), RGBPixel(red: 1, green: 0, blue: 0), RGBPixel(red: 0, green: 1, blue: 0)] + ], + cellSize: 12 + ) + + let sampler = FaceWarpSampler(cellInset: 0.2) + let fullQuad = FaceQuadrilateral( + topLeft: NormalizedPoint(x: 0, y: 0), + topRight: NormalizedPoint(x: 1, y: 0), + bottomRight: NormalizedPoint(x: 1, y: 1), + bottomLeft: NormalizedPoint(x: 0, y: 1) + ) + + let center = sampler.sampleCell(frame: frame, quad: fullQuad, row: 1, column: 1) + + XCTAssertEqual(center.red, 1, accuracy: 0.02) + XCTAssertEqual(center.green, 1, accuracy: 0.02) + XCTAssertEqual(center.blue, 1, accuracy: 0.02) + } + + func testDefaultFaceScannerWithStaticFrame() async throws { + let frame = try makeSyntheticFrame( + rows: [ + [RGBPixel(red: 0.95, green: 0.10, blue: 0.10), RGBPixel(red: 0.10, green: 0.65, blue: 0.20), RGBPixel(red: 0.10, green: 0.20, blue: 0.85)], + [RGBPixel(red: 0.95, green: 0.45, blue: 0.12), RGBPixel(red: 0.96, green: 0.96, blue: 0.96), RGBPixel(red: 0.95, green: 0.85, blue: 0.15)], + [RGBPixel(red: 0.10, green: 0.20, blue: 0.85), RGBPixel(red: 0.95, green: 0.10, blue: 0.10), RGBPixel(red: 0.10, green: 0.65, blue: 0.20)] + ], + cellSize: 18 + ) + + let scanner = DefaultFaceScanner( + frameSource: StaticFrameSource(frame: frame), + quadDetector: FullQuadDetector(), + warpSampler: FaceWarpSampler(cellInset: 0.15), + classifier: HSVStickerClassifier(), + maxScanAttempts: 1, + minimumMeanConfidence: 0 + ) + + let result = try await scanner.scanFace(for: .front) + + XCTAssertEqual(result.id, .front) + XCTAssertEqual(result.grid[0], .red) + XCTAssertEqual(result.grid[4], .white) + XCTAssertEqual(result.grid[5], .yellow) + } + + private func makeSyntheticFrame(rows: [[RGBPixel]], cellSize: Int) throws -> RGBFrame { + let width = 3 * cellSize + let height = 3 * cellSize + var pixels = Array(repeating: RGBPixel.black, count: width * height) + + for row in 0..<3 { + for column in 0..<3 { + let color = rows[row][column] + let startY = row * cellSize + let endY = startY + cellSize + let startX = column * cellSize + let endX = startX + cellSize + + for y in startY.. FaceQuadrilateral? { + _ = frame + return FaceQuadrilateral( + topLeft: NormalizedPoint(x: 0, y: 0), + topRight: NormalizedPoint(x: 1, y: 0), + bottomRight: NormalizedPoint(x: 1, y: 1), + bottomLeft: NormalizedPoint(x: 0, y: 1) + ) + } + } +} diff --git a/Tests/CubeUITests/ScanSolveFlowIntegrationTests.swift b/Tests/CubeUITests/ScanSolveFlowIntegrationTests.swift new file mode 100644 index 0000000..0e41ed8 --- /dev/null +++ b/Tests/CubeUITests/ScanSolveFlowIntegrationTests.swift @@ -0,0 +1,72 @@ +#if canImport(SwiftUI) + +import XCTest +@testable import CubeCore +@testable import CubeScanner +@testable import CubeUI + +@MainActor +final class ScanSolveFlowIntegrationTests: XCTestCase { + func testFullFlowWithMockScanner() async { + let scanner = SimulatedFaceScanner(scriptedFaces: solvedScannedFaces()) + let viewModel = CubeScanSolveFlowViewModel( + scanner: scanner, + validator: CubeStateValidator(), + solver: KociembaCompatibleCubeSolver() + ) + + for _ in viewModel.scanOrder { + await viewModel.scanCurrentFace() + XCTAssertNotNil(viewModel.pendingFace) + viewModel.confirmPendingFace() + } + + XCTAssertTrue(viewModel.canStartSolve) + XCTAssertNil(viewModel.validationError) + + await viewModel.solve() + + XCTAssertEqual(viewModel.state, .solved) + XCTAssertTrue(viewModel.solvedMoves.isEmpty) + } + + func testInvalidMockScanTransitionsToManualEditAndRecovers() async { + var invalidFaces = solvedScannedFaces() + var front = invalidFaces[.front]! + front.grid[0] = .blue + invalidFaces[.front] = front + + let scanner = SimulatedFaceScanner(scriptedFaces: invalidFaces) + let viewModel = CubeScanSolveFlowViewModel( + scanner: scanner, + validator: CubeStateValidator(), + solver: KociembaCompatibleCubeSolver() + ) + + for _ in viewModel.scanOrder { + await viewModel.scanCurrentFace() + viewModel.confirmPendingFace() + } + + XCTAssertEqual(viewModel.validationError?.type, .countMismatch) + XCTAssertEqual(viewModel.state, .editing) + + viewModel.updateSticker(face: .front, index: 0, color: .red) + + XCTAssertNil(viewModel.validationError) + XCTAssertEqual(viewModel.state, .readyToSolve) + } + + private func solvedScannedFaces() -> [FaceId: ScannedFaceData] { + [ + .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) + ] + } +} + +#endif From 17f97491889e274a637e09c077e71ee914cb86dc Mon Sep 17 00:00:00 2001 From: Mark Coleman Date: Sun, 22 Feb 2026 15:03:35 -0500 Subject: [PATCH 3/4] ux improvements --- .../ScanSolveFlow/CubeManualEditView.swift | 103 +++++++++--- .../CubeScanSolveComponents.swift | 72 ++++++-- .../ScanSolveFlow/FaceConfirmView.swift | 11 ++ .../LiveScanWizardContainerView.swift | 51 +++++- .../ScanSolveFlow/RotatingScanCubeView.swift | 15 +- .../ScanSolveFlow/ScanFaceGuidanceView.swift | 1 + .../CubeUI/ScanSolveFlow/ScanWizardView.swift | 158 +++++++++++++----- docs/ACCESSIBILITY.md | 16 ++ 8 files changed, 336 insertions(+), 91 deletions(-) diff --git a/Sources/CubeUI/ScanSolveFlow/CubeManualEditView.swift b/Sources/CubeUI/ScanSolveFlow/CubeManualEditView.swift index d667418..6c456c9 100644 --- a/Sources/CubeUI/ScanSolveFlow/CubeManualEditView.swift +++ b/Sources/CubeUI/ScanSolveFlow/CubeManualEditView.swift @@ -6,27 +6,25 @@ import CubeCore public struct CubeManualEditView: View { @ObservedObject private var viewModel: CubeScanSolveFlowViewModel @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor - @State private var selectedFace: FaceId = .up + @State private var selectedFace: FaceId @State private var selectedColor: CubeColor = .white - public init(viewModel: CubeScanSolveFlowViewModel) { + public init(viewModel: CubeScanSolveFlowViewModel, initialFace: FaceId? = nil) { self.viewModel = viewModel + _selectedFace = State(initialValue: initialFace ?? viewModel.scanOrder.first ?? .up) } public var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 16) { - Picker("Face", selection: $selectedFace) { - ForEach(viewModel.scanOrder, id: \.self) { face in - Text(face.displayName).tag(face) - } - } - .pickerStyle(.segmented) + facePicker if let scanned = viewModel.scannedFaces[selectedFace] { - Text("Tap a sticker to set \(selectedColor.rawValue)") + Text("Selected color: \(selectedColor.rawValue). Tap any sticker to apply.") .font(.caption) .foregroundStyle(.secondary) @@ -36,6 +34,7 @@ public struct CubeManualEditView: View { ) { index in viewModel.updateSticker(face: selectedFace, index: index, color: selectedColor) } + .accessibilityIdentifier("editableFaceView") if let error = viewModel.validationError { VStack(alignment: .leading, spacing: 6) { @@ -49,7 +48,7 @@ public struct CubeManualEditView: View { .background(Color.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) } } else { - Text("Face not scanned yet.") + Text("Face not scanned yet. Capture it first, or switch to a captured face.") .foregroundStyle(.secondary) } @@ -60,12 +59,16 @@ public struct CubeManualEditView: View { viewModel.resetFace(selectedFace) } .buttonStyle(.bordered) + .accessibilityHint("Resets this face to its default center color.") + .accessibilityIdentifier("resetFaceButton") Button("Re-scan Face", systemImage: "camera.rotate") { viewModel.markFaceForRescan(selectedFace) dismiss() } .buttonStyle(.bordered) + .accessibilityHint("Returns to scanning for this face.") + .accessibilityIdentifier("rescanFaceButton") } } .padding() @@ -77,26 +80,84 @@ public struct CubeManualEditView: View { viewModel.resumeWizard() dismiss() } + .accessibilityIdentifier("doneButton") } } } } private var colorPalette: some View { - HStack(spacing: 10) { - ForEach(CubeColor.allCases, id: \.self) { color in + VStack(alignment: .leading, spacing: 10) { + Text("Input Color") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 46), spacing: 12)], alignment: .leading, spacing: 12) { + ForEach(CubeColor.allCases, id: \.self) { color in + colorButton(for: color) + } + } + } + .accessibilityIdentifier("colorSelector") + } + + private var facePicker: some View { + Group { + if dynamicTypeSize.isAccessibilitySize { + Picker("Face", selection: $selectedFace) { + ForEach(viewModel.scanOrder, id: \.self) { face in + Text(face.displayName).tag(face) + } + } + .pickerStyle(.menu) + } else { + Picker("Face", selection: $selectedFace) { + ForEach(viewModel.scanOrder, id: \.self) { face in + Text(face.displayName).tag(face) + } + } + .pickerStyle(.segmented) + } + } + .accessibilityIdentifier("faceSelector") + } + + private func colorButton(for color: CubeColor) -> some View { + let isSelected = selectedColor == color + + return Button { + selectedColor = color + } label: { + ZStack { Circle() .fill(swiftUIColor(for: color)) - .frame(width: 34, height: 34) - .overlay( - Circle() - .stroke(selectedColor == color ? Color.primary : Color.clear, lineWidth: 3) - ) - .onTapGesture { - selectedColor = color - } - .accessibilityLabel("Set color \(color.rawValue)") + + Circle() + .stroke(isSelected ? Color.primary : Color.black.opacity(0.2), lineWidth: isSelected ? 3 : 1) + + if isSelected || differentiateWithoutColor { + Image(systemName: isSelected ? "checkmark" : "circle") + .font(.caption.weight(.bold)) + .foregroundStyle(selectionIconColor(for: color)) + .opacity(isSelected ? 1 : 0.4) + } } + .frame(width: 46, height: 46) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(color.rawValue) color") + .accessibilityValue(isSelected ? "Selected" : "Not selected") + .accessibilityHint("Sets the input color for the next sticker.") + .accessibilityIdentifier("\(color.rawValue)ColorButton") + } + + private func selectionIconColor(for color: CubeColor) -> Color { + switch color { + case .white, .yellow, .orange: + return .black + case .red, .blue, .green: + return .white } } diff --git a/Sources/CubeUI/ScanSolveFlow/CubeScanSolveComponents.swift b/Sources/CubeUI/ScanSolveFlow/CubeScanSolveComponents.swift index 23befaa..6461d8a 100644 --- a/Sources/CubeUI/ScanSolveFlow/CubeScanSolveComponents.swift +++ b/Sources/CubeUI/ScanSolveFlow/CubeScanSolveComponents.swift @@ -7,6 +7,7 @@ struct FaceGridView: View { let grid: CubeFaceGrid let highlightedIndices: Set let onTap: ((Int) -> Void)? + private let cellSide: CGFloat = 46 init(grid: CubeFaceGrid, highlightedIndices: Set = [], onTap: ((Int) -> Void)? = nil) { self.grid = grid @@ -20,37 +21,78 @@ struct FaceGridView: View { HStack(spacing: 4) { ForEach(0..<3, id: \.self) { column in let index = row * 3 + column - Rectangle() - .fill(swiftUIColor(for: grid[index])) - .overlay( - Rectangle() - .stroke(highlightedIndices.contains(index) ? Color.red : Color.black.opacity(0.35), lineWidth: highlightedIndices.contains(index) ? 3 : 1) - ) - .frame(width: 42, height: 42) - .onTapGesture { - onTap?(index) - } + stickerCell(row: row, column: column, index: index) } } } } + .accessibilityElement(children: isInteractive ? .contain : .ignore) + .accessibilityLabel(isInteractive ? "Editable face grid" : "Face grid preview") + } + + private var isInteractive: Bool { + onTap != nil + } + + @ViewBuilder + private func stickerCell(row: Int, column: Int, index: Int) -> some View { + let cell = Rectangle() + .fill(swiftUIColor(for: grid[index])) + .overlay( + Rectangle() + .stroke( + highlightedIndices.contains(index) ? Color.red : Color.black.opacity(0.35), + lineWidth: highlightedIndices.contains(index) ? 3 : 1 + ) + ) + .frame(width: cellSide, height: cellSide) + + if let onTap { + Button { + onTap(index) + } label: { + cell + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .accessibilityLabel("Sticker row \(row + 1), column \(column + 1)") + .accessibilityValue("\(grid[index].rawValue) color") + .accessibilityHint("Sets this sticker to the selected color.") + .accessibilityIdentifier("cell_\(row)_\(column)") + } else { + cell + } } } struct FaceBadgeView: View { let face: FaceId let isComplete: Bool + var isCurrent: Bool = false var body: some View { - HStack(spacing: 6) { - Text(face.rawValue) - .font(.caption.monospaced().bold()) + HStack(spacing: 8) { Image(systemName: isComplete ? "checkmark.circle.fill" : "circle") .foregroundStyle(isComplete ? Color.green : Color.secondary) + Text(face.displayName) + .font(.caption.weight(.semibold)) + Text(face.rawValue) + .font(.caption2.monospaced().bold()) + .foregroundStyle(.secondary) + if isCurrent { + Text("Current") + .font(.caption2.weight(.bold)) + .foregroundStyle(.blue) + } } - .padding(.horizontal, 8) - .padding(.vertical, 6) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(minHeight: 44) .background(.thinMaterial, in: Capsule()) + .overlay( + Capsule() + .stroke(isCurrent ? Color.blue.opacity(0.45) : Color.clear, lineWidth: 1.5) + ) } } diff --git a/Sources/CubeUI/ScanSolveFlow/FaceConfirmView.swift b/Sources/CubeUI/ScanSolveFlow/FaceConfirmView.swift index a17a37f..6be750b 100644 --- a/Sources/CubeUI/ScanSolveFlow/FaceConfirmView.swift +++ b/Sources/CubeUI/ScanSolveFlow/FaceConfirmView.swift @@ -18,23 +18,34 @@ public struct FaceConfirmView: View { VStack(spacing: 20) { Text("Confirm \(face.id.displayName) Face") .font(.title3.bold()) + .accessibilityAddTraits(.isHeader) FaceGridView(grid: face.grid) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Captured \(face.id.displayName) face preview") + .accessibilityValue("Confidence \(Int(face.confidence * 100)) percent") + .accessibilityIdentifier("capturedFacePreview") Text("Confidence: \(Int(face.confidence * 100))%") .font(.caption) .foregroundStyle(.secondary) + .accessibilityLabel("Capture confidence") + .accessibilityValue("\(Int(face.confidence * 100)) percent") HStack(spacing: 12) { Button("Re-scan", systemImage: "arrow.counterclockwise") { onRescan() } .buttonStyle(.bordered) + .accessibilityHint("Discard this capture and scan this face again.") + .accessibilityIdentifier("rescanPendingFaceButton") Button("Looks Good", systemImage: "checkmark") { onConfirm() } .buttonStyle(.borderedProminent) + .accessibilityHint("Accept this face and continue.") + .accessibilityIdentifier("confirmPendingFaceButton") } } .frame(maxWidth: .infinity) diff --git a/Sources/CubeUI/ScanSolveFlow/LiveScanWizardContainerView.swift b/Sources/CubeUI/ScanSolveFlow/LiveScanWizardContainerView.swift index 4df79f0..0e3a14e 100644 --- a/Sources/CubeUI/ScanSolveFlow/LiveScanWizardContainerView.swift +++ b/Sources/CubeUI/ScanSolveFlow/LiveScanWizardContainerView.swift @@ -7,6 +7,7 @@ import CubeScanner /// Composition root for the live scan wizard using CameraSession + Vision detector. public struct LiveScanWizardContainerView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion @StateObject private var cameraSession: CameraSession @StateObject private var flowViewModel: CubeScanSolveFlowViewModel @State private var cameraError: String? @@ -73,14 +74,28 @@ public struct LiveScanWizardContainerView: View { cameraSession.stop() } .onAppear { - withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { - isStatusPulseOn = true - } + updatePulseAnimation() + } + .onChange(of: reduceMotion) { _, _ in + updatePulseAnimation() } } + + private func updatePulseAnimation() { + guard !reduceMotion else { + isStatusPulseOn = false + return + } + + isStatusPulseOn = false + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + isStatusPulseOn = true + } + } } private struct LiveCameraPreviewCard: View { + @Environment(\.colorSchemeContrast) private var colorSchemeContrast let cameraSession: CameraSession let isRunning: Bool let isBusy: Bool @@ -90,7 +105,7 @@ private struct LiveCameraPreviewCard: View { var body: some View { ZStack { LiveCameraPreviewView(cameraSession: cameraSession) - .overlay(Color.black.opacity(isRunning ? 0.14 : 0.28)) + .overlay(Color.black.opacity(cameraOverlayOpacity)) FaceTargetGridOverlay() .padding(20) @@ -107,13 +122,13 @@ private struct LiveCameraPreviewCard: View { .foregroundStyle(.white) .padding(.horizontal, 12) .padding(.vertical, 8) - .background(Color.black.opacity(0.5), in: Capsule()) + .background(Color.black.opacity(capsuleOverlayOpacity), in: Capsule()) Text("Center color: \(currentFace.expectedCenterColorName)") .font(.caption2.weight(.semibold)) .foregroundStyle(.white.opacity(0.92)) .padding(.horizontal, 10) .padding(.vertical, 6) - .background(Color.black.opacity(0.42), in: Capsule()) + .background(Color.black.opacity(capsuleOverlayOpacity - 0.08), in: Capsule()) } .padding(10) } @@ -127,6 +142,18 @@ private struct LiveCameraPreviewCard: View { .accessibilityElement(children: .contain) .accessibilityLabel("Live camera preview") .accessibilityHint("Align the target face inside the guide before scanning.") + .accessibilityIdentifier("liveCameraPreview") + } + + private var cameraOverlayOpacity: Double { + if colorSchemeContrast == .increased { + return isRunning ? 0.34 : 0.46 + } + return isRunning ? 0.2 : 0.32 + } + + private var capsuleOverlayOpacity: Double { + colorSchemeContrast == .increased ? 0.72 : 0.56 } } @@ -147,6 +174,7 @@ private struct LiveCameraPreviewView: UIViewRepresentable { } private struct LiveCameraStatusBanner: View { + @Environment(\.colorSchemeContrast) private var colorSchemeContrast let isRunning: Bool let isBusy: Bool let currentFace: FaceId @@ -162,20 +190,27 @@ private struct LiveCameraStatusBanner: View { VStack(alignment: .leading, spacing: 2) { Text(primaryText) .font(.caption.weight(.semibold)) + .foregroundStyle(.white) Text("Target face: \(currentFace.displayName) (\(currentFace.rawValue)) - \(currentFace.expectedCenterColorName)") .font(.caption2) - .foregroundStyle(.secondary) + .foregroundStyle(.white.opacity(colorSchemeContrast == .increased ? 1 : 0.9)) } Spacer() } .padding(.horizontal, 12) .padding(.vertical, 10) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .background( + Color.black.opacity(colorSchemeContrast == .increased ? 0.72 : 0.55), + in: RoundedRectangle(cornerRadius: 12) + ) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color.white.opacity(0.25), lineWidth: 1) ) + .accessibilityElement(children: .combine) + .accessibilityLabel(primaryText) + .accessibilityValue("Target \(currentFace.displayName) face, center color \(currentFace.expectedCenterColorName)") } private var primaryText: String { diff --git a/Sources/CubeUI/ScanSolveFlow/RotatingScanCubeView.swift b/Sources/CubeUI/ScanSolveFlow/RotatingScanCubeView.swift index 449e588..3c8fcce 100644 --- a/Sources/CubeUI/ScanSolveFlow/RotatingScanCubeView.swift +++ b/Sources/CubeUI/ScanSolveFlow/RotatingScanCubeView.swift @@ -4,6 +4,7 @@ import SwiftUI import CubeCore struct RotatingScanCubeView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion let targetFace: FaceId let scannedFaces: [FaceId: ScannedFaceData] let isScanning: Bool @@ -20,7 +21,10 @@ struct RotatingScanCubeView: View { let livePitch = clampedPitch(basePitch - (Double(dragInteraction.translation.height) * 0.010)) return TimelineView( - .animation(minimumInterval: 1.0 / 24.0, paused: !autoRotate || dragInteraction.isActive) + .animation( + minimumInterval: 1.0 / 24.0, + paused: !autoRotate || reduceMotion || dragInteraction.isActive + ) ) { timeline in Canvas { context, size in drawCube( @@ -43,7 +47,11 @@ struct RotatingScanCubeView: View { .accessibilityElement(children: .ignore) .accessibilityLabel("3D cube scan preview") .accessibilityValue(accessibilityValue) - .accessibilityHint("Drag to rotate the cube. Double tap to reset orientation.") + .accessibilityHint( + reduceMotion + ? "Drag to rotate the cube. Motion effects are reduced." + : "Drag to rotate the cube. Double tap to reset orientation." + ) } private func drawCube( @@ -199,7 +207,8 @@ struct RotatingScanCubeView: View { } private func clampedPitch(_ value: Double) -> Double { - min(max(value, -1.05), 0.28) + // Allow users to rotate far enough to inspect top/bottom guidance faces. + min(max(value, -1.12), 1.12) } private func normalizedAngle(_ value: Double) -> Double { diff --git a/Sources/CubeUI/ScanSolveFlow/ScanFaceGuidanceView.swift b/Sources/CubeUI/ScanSolveFlow/ScanFaceGuidanceView.swift index 9382f96..ecdd149 100644 --- a/Sources/CubeUI/ScanSolveFlow/ScanFaceGuidanceView.swift +++ b/Sources/CubeUI/ScanSolveFlow/ScanFaceGuidanceView.swift @@ -54,6 +54,7 @@ struct ScanFaceGuidanceView: View { .accessibilityElement(children: .combine) .accessibilityLabel("Scan guidance") .accessibilityValue("Target \(targetFace.displayName) face, center color \(targetFace.expectedCenterColorName).") + .accessibilityIdentifier("scanFaceGuidanceCard") } private var previousCompletedFace: FaceId? { diff --git a/Sources/CubeUI/ScanSolveFlow/ScanWizardView.swift b/Sources/CubeUI/ScanSolveFlow/ScanWizardView.swift index c1c97d0..e79f717 100644 --- a/Sources/CubeUI/ScanSolveFlow/ScanWizardView.swift +++ b/Sources/CubeUI/ScanSolveFlow/ScanWizardView.swift @@ -8,6 +8,7 @@ public struct ScanWizardView: View { private let cameraPreview: AnyView? @State private var showingManualEdit = false @State private var showingSteps = false + @State private var manualEditInitialFace: FaceId = .up public init(viewModel: CubeScanSolveFlowViewModel, cameraPreview: AnyView? = nil) { _viewModel = StateObject(wrappedValue: viewModel) @@ -20,15 +21,20 @@ public struct ScanWizardView: View { VStack(alignment: .leading, spacing: 18) { Text("Scan -> Validate -> Edit -> Solve") .font(.title2.bold()) + .accessibilityAddTraits(.isHeader) + .accessibilityIdentifier("scanWizardTitle") if let cameraPreview { cameraPreview + .accessibilityIdentifier("scanWizardCameraPreview") } ProgressView(value: Double(viewModel.scannedFaces.count), total: Double(viewModel.scanOrder.count)) { Text(viewModel.progressText) .font(.subheadline) } + .accessibilityValue("\(viewModel.progressText) captured") + .accessibilityIdentifier("scanWizardProgress") ScanFaceGuidanceView( targetFace: viewModel.currentFaceId, @@ -37,41 +43,7 @@ public struct ScanWizardView: View { isScanning: viewModel.isBusy ) - faceStatusRow - - if let pending = viewModel.pendingFace { - FaceConfirmView( - face: pending, - onConfirm: { viewModel.confirmPendingFace() }, - onRescan: { viewModel.rejectPendingFaceAndRescan() } - ) - } else { - VStack(alignment: .leading, spacing: 8) { - Text("Next face: \(viewModel.currentFaceId.displayName) (\(viewModel.currentFaceId.rawValue))") - .font(.headline) - Text("Keep the \(viewModel.currentFaceId.displayName.lowercased()) face centered, then tap scan.") - .font(.caption) - .foregroundStyle(.secondary) - - Button { - Task { - await viewModel.scanCurrentFace() - } - } label: { - Label( - viewModel.isBusy - ? "Scanning \(viewModel.currentFaceId.displayName)..." - : "Scan \(viewModel.currentFaceId.displayName) Face", - systemImage: viewModel.isBusy ? "camera.aperture" : "camera" - ) - } - .disabled(viewModel.isBusy) - .buttonStyle(.borderedProminent) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16)) - } + faceStatusSection if let validationError = viewModel.validationError { VStack(alignment: .leading, spacing: 6) { @@ -81,9 +53,10 @@ public struct ScanWizardView: View { .font(.caption) .foregroundStyle(.secondary) Button("Open Manual Edit") { - showingManualEdit = true + presentManualEdit(startingAt: viewModel.currentFaceId) } .buttonStyle(.bordered) + .accessibilityIdentifier("openManualEditButton") } .padding() .frame(maxWidth: .infinity, alignment: .leading) @@ -101,6 +74,7 @@ public struct ScanWizardView: View { } .disabled(viewModel.isBusy) .buttonStyle(.borderedProminent) + .accessibilityIdentifier("solveCubeButton") } if case .failed(let message) = viewModel.state { @@ -114,14 +88,20 @@ public struct ScanWizardView: View { showingSteps = true } .buttonStyle(.bordered) + .accessibilityIdentifier("viewStepByStepButton") } } .padding() } + .safeAreaInset(edge: .bottom, spacing: 0) { + captureActionPanel + } .navigationTitle("Cube Scanner") } - .sheet(isPresented: $showingManualEdit) { - CubeManualEditView(viewModel: viewModel) + .sheet(isPresented: $showingManualEdit, onDismiss: { + viewModel.resumeWizard() + }) { + CubeManualEditView(viewModel: viewModel, initialFace: manualEditInitialFace) } .navigationDestination(isPresented: $showingSteps) { SolveStepsView(viewModel: viewModel) @@ -133,22 +113,112 @@ public struct ScanWizardView: View { } } + private var faceStatusSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Captured Faces") + .font(.headline) + + Text("Tap any captured face to review or edit.") + .font(.caption) + .foregroundStyle(.secondary) + + faceStatusRow + } + } + private var faceStatusRow: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(viewModel.scanOrder, id: \.self) { face in - FaceBadgeView( - face: face, - isComplete: viewModel.scannedFaces[face] != nil + let isComplete = viewModel.scannedFaces[face] != nil + let isCurrent = face == viewModel.currentFaceId + + Button { + presentManualEdit(startingAt: face) + } label: { + FaceBadgeView( + face: face, + isComplete: isComplete, + isCurrent: isCurrent + ) + } + .buttonStyle(.plain) + .disabled(!isComplete) + .accessibilityLabel("\(face.displayName) face") + .accessibilityValue(isComplete ? "Captured" : "Not captured") + .accessibilityHint( + isComplete + ? "Opens manual edit for this face." + : "Capture this face to enable editing." ) - .onTapGesture { - if viewModel.scannedFaces[face] != nil { - showingManualEdit = true + .accessibilityIdentifier("scanFaceBadge_\(face.rawValue)") + } + } + } + .accessibilityIdentifier("scanFaceStatusRow") + } + + private var captureActionPanel: some View { + VStack(spacing: 10) { + if let pending = viewModel.pendingFace { + FaceConfirmView( + face: pending, + onConfirm: { viewModel.confirmPendingFace() }, + onRescan: { viewModel.rejectPendingFaceAndRescan() } + ) + } else if viewModel.scannedFaces.count == viewModel.scanOrder.count { + VStack(alignment: .leading, spacing: 4) { + Text("All faces captured.") + .font(.headline) + Text("Review captured faces above or continue to solve.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16)) + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Next face: \(viewModel.currentFaceId.displayName) (\(viewModel.currentFaceId.rawValue))") + .font(.headline) + Text("Keep the \(viewModel.currentFaceId.displayName.lowercased()) face centered, then tap scan.") + .font(.caption) + .foregroundStyle(.secondary) + + Button { + Task { + await viewModel.scanCurrentFace() } + } label: { + Label( + viewModel.isBusy + ? "Scanning \(viewModel.currentFaceId.displayName)..." + : "Scan \(viewModel.currentFaceId.displayName) Face", + systemImage: viewModel.isBusy ? "camera.aperture" : "camera" + ) } + .disabled(viewModel.isBusy) + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("scanCurrentFaceButton") } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16)) } } + .padding(.horizontal) + .padding(.top, 10) + .padding(.bottom, 8) + .background(.ultraThinMaterial) + .overlay(alignment: .top) { + Divider() + } + } + + private func presentManualEdit(startingAt face: FaceId?) { + manualEditInitialFace = face ?? viewModel.currentFaceId + viewModel.openManualEdit() + showingManualEdit = true } } diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index 270c2ad..7dd0ff5 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -270,6 +270,22 @@ For automated testing, all elements have accessibility identifiers: - `resetButton` - `solutionStepsView` +**Scan Wizard Screen** +- `scanWizardTitle` +- `scanWizardCameraPreview` +- `liveCameraPreview` +- `scanWizardProgress` +- `scanFaceGuidanceCard` +- `scanFaceStatusRow` +- `scanFaceBadge_[face]` (e.g., `scanFaceBadge_F`) +- `scanCurrentFaceButton` +- `capturedFacePreview` +- `confirmPendingFaceButton` +- `rescanPendingFaceButton` +- `openManualEditButton` +- `solveCubeButton` +- `viewStepByStepButton` + **Manual Input Screen** - `manualInputTitle` - `faceSelector` From ca3bce2c09e931b11411e492c736f3d841b4cd20 Mon Sep 17 00:00:00 2001 From: Mark Coleman Date: Sun, 22 Feb 2026 15:25:12 -0500 Subject: [PATCH 4/4] fixes --- .../ScanSolveFlow/CubeStateValidator.swift | 54 ++++++++++-------- .../CubeStateValidatorEngineTests.swift | 57 ++++++++++++++++++- 2 files changed, 86 insertions(+), 25 deletions(-) diff --git a/Sources/CubeCore/ScanSolveFlow/CubeStateValidator.swift b/Sources/CubeCore/ScanSolveFlow/CubeStateValidator.swift index 8c9922b..e156884 100644 --- a/Sources/CubeCore/ScanSolveFlow/CubeStateValidator.swift +++ b/Sources/CubeCore/ScanSolveFlow/CubeStateValidator.swift @@ -39,7 +39,13 @@ public protocol CubeStateValidating: Sendable { } public struct CubeStateValidator: CubeStateValidating { - public init() {} + /// Enables orientation/parity checks that may reject states when face orientation metadata is ambiguous. + /// Keep this off for user-entered or scanned states to avoid false negatives. + public let strictPhysicalChecks: Bool + + public init(strictPhysicalChecks: Bool = false) { + self.strictPhysicalChecks = strictPhysicalChecks + } public func validate(state: CubeState) -> Result { if let failure = validateFaceConfiguration(state) { @@ -167,30 +173,32 @@ public struct CubeStateValidator: CubeStateValidating { return error } - guard edgeValidation.orientationSum % 2 == 0 else { - return ValidationError( - type: .invalidEdgeOrientation, - message: "Edge orientation is impossible for a physical 3x3 cube.", - suggestedFix: "A flipped edge is likely mis-scanned. Re-scan front/back adjacent edges." - ) - } + if strictPhysicalChecks { + guard edgeValidation.orientationSum % 2 == 0 else { + return ValidationError( + type: .invalidEdgeOrientation, + message: "Edge orientation is impossible for a physical 3x3 cube.", + suggestedFix: "A flipped edge is likely mis-scanned. Re-scan front/back adjacent edges." + ) + } - guard cornerValidation.orientationSum % 3 == 0 else { - return ValidationError( - type: .invalidCornerOrientation, - message: "Corner orientation is impossible for a physical 3x3 cube.", - suggestedFix: "A twisted corner is likely mis-scanned. Re-scan top-layer corners or edit manually." - ) - } + guard cornerValidation.orientationSum % 3 == 0 else { + return ValidationError( + type: .invalidCornerOrientation, + message: "Corner orientation is impossible for a physical 3x3 cube.", + suggestedFix: "A twisted corner is likely mis-scanned. Re-scan top-layer corners or edit manually." + ) + } - let edgeParity = parity(of: edgeValidation.permutation) - let cornerParity = parity(of: cornerValidation.permutation) - guard edgeParity == cornerParity else { - return ValidationError( - type: .impossibleParity, - message: "Piece permutation parity is impossible on a real cube.", - suggestedFix: "At least one piece is incorrect. Re-scan the most uncertain face and validate again." - ) + let edgeParity = parity(of: edgeValidation.permutation) + let cornerParity = parity(of: cornerValidation.permutation) + guard edgeParity == cornerParity else { + return ValidationError( + type: .impossibleParity, + message: "Piece permutation parity is impossible on a real cube.", + suggestedFix: "At least one piece is incorrect. Re-scan the most uncertain face and validate again." + ) + } } return nil diff --git a/Tests/CubeCoreTests/CubeStateValidatorEngineTests.swift b/Tests/CubeCoreTests/CubeStateValidatorEngineTests.swift index b567ace..8b84126 100644 --- a/Tests/CubeCoreTests/CubeStateValidatorEngineTests.swift +++ b/Tests/CubeCoreTests/CubeStateValidatorEngineTests.swift @@ -3,6 +3,7 @@ import XCTest final class CubeStateValidatorEngineTests: XCTestCase { private let validator = CubeStateValidator() + private let strictValidator = CubeStateValidator(strictPhysicalChecks: true) func testSolvedCubePassesValidation() { let result = validator.validate(state: CubeState()) @@ -35,7 +36,7 @@ final class CubeStateValidatorEngineTests: XCTestCase { // Swap two corners to force odd corner parity while keeping color counts valid. swapCorner(&state, (.up, 8), (.right, 0), (.front, 2), with: (.up, 6), (.front, 0), (.left, 2)) - let result = validator.validate(state: state) + let result = strictValidator.validate(state: state) guard case let .failure(error) = result else { return XCTFail("Expected validation failure") @@ -50,7 +51,7 @@ final class CubeStateValidatorEngineTests: XCTestCase { // Swap UR and UF edge pieces as whole pieces. swapEdge(&state, first: (.up, 5), (.right, 1), second: (.up, 7), (.front, 1)) - let result = validator.validate(state: state) + let result = strictValidator.validate(state: state) guard case let .failure(error) = result else { return XCTFail("Expected validation failure") @@ -59,6 +60,58 @@ final class CubeStateValidatorEngineTests: XCTestCase { XCTAssertEqual(error.type, .impossibleParity) } + func testRandomReachableStatesPassValidation() { + let allMoves = Turn.allCases.flatMap { turn in + [ + Move(turn: turn, amount: .clockwise), + Move(turn: turn, amount: .counter), + Move(turn: turn, amount: .double) + ] + } + + var generator = LCG(seed: 0xC0FFEE) + + for scrambleIndex in 0..<200 { + var state = CubeState() + let moveCount = 15 + Int(generator.next() % 30) + var lastTurn: Turn? + var moves: [Move] = [] + + for _ in 0.. UInt64 { + state = 6364136223846793005 &* state &+ 1442695040888963407 + return state + } + } + private func swapEdge( _ state: inout CubeState, first firstA: (Face, Int), _ firstB: (Face, Int),