diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c6a8357 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Tests (macOS) + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Toolchain + run: | + xcodebuild -version + swift --version + - name: Run tests + run: swift test + env: + MUSCLEMAP_SNAPSHOT_FAILURES: ${{ runner.temp }}/snapshot-failures + - name: Upload failed snapshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: failed-snapshots + path: ${{ runner.temp }}/snapshot-failures + if-no-files-found: ignore + + ios: + name: iOS build + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Build library + run: xcodebuild -scheme MuscleMap -destination 'generic/platform=iOS Simulator' build + - name: Build demo app + run: | + xcodebuild -project Example/MuscleMapDemoApp.xcodeproj -scheme MuscleMapDemoApp \ + -destination 'generic/platform=iOS Simulator' build + + podspec: + name: CocoaPods lint + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Lint podspec + run: pod lib lint MuscleMap.podspec --allow-warnings diff --git a/.gitignore b/.gitignore index 546453e..ddecbff 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ DerivedData/ .swiftpm/xcode/xcuserdata/ .netrc TODO.md +Tools/ScreenshotGenerator/.build diff --git a/Example/MuscleMapDemoApp/ContentView.swift b/Example/MuscleMapDemoApp/ContentView.swift index dbec853..d9e0b32 100644 --- a/Example/MuscleMapDemoApp/ContentView.swift +++ b/Example/MuscleMapDemoApp/ContentView.swift @@ -662,10 +662,49 @@ struct SubGroupsDemo: View { } .padding(.horizontal) + Text("Back Regions") + .font(.headline) + + Text("Upper and lower trapezius, rhomboids, rear deltoid and rotator cuff.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + HStack(spacing: 16) { + ForEach(BodyGender.allCases, id: \.self) { gender in + BodyView(gender: gender, side: .back) + .showSubGroups() + .highlight(.upperTrapezius, color: .orange) + .highlight(.rhomboids, color: .red) + .highlight(.lowerTrapezius, color: .purple) + .highlight(.rearDeltoid, color: .blue) + .highlight(.rotatorCuff, color: .teal) + .selected(selectedMuscle) + .onMuscleSelected { muscle, _ in + selectedMuscle = muscle + } + .frame(height: 350) + } + } + .padding(.horizontal) + + HStack(spacing: 12) { + ForEach([(Muscle.upperTrapezius, Color.orange), (.rhomboids, .red), (.lowerTrapezius, .purple), (.rearDeltoid, .blue), (.rotatorCuff, .teal)], id: \.0) { muscle, color in + HStack(spacing: 4) { + Circle().fill(color).frame(width: 8, height: 8) + Text(muscle.displayName).font(.caption2) + } + } + } + .padding(.horizontal) + if let muscle = selectedMuscle { VStack(spacing: 4) { Text(muscle.displayName) .font(.title3.bold()) + Text("Drawn on: \(drawnViews(of: muscle))") + .font(.caption) + .foregroundStyle(.secondary) if muscle.isSubGroup, let parent = muscle.parentGroup { Text("Sub-group of \(parent.displayName)") .font(.caption) @@ -684,6 +723,13 @@ struct SubGroupsDemo: View { .navigationTitle("Sub-Groups") } } + + private func drawnViews(of muscle: Muscle) -> String { + BodySide.allCases + .filter { side in BodyGender.allCases.contains { muscle.isDrawable(gender: $0, side: side) } } + .map(\.displayName) + .joined(separator: ", ") + } } #Preview { diff --git a/Examples/MuscleMapDemo/ContentView.swift b/Examples/MuscleMapDemo/ContentView.swift deleted file mode 100644 index 9dc148f..0000000 --- a/Examples/MuscleMapDemo/ContentView.swift +++ /dev/null @@ -1,697 +0,0 @@ -import SwiftUI -import MuscleMap - -struct ContentView: View { - @State private var selectedTab = 0 - - var body: some View { - TabView(selection: $selectedTab) { - HighlightDemo() - .tabItem { Label("Highlight", systemImage: "figure.stand") } - .tag(0) - - HeatmapDemo() - .tabItem { Label("Heatmap", systemImage: "flame") } - .tag(1) - - InteractiveDemo() - .tabItem { Label("Interactive", systemImage: "hand.tap") } - .tag(2) - - StyleDemo() - .tabItem { Label("Styles", systemImage: "paintbrush") } - .tag(3) - - GradientDemo() - .tabItem { Label("Gradient", systemImage: "paintpalette") } - .tag(4) - - AnimationDemo() - .tabItem { Label("Animation", systemImage: "wand.and.stars") } - .tag(5) - - InteractiveV2Demo() - .tabItem { Label("Interactive V2", systemImage: "hand.draw") } - .tag(6) - - HeatmapV2Demo() - .tabItem { Label("Heatmap V2", systemImage: "chart.bar.fill") } - .tag(7) - - SubGroupsDemo() - .tabItem { Label("Sub-Groups", systemImage: "rectangle.split.3x3") } - .tag(8) - } - } -} - -// MARK: - Highlight Demo - -struct HighlightDemo: View { - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 24) { - Text("Male - Front & Back") - .font(.headline) - - HStack(spacing: 16) { - BodyView(gender: .male, side: .front) - .highlight(.chest, color: .red) - .highlight(.biceps, color: .orange, opacity: 0.8) - .highlight(.abs, color: .yellow, opacity: 0.6) - .highlight(.quadriceps, color: .red) - .highlight(.deltoids, color: .orange) - .frame(height: 350) - - BodyView(gender: .male, side: .back) - .highlight(.trapezius, color: .orange) - .highlight(.upperBack, color: .red) - .highlight(.lowerBack, color: .yellow) - .highlight(.hamstring, color: .red) - .highlight(.gluteal, color: .orange) - .frame(height: 350) - } - .padding(.horizontal) - - Text("Female - Front & Back") - .font(.headline) - - HStack(spacing: 16) { - BodyView(gender: .female, side: .front) - .highlight(.chest, color: .pink) - .highlight(.abs, color: .orange) - .highlight(.quadriceps, color: .red) - .highlight(.calves, color: .yellow) - .frame(height: 350) - - BodyView(gender: .female, side: .back) - .highlight(.gluteal, color: .pink) - .highlight(.hamstring, color: .red) - .highlight(.upperBack, color: .orange) - .frame(height: 350) - } - .padding(.horizontal) - } - .padding(.vertical) - } - .navigationTitle("Highlight") - } - } -} - -// MARK: - Heatmap Demo - -struct HeatmapDemo: View { - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 24) { - Text("Workout Intensity (0-4)") - .font(.headline) - - BodyView(gender: .male, side: .front) - .intensities([ - .chest: 4, - .biceps: 3, - .abs: 2, - .quadriceps: 4, - .deltoids: 3, - .forearm: 1, - .obliques: 2, - .triceps: 2 - ]) - .frame(height: 400) - .padding(.horizontal) - - Text("Thermal Scale") - .font(.headline) - - BodyView(gender: .male, side: .back) - .intensities([ - .trapezius: 3, - .upperBack: 4, - .lowerBack: 2, - .hamstring: 3, - .gluteal: 4, - .calves: 1, - .triceps: 2 - ], colorScale: .thermal) - .frame(height: 400) - .padding(.horizontal) - - Text("Medical Scale") - .font(.headline) - - let data: [MuscleIntensity] = [ - .init(muscle: .chest, intensity: 0.9), - .init(muscle: .deltoids, intensity: 0.7), - .init(muscle: .biceps, intensity: 0.5), - .init(muscle: .abs, intensity: 0.3), - .init(muscle: .quadriceps, intensity: 0.6) - ] - - BodyView(gender: .female, side: .front) - .heatmap(data, colorScale: .medical) - .frame(height: 400) - .padding(.horizontal) - } - .padding(.vertical) - } - .navigationTitle("Heatmap") - } - } -} - -// MARK: - Interactive Demo - -struct InteractiveDemo: View { - @State private var selectedMuscle: Muscle? - @State private var selectedSide: MuscleSide = .both - @State private var gender: BodyGender = .male - @State private var bodySide: BodySide = .front - - var body: some View { - NavigationStack { - VStack(spacing: 16) { - HStack { - Picker("Gender", selection: $gender) { - Text("Male").tag(BodyGender.male) - Text("Female").tag(BodyGender.female) - } - .pickerStyle(.segmented) - - Picker("Side", selection: $bodySide) { - Text("Front").tag(BodySide.front) - Text("Back").tag(BodySide.back) - } - .pickerStyle(.segmented) - } - .padding(.horizontal) - - if let muscle = selectedMuscle { - HStack { - Image(systemName: "figure.stand") - Text("\(muscle.displayName) (\(selectedSide.rawValue))") - .font(.title3.bold()) - } - .padding(8) - .background(.green.opacity(0.15), in: RoundedRectangle(cornerRadius: 8)) - } else { - Text("Tap a muscle") - .foregroundStyle(.secondary) - } - - BodyView(gender: gender, side: bodySide) - .selected(selectedMuscle) - .pulseSelected() - .onMuscleSelected { muscle, side in - selectedMuscle = muscle - selectedSide = side - } - .frame(maxHeight: .infinity) - .padding(.horizontal) - } - .padding(.vertical) - .navigationTitle("Interactive") - } - } -} - -// MARK: - Style Demo - -struct StyleDemo: View { - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 24) { - styleCard("Default", style: .default) - styleCard("Minimal", style: .minimal) - styleCard("Neon", style: .neon, background: .black) - styleCard("Medical", style: .medical) - } - .padding() - } - .navigationTitle("Styles") - } - } - - @ViewBuilder - func styleCard(_ name: String, style: BodyViewStyle, background: Color = .clear) -> some View { - VStack { - Text(name) - .font(.headline) - - BodyView(gender: .male, side: .front) - .highlight(.chest, color: .red) - .highlight(.biceps, color: .orange) - .highlight(.quadriceps, color: .red) - .bodyStyle(style) - .frame(height: 300) - .padding() - .background(background, in: RoundedRectangle(cornerRadius: 12)) - } - } -} - -// MARK: - Gradient Demo - -struct GradientDemo: View { - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 24) { - Text("Linear Gradient") - .font(.headline) - - BodyView(gender: .male, side: .front) - .highlight(.chest, linearGradient: [.red, .orange], startPoint: .top, endPoint: .bottom) - .highlight(.biceps, linearGradient: [.blue, .cyan], startPoint: .leading, endPoint: .trailing) - .highlight(.abs, linearGradient: [.yellow, .orange, .red], startPoint: .top, endPoint: .bottom) - .highlight(.quadriceps, linearGradient: [.purple, .pink]) - .frame(height: 400) - .padding(.horizontal) - - Text("Radial Gradient") - .font(.headline) - - BodyView(gender: .male, side: .front) - .highlight(.chest, radialGradient: [.white, .red], center: .center, endRadius: 40) - .highlight(.biceps, radialGradient: [.white, .blue], center: .center, endRadius: 30) - .highlight(.deltoids, radialGradient: [.yellow, .orange], center: .center, endRadius: 25) - .frame(height: 400) - .padding(.horizontal) - - Text("Mixed Fills") - .font(.headline) - - BodyView(gender: .female, side: .front) - .highlight(.chest, linearGradient: [.pink, .red]) - .highlight(.abs, color: .orange, opacity: 0.7) - .highlight(.quadriceps, radialGradient: [.white, .purple], center: .center, endRadius: 50) - .frame(height: 400) - .padding(.horizontal) - } - .padding(.vertical) - } - .navigationTitle("Gradient") - } - } -} - -// MARK: - Animation Demo - -struct AnimationDemo: View { - @State private var showUpperBody = true - @State private var selectedMuscle: Muscle? - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 24) { - Text("Animated Transitions") - .font(.headline) - - Button(showUpperBody ? "Show Lower Body" : "Show Upper Body") { - showUpperBody.toggle() - } - .buttonStyle(.borderedProminent) - - animatedBodyView - .frame(height: 400) - .padding(.horizontal) - - Text("Pulse Animation") - .font(.headline) - - Text("Tap a muscle to see pulse effect") - .font(.caption) - .foregroundStyle(.secondary) - - BodyView(gender: .male, side: .front) - .highlight(.chest, linearGradient: [.red, .orange]) - .highlight(.biceps, color: .blue) - .highlight(.quadriceps, color: .purple) - .selected(selectedMuscle) - .pulseSelected(speed: 1.5, range: 0.6...1.0) - .onMuscleSelected { muscle, _ in - selectedMuscle = muscle - } - .frame(height: 400) - .padding(.horizontal) - - Text("Shadow + Neon Style") - .font(.headline) - - BodyView(gender: .male, side: .front) - .highlight(.chest, linearGradient: [.cyan, .blue]) - .highlight(.biceps, color: .cyan) - .highlight(.quadriceps, linearGradient: [.cyan, .teal]) - .bodyStyle(.neon) - .frame(height: 400) - .padding(.horizontal) - .background(.black, in: RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal) - } - .padding(.vertical) - } - .navigationTitle("Animation") - } - } - - @ViewBuilder - private var animatedBodyView: some View { - if showUpperBody { - BodyView(gender: .male, side: .front) - .highlight(.chest, color: .red) - .highlight(.biceps, color: .orange) - .highlight(.deltoids, color: .yellow) - .highlight(.abs, color: .red, opacity: 0.6) - .animated(duration: 0.5) - } else { - BodyView(gender: .male, side: .front) - .highlight(.quadriceps, color: .blue) - .highlight(.calves, color: .cyan) - .highlight(.hamstring, color: .purple) - .animated(duration: 0.5) - } - } -} - -// MARK: - Interactive V2 Demo - -struct InteractiveV2Demo: View { - @State private var selectedMuscles: Set = [] - @State private var lastSide: MuscleSide = .both - @State private var longPressedMuscle: String = "" - @State private var history = SelectionHistory() - - var body: some View { - NavigationStack { - VStack(spacing: 12) { - // Status bar - HStack { - if selectedMuscles.isEmpty { - Text("Tap to select, long press for info, drag to paint") - .font(.caption) - .foregroundStyle(.secondary) - } else { - Text(selectedMuscles.map(\.displayName).sorted().joined(separator: ", ")) - .font(.caption.bold()) - .lineLimit(2) - } - Spacer() - Button("Undo") { - if let state = history.undo() { - selectedMuscles = state - } - } - .disabled(!history.canUndo) - - Button("Redo") { - if let state = history.redo() { - selectedMuscles = state - } - } - .disabled(!history.canRedo) - } - .padding(.horizontal) - - if !longPressedMuscle.isEmpty { - Text(longPressedMuscle) - .font(.footnote) - .padding(6) - .background(.blue.opacity(0.15), in: RoundedRectangle(cornerRadius: 6)) - .transition(.opacity) - } - - // Body view with all interactive features - BodyView(gender: .male, side: .front) - .highlight(Array(selectedMuscles), color: .orange) - .selected(selectedMuscles) - .pulseSelected(speed: 1.5, range: 0.7...1.0) - .onMuscleSelected { muscle, side in - var newSelection = selectedMuscles - if newSelection.contains(muscle) { - newSelection.remove(muscle) - } else { - newSelection.insert(muscle) - } - selectedMuscles = newSelection - lastSide = side - history.push(newSelection) - } - .onMuscleLongPressed(duration: 0.5) { muscle, side in - withAnimation { - longPressedMuscle = "Long pressed: \(muscle.displayName) (\(side.rawValue))" - } - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - withAnimation { longPressedMuscle = "" } - } - } - .onMuscleDragged({ muscle, side in - if !selectedMuscles.contains(muscle) { - selectedMuscles.insert(muscle) - history.push(selectedMuscles) - } - }, onEnded: {}) - .zoomable(minScale: 1.0, maxScale: 3.0) - .tooltip { muscle, _ in - Text(muscle.displayName) - .font(.caption2.bold()) - .padding(4) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 4)) - } - .undoable(history) - .frame(maxHeight: .infinity) - .padding(.horizontal) - - Button("Clear Selection") { - selectedMuscles.removeAll() - history.push(selectedMuscles) - } - .buttonStyle(.bordered) - .disabled(selectedMuscles.isEmpty) - } - .padding(.vertical) - .navigationTitle("Interactive V2") - } - } -} - -// MARK: - Heatmap V2 Demo - -struct HeatmapV2Demo: View { - @State private var threshold: Double = 0.0 - @State private var useGradientFill = false - @State private var showAnimated = false - @State private var interpolation: InterpolationChoice = .linear - - enum InterpolationChoice: String, CaseIterable { - case linear = "Linear" - case easeIn = "Ease In" - case easeOut = "Ease Out" - case easeInOut = "Ease In-Out" - case stepped = "Stepped" - - var value: ColorInterpolation { - switch self { - case .linear: return .linear - case .easeIn: return .easeIn - case .easeOut: return .easeOut - case .easeInOut: return .easeInOut - case .stepped: return .step(count: 5) - } - } - } - - private var upperBodyData: [MuscleIntensity] { - [ - .init(muscle: .chest, intensity: 0.9), - .init(muscle: .deltoids, intensity: 0.7), - .init(muscle: .biceps, intensity: 0.5), - .init(muscle: .abs, intensity: 0.3), - .init(muscle: .obliques, intensity: 0.15), - .init(muscle: .forearm, intensity: 0.4), - .init(muscle: .quadriceps, intensity: 0.6), - .init(muscle: .triceps, intensity: 0.2) - ] - } - - private var lowerBodyData: [MuscleIntensity] { - [ - .init(muscle: .quadriceps, intensity: 0.9), - .init(muscle: .calves, intensity: 0.7), - .init(muscle: .abs, intensity: 0.2), - .init(muscle: .chest, intensity: 0.1), - .init(muscle: .obliques, intensity: 0.4), - .init(muscle: .adductors, intensity: 0.6) - ] - } - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 20) { - // Controls - GroupBox("Settings") { - VStack(spacing: 12) { - Picker("Interpolation", selection: $interpolation) { - ForEach(InterpolationChoice.allCases, id: \.self) { choice in - Text(choice.rawValue).tag(choice) - } - } - .pickerStyle(.segmented) - - HStack { - Text("Threshold: \(threshold, specifier: "%.2f")") - Slider(value: $threshold, in: 0...0.5, step: 0.05) - } - - Toggle("Gradient Fill", isOn: $useGradientFill) - } - } - .padding(.horizontal) - - // Animated heatmap toggle - Button(showAnimated ? "Show Upper Body" : "Show Lower Body") { - showAnimated.toggle() - } - .buttonStyle(.borderedProminent) - - // Body view with heatmap config - HStack(alignment: .top, spacing: 8) { - bodyView - .frame(height: 420) - - HeatmapLegendView( - colorScale: .thermal, - interpolation: interpolation.value, - orientation: .vertical, - barThickness: 14, - labelMin: "Rest", - labelMax: "Max" - ) - .frame(width: 50, height: 200) - .padding(.top, 100) - } - .padding(.horizontal) - - // Horizontal legend - HeatmapLegendView( - colorScale: .thermal, - interpolation: interpolation.value, - labelMin: "Low", - labelMax: "High" - ) - .frame(width: 250) - .padding(.horizontal) - - // Stepped preset demo - GroupBox("Stepped Preset") { - BodyView(gender: .female, side: .front) - .heatmap(upperBodyData, colorScale: .workoutStepped) - .frame(height: 350) - } - .padding(.horizontal) - } - .padding(.vertical) - } - .navigationTitle("Heatmap V2") - } - } - - @ViewBuilder - private var bodyView: some View { - let data = showAnimated ? lowerBodyData : upperBodyData - let config = HeatmapConfiguration( - colorScale: .thermal, - interpolation: interpolation.value, - threshold: threshold > 0 ? threshold : nil, - isGradientFillEnabled: useGradientFill, - gradientDirection: .topToBottom, - gradientLowIntensityFactor: 0.3 - ) - - BodyView(gender: .male, side: .front) - .heatmap(data, configuration: config) - .animated(duration: 0.5) - } -} - -// MARK: - Sub-Groups Demo - -struct SubGroupsDemo: View { - @State private var selectedMuscle: Muscle? - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 24) { - Text("Muscle Sub-Groups") - .font(.headline) - - Text("Sub-groups inherit parent highlight. Tap to see sub-group priority.") - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - HStack(spacing: 16) { - BodyView(gender: .male, side: .front) - .highlight(.chest, color: .red, opacity: 0.4) - .highlight(.upperChest, color: .red, opacity: 0.9) - .highlight(.quadriceps, color: .blue, opacity: 0.4) - .highlight(.innerQuad, color: .blue, opacity: 0.9) - .highlight(.abs, color: .orange, opacity: 0.4) - .highlight(.upperAbs, color: .orange, opacity: 0.9) - .highlight(.deltoids, color: .purple, opacity: 0.4) - .highlight(.frontDeltoid, color: .purple, opacity: 0.9) - .selected(selectedMuscle) - .onMuscleSelected { muscle, _ in - selectedMuscle = muscle - } - .frame(height: 350) - - BodyView(gender: .male, side: .back) - .highlight(.trapezius, color: .green, opacity: 0.4) - .highlight(.upperTrapezius, color: .green, opacity: 0.9) - .highlight(.lowerTrapezius, color: .teal, opacity: 0.9) - .highlight(.deltoids, color: .purple, opacity: 0.4) - .highlight(.rearDeltoid, color: .purple, opacity: 0.9) - .highlight(.rhomboids, color: .orange) - .highlight(.rotatorCuff, color: .cyan) - .selected(selectedMuscle) - .onMuscleSelected { muscle, _ in - selectedMuscle = muscle - } - .frame(height: 350) - } - .padding(.horizontal) - - if let muscle = selectedMuscle { - VStack(spacing: 4) { - Text(muscle.displayName) - .font(.title3.bold()) - if muscle.isSubGroup, let parent = muscle.parentGroup { - Text("Sub-group of \(parent.displayName)") - .font(.caption) - .foregroundStyle(.secondary) - } - if !muscle.subGroups.isEmpty { - Text("Sub-groups: \(muscle.subGroups.map(\.displayName).joined(separator: ", "))") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - .padding(.vertical) - } - .navigationTitle("Sub-Groups") - } - } -} - -#Preview { - ContentView() -} diff --git a/Examples/MuscleMapDemo/MuscleMapDemoApp.swift b/Examples/MuscleMapDemo/MuscleMapDemoApp.swift deleted file mode 100644 index b6ea7ba..0000000 --- a/Examples/MuscleMapDemo/MuscleMapDemoApp.swift +++ /dev/null @@ -1,10 +0,0 @@ -import SwiftUI - -@main -struct MuscleMapDemoApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/MuscleMap.podspec b/MuscleMap.podspec index 30823b2..8317994 100644 --- a/MuscleMap.podspec +++ b/MuscleMap.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "MuscleMap" - s.version = "1.7.1" + s.version = "1.8.0" s.summary = "A SwiftUI SDK for rendering interactive human body muscle maps." s.description = <<-DESC MuscleMap provides a declarative way to display a human body and visualize @@ -16,7 +16,7 @@ Pod::Spec.new do |s| s.ios.deployment_target = "17.0" s.osx.deployment_target = "14.0" - s.swift_version = "5.9" + s.swift_versions = ["5.9", "6.0"] s.source = { :git => "https://github.com/melihcolpan/MuscleMap.git", :tag => s.version.to_s } s.source_files = "Sources/MuscleMap/**/*.swift" diff --git a/Package.swift b/Package.swift index dfdf9dd..080c153 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,8 @@ let package = Package( .testTarget( name: "MuscleMapTests", dependencies: ["MuscleMap"], - path: "Tests/MuscleMapTests" + path: "Tests/MuscleMapTests", + exclude: ["__Snapshots__"] ), ] ) diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift new file mode 100644 index 0000000..8d735db --- /dev/null +++ b/Package@swift-6.0.swift @@ -0,0 +1,41 @@ +// swift-tools-version: 6.0 + +// +// Package.swift +// MuscleMap +// +// Created by Melih Colpan on 2026-02-09. +// Copyright © 2026 Melih Colpan. All rights reserved. +// Licensed under the MIT License. +// + +import PackageDescription + +let package = Package( + name: "MuscleMap", + defaultLocalization: "en", + platforms: [ + .iOS(.v17), + .macOS(.v14) + ], + products: [ + .library( + name: "MuscleMap", + targets: ["MuscleMap"] + ), + ], + targets: [ + .target( + name: "MuscleMap", + path: "Sources/MuscleMap", + resources: [.process("Resources")] + ), + .testTarget( + name: "MuscleMapTests", + dependencies: ["MuscleMap"], + path: "Tests/MuscleMapTests", + exclude: ["__Snapshots__"] + ), + ], + swiftLanguageModes: [.v6] +) diff --git a/README.md b/README.md index aa16d82..5c3a6ae 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Add to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/melihcolpan/MuscleMap.git", from: "1.7.1") + .package(url: "https://github.com/melihcolpan/MuscleMap.git", from: "1.8.0") ] ``` @@ -56,7 +56,7 @@ Or in Xcode: **File > Add Package Dependencies** and paste the repository URL. Add to your `Podfile`: ```ruby -pod 'MuscleMap', '~> 1.7.1' +pod 'MuscleMap', '~> 1.8.0' ``` Then run `pod install`. @@ -162,6 +162,10 @@ let custom = HeatmapColorScale(colors: [.blue, .purple, .pink]) ### Color Interpolation +

+ Stepped Heatmap +

+ Control how intensity values map to colors across the scale: ```swift @@ -443,18 +447,41 @@ BodyView(gender: .female, side: .back) // Female back ### Checking What Is Drawn -Not every muscle has artwork in every view. Use `isDrawable` to check a muscle mapping, for example in a unit test: +

+ Back Regions +

+ +Every muscle is drawn in at least one view, but many only on the front or the back. Use `isDrawable` to check a muscle mapping for a specific view, for example in a unit test: ```swift Muscle.rhomboids.isDrawable // true (drawn somewhere) Muscle.rhomboids.isDrawable(gender: .male, side: .back) // true Muscle.rhomboids.isDrawable(gender: .male, side: .front) // false - -Muscle.allCases.filter { !$0.isDrawable } -// [.rearDeltoid, .upperTrapezius, .lowerTrapezius] +Muscle.head.isDrawable(gender: .female, side: .back) // false, the hair covers it ``` -`.rhomboids` and `.rotatorCuff` are drawn on the back views. The rhomboids sit inside the trapezius and are only painted while highlighted or selected, so an unhighlighted body and a `.trapezius` highlight look exactly as before. The rotator cuff is the infraspinatus area above the shoulder blade; it shows the `.upperBack` highlight when it has none of its own. With sub-groups hidden (the default), tapping either returns `.trapezius` / `.upperBack`. +The back views have these extra regions (shown above with `.showSubGroups()`): + +- `.upperTrapezius` and `.lowerTrapezius`: the trapezius above and below the rhomboids. On the front views the visible trapezius is the upper part. +- `.rhomboids`: inside the trapezius, painted only while highlighted or selected, so an unhighlighted body and a `.trapezius` highlight look exactly as before. +- `.rearDeltoid`: the deltoid as seen from behind. +- `.rotatorCuff`: the infraspinatus area above the shoulder blade. It shows the `.upperBack` highlight when it has none of its own. + +With sub-groups hidden (the default), tapping the rhomboids or the rotator cuff returns `.trapezius` / `.upperBack`. + +### Faster First Render + +Creating a `BodyView` starts parsing the body artwork in the background. To have it ready before the first body appears, start it at launch: + +```swift +@main +struct MyApp: App { + init() { + BodyView.preloadArtwork() + } + // ... +} +``` ## Available Muscles @@ -504,7 +531,7 @@ Muscle.allCases.filter { !$0.isDrawable } | Adductors | `.adductors` | `.hamstring` | Yes | | Neck | `.neck` | `.head` | Yes | -`.rearDeltoid`, `.upperTrapezius` and `.lowerTrapezius` have no artwork yet; highlighting them paints nothing. +`.rearDeltoid`, `.upperTrapezius` and `.lowerTrapezius` are drawn on the back views, and `.upperTrapezius` on the front views too. ## UIKit Integration @@ -554,7 +581,7 @@ view.addSubview(legend) MuscleMap includes full VoiceOver support. Each muscle region is exposed as an accessibility element with: -- Localized muscle name as the accessibility label +- Localized muscle name as the accessibility label; muscles drawn on both sides get one element per side ("Biceps, Left", "Biceps, Right") - Selection state ("Selected" / "Not selected") - Tap and long press hints - Top-to-bottom traversal order (anatomical navigation) @@ -606,7 +633,17 @@ A demo app is included in the `Example/` directory. Open `Example/MuscleMapDemoA - iOS 17.0+ - macOS 14.0+ -- Swift 5.9+ +- Swift 5.9+ (builds in the Swift 6 language mode on a Swift 6 toolchain) + +## Development + +```bash +swift test # unit tests +swift run --package-path Tools/ScreenshotGenerator ScreenshotGenerator Screenshots + # regenerate the README screenshots +``` + +Every push and pull request runs the tests, an iOS build, the demo app build and `pod lib lint` on GitHub Actions. ## License diff --git a/Screenshots/back_regions.png b/Screenshots/back_regions.png new file mode 100644 index 0000000..d8de94c Binary files /dev/null and b/Screenshots/back_regions.png differ diff --git a/Screenshots/female_back_highlight.png b/Screenshots/female_back_highlight.png index d4090e7..c697204 100644 Binary files a/Screenshots/female_back_highlight.png and b/Screenshots/female_back_highlight.png differ diff --git a/Screenshots/female_front_highlight.png b/Screenshots/female_front_highlight.png index 68f141f..4ece756 100644 Binary files a/Screenshots/female_front_highlight.png and b/Screenshots/female_front_highlight.png differ diff --git a/Screenshots/gradient_linear.png b/Screenshots/gradient_linear.png index c577a37..c93f6dc 100644 Binary files a/Screenshots/gradient_linear.png and b/Screenshots/gradient_linear.png differ diff --git a/Screenshots/gradient_neon.png b/Screenshots/gradient_neon.png index 3f9acde..9117c7c 100644 Binary files a/Screenshots/gradient_neon.png and b/Screenshots/gradient_neon.png differ diff --git a/Screenshots/gradient_radial.png b/Screenshots/gradient_radial.png index f3914c9..87f2c86 100644 Binary files a/Screenshots/gradient_radial.png and b/Screenshots/gradient_radial.png differ diff --git a/Screenshots/heatmap_stepped.png b/Screenshots/heatmap_stepped.png new file mode 100644 index 0000000..b0ea9c2 Binary files /dev/null and b/Screenshots/heatmap_stepped.png differ diff --git a/Screenshots/heatmap_thermal.png b/Screenshots/heatmap_thermal.png index 3659907..2627dd4 100644 Binary files a/Screenshots/heatmap_thermal.png and b/Screenshots/heatmap_thermal.png differ diff --git a/Screenshots/heatmap_workout.png b/Screenshots/heatmap_workout.png index 1225ac9..8125ba7 100644 Binary files a/Screenshots/heatmap_workout.png and b/Screenshots/heatmap_workout.png differ diff --git a/Screenshots/male_back_highlight.png b/Screenshots/male_back_highlight.png index ae43624..74ff5a8 100644 Binary files a/Screenshots/male_back_highlight.png and b/Screenshots/male_back_highlight.png differ diff --git a/Screenshots/male_front_highlight.png b/Screenshots/male_front_highlight.png index cd31ae1..e2f6b00 100644 Binary files a/Screenshots/male_front_highlight.png and b/Screenshots/male_front_highlight.png differ diff --git a/Screenshots/style_medical.png b/Screenshots/style_medical.png index 16a3767..ce0c84e 100644 Binary files a/Screenshots/style_medical.png and b/Screenshots/style_medical.png differ diff --git a/Screenshots/style_neon.png b/Screenshots/style_neon.png index 0948143..e9224dc 100644 Binary files a/Screenshots/style_neon.png and b/Screenshots/style_neon.png differ diff --git a/Sources/MuscleMap/Data/FemaleBackPaths.swift b/Sources/MuscleMap/Data/FemaleBackPaths.swift index 8b411a7..6772245 100644 --- a/Sources/MuscleMap/Data/FemaleBackPaths.swift +++ b/Sources/MuscleMap/Data/FemaleBackPaths.swift @@ -222,6 +222,52 @@ enum FemaleBackPaths { right: [ "m 1206.8,1373.7 q 8.72,22.21 13.81,42.33 0.94,3.7 -1.71,6.53 -1.44,1.54 -5.28,2.63 -14.71,4.18 -29.01,2.49 -9.46,-1.11 -14.96,-6.81 -1.68,-1.75 -1.46,-3.83 2.75,-26.35 9.42,-50.74 0.68,-2.5 0.89,0.09 0.63,7.86 2.59,16.87 2.96,13.59 7.32,30.23 0.79,2.98 3.86,3.47 3.52,0.56 5.1,-1.58 c 2.84,-3.86 4.42,-8.89 4.83,-13.69 0.96,-11.15 -0.35,-22.32 -1,-33 q -0.2,-3.25 0.17,-6.56 0.09,-0.79 0.42,-0.07 2.74,5.83 5.01,11.64 z" ] - ) + ), + + // Upper Trapezius (sub-group, the part of the trapezius above the rhomboids) + BodyPartPathData( + slug: .upperTrapezius, + left: [ + "M1137.4 318.1L1137 305.3L1136.5 303.7L1135.6 302.1L1130.9 298.2L1125.3 294.7L1118.8 291.5L1111.5 288.8L1101.7 285.8L1091.2 283.3L1080.6 281.5L1069.9 280.4L1058.6 279.9L1047 280.2L1040.3 281.2L1036.4 282.4L1035.5 283L1036.1 283.5L1040.8 284.5L1044.5 285.6L1050.1 288L1056 291.7L1061.7 296.6L1066.9 302.8L1071.4 309.7L1075.8 318.3L1080.2 328.6Z" + ], + right: [ + "M1207.7 323.1L1211.7 314.1L1215.8 306.8L1220.3 300.5L1225.1 295.4L1230.1 291.3L1235.7 287.9L1240.8 285.7L1249.6 283.4L1250.2 283.1L1250.1 282.8L1246 281.4L1240.7 280.4L1235.4 279.9L1226.6 279.8L1218.2 280.1L1209.8 280.8L1201.4 282L1193 283.5L1184.8 285.5L1177.2 287.7L1169.9 290.3L1162.8 293.3L1158 295.9L1153.2 299.3L1149.9 302.2L1148.5 304.6L1148.2 307L1148 318.1L1205.5 328.6Z" + ] + ), + + // Lower Trapezius (sub-group, the part of the trapezius below the rhomboids) + BodyPartPathData( + slug: .lowerTrapezius, + left: [ + "M1136.1 407.1L1136.6 407L1137.3 406.1L1138 403.8L1138.2 402L1138.1 373.1L1104.2 381L1107.4 385.2L1110.9 389.2L1114.6 393L1118.6 396.5L1122.9 399.7L1127.4 402.6L1132.1 405.3Z" + ], + right: [ + "M1147.4 373.1L1147.5 403L1148 406L1148.7 406.9L1149.3 407L1149.9 407L1152.1 406L1157.8 402.8L1162.7 399.6L1166.9 396.4L1170.9 393L1174.7 389.2L1178.2 385.1L1181.3 381Z" + ] + ), + + // Rear Deltoid (sub-group, the deltoid as seen from behind) + BodyPartPathData( + slug: .rearDeltoid, + left: [ + "m 1015.26,291.32 c 13.82,-8.28 31.54,1.05 42.43,10.31 a 0.79,0.78 46.2 0 1 -0.02,1.22 c -24.49,19.25 -48.03,41.91 -64.52,68.41 q -0.81,1.29 -0.91,-0.22 c -0.55,-8.02 -1.66,-16.16 -1.47,-24.04 q 0.34,-14.94 4.17,-28.72 4.91,-17.72 20.32,-26.96 z" + ], + right: [ + "m 1269.98,290.8 c 13.79,8.38 20.48,19.55 22.26,34.89 1.11,9.5 1.29,18.77 0.46,28.54 q -0.73,8.56 -1.29,17.08 -0.08,1.29 -0.8,0.22 c -16.86,-25.21 -37.38,-47.58 -59.38,-68.88 q -0.46,-0.45 0,-0.88 c 9.46,-8.91 18.65,-15.05 32.34,-13.43 2.34,0.28 4.42,1.25 6.41,2.46 z" + ] + ), + + // Ankles (always-visible sub-group, the upper part of the heel shape) + BodyPartPathData( + slug: .ankles, + left: [ + "M1079.7 1373.3L1075.5 1384.4L1070.9 1398.2L1082.2 1398.2L1082.1 1389.2L1083.3 1370.6L1083.6 1364.1Z", + "M1114 1398.2L1111.1 1382.7L1107.6 1368.5L1105.4 1382.4L1101.8 1398.2Z" + ], + right: [ + "M1177.9 1368.4L1174.3 1383.5L1171.5 1398.3L1183.7 1398.3L1179.7 1380.5Z", + "M1214.7 1398.3L1208.3 1379.9L1205 1371.2L1202 1364.4L1202 1368.3L1203.2 1388L1203.2 1398.3Z" + ] + ), ] } diff --git a/Sources/MuscleMap/Data/FemaleFrontPaths.swift b/Sources/MuscleMap/Data/FemaleFrontPaths.swift index 684b5df..9ac98f0 100644 --- a/Sources/MuscleMap/Data/FemaleFrontPaths.swift +++ b/Sources/MuscleMap/Data/FemaleFrontPaths.swift @@ -376,6 +376,17 @@ enum FemaleFrontPaths { "m 382.04,1384.42 q 1.51,-0.66 3.03,-0.87 0.57,-0.07 0.91,0.39 l 27.73,37.1 a 1.23,1.22 61 0 1 -0.53,1.87 c -12.37,4.87 -27.24,6.04 -40.29,5.05 a 1.5,1.49 81 0 1 -1.27,-0.93 c -3.14,-7.8 -6.11,-16.5 -6.82,-25.44 q -0.2,-2.47 0.96,-4.02 6.68,-8.97 16.28,-13.15 z", "m 347.64,1416.45 c -0.34,-4.76 6.41,-11.73 10.12,-14.46 a 1.41,1.4 -37.2 0 1 1.94,0.26 c 2.44,3.09 2.86,7.16 3.19,10.93 q 0.6,6.77 -0.53,13.21 a 1.96,1.95 5.2 0 1 -1.95,1.62 l -9.39,-0.1 a 2.74,2.74 0 0 1 -2.7,-2.53 q -0.35,-4.33 -0.68,-8.93 z" ] - ) + ), + + // Upper Trapezius (sub-group, the trapezius visible from the front is its upper part) + BodyPartPathData( + slug: .upperTrapezius, + left: [ + "m 241.01,294.28 c 12.76,-8.28 25.21,-16.07 39.54,-21.44 2.58,-0.96 6.02,0.04 5.63,3.51 q -0.44,3.92 -1.62,5.3 c -6.48,7.58 -12.74,12.81 -19.24,19.14 -1.03,1.01 -1.87,1.15 -3.25,1.03 q -8.13,-0.69 -18.79,-2.97 -2.99,-0.65 -2.7,-3.86 0.05,-0.46 0.43,-0.71 z" + ], + right: [ + "m 366.31,275.29 c 12,4.97 22.67,11.96 33.55,18.94 q 0.51,0.33 0.54,0.94 c 0.08,1.63 -0.71,3.2 -2.44,3.59 q -9.65,2.18 -18.62,2.99 c -1.26,0.11 -2.4,0.24 -3.39,-0.7 q -5.03,-4.78 -10.97,-10.25 -6.53,-6.01 -8.56,-9.16 -1.87,-2.89 -1.63,-6.77 0.03,-0.49 0.35,-0.85 c 3.04,-3.4 7.94,-0.07 11.17,1.27 z" + ] + ), ] } diff --git a/Sources/MuscleMap/Data/MaleBackPaths.swift b/Sources/MuscleMap/Data/MaleBackPaths.swift index 6efbc73..1acc947 100644 --- a/Sources/MuscleMap/Data/MaleBackPaths.swift +++ b/Sources/MuscleMap/Data/MaleBackPaths.swift @@ -242,6 +242,39 @@ enum MaleBackPaths { common: [ "M1138.38 168.39q-.49 4.68-3.37 8.55-.31.41-.81.56c-9.91 3.11-15.97 9.67-20.28 18.94-2.21 4.75-5.25 12.39-11.48 12.3q-18.46-.25-36.94.25-5.35.14-7.43-3.53c-6.78-11.97-10.46-22.53-23.52-27.48-5.05-1.92-5.38-6.47-6.41-11.53q-6.64-26.16 4.43-48.88c8.13-16.7 34.61-21.41 51.58-21.04 4.89.11 9.69-.11 14.42.85 18.79 3.8 33.17 8.5 39.34 28.66q6.38 20.88.47 42.35z" ] - ) + ), + + // Upper Trapezius (sub-group, the part of the trapezius above the rhomboids) + BodyPartPathData( + slug: .upperTrapezius, + left: [ + "M1071.8 309.7L1067.1 305.7L1064 303.8L1060.5 302L1056.7 300.6L1049.8 299.1L1036.2 297.6L1029.2 297.3L1019.7 298.1L1010.9 299.8L1002.8 302.3L1002.8 302.8L1007.1 310L1010 315.9L1014 326L1018 337.7L1077.7 326.3L1076.5 319.9L1075 315L1073.1 311.4Z" + ], + right: [ + "M1164 302.1L1157 300L1150.5 298.6L1144 297.7L1138.1 297.3L1130.9 297.6L1120.3 298.7L1114 299.7L1109.2 301L1105 302.8L1101.3 304.9L1097 308L1094.5 310.6L1092.7 313.5L1091.4 316.6L1090.6 320.3L1089.6 326.3L1149 337.7L1153.1 325.1L1155.8 318.3L1159.7 310.3L1164.3 302.6Z" + ] + ), + + // Lower Trapezius (sub-group, the part of the trapezius below the rhomboids) + BodyPartPathData( + slug: .lowerTrapezius, + left: [ + "M1041.6 408.6L1045.6 416.9L1050 425.2L1064.4 450.5L1068.5 459.9L1072.3 472.2L1072.6 472.7L1073 473L1073.6 472.8L1074.8 460.9L1076.5 438.6L1078.7 399.3Z" + ], + right: [ + "M1088.4 399.5L1089.8 427.1L1091.7 454.5L1093.2 471.8L1093.3 472.4L1093.7 472.8L1094.3 472.8L1094.7 472.4L1096.6 466L1098.5 460.4L1102.4 451.3L1106.9 442.9L1119.4 421.4L1122.1 416L1125.4 408.6Z" + ] + ), + + // Rear Deltoid (sub-group, the deltoid as seen from behind) + BodyPartPathData( + slug: .rearDeltoid, + left: [ + "M980.66 319.58c.19.14.55.19.65.32a.8.8 0 01-.16 1.15c-6.78 4.75-15.26 9.77-20.03 15.58-6.41 7.78-8.76 16.96-9.44 27.04-.39 5.92-1.68 9.5-5.59 13.43-10.02 10.08-19.04 16.47-31.14 20.41q-.75.25-.75-.55.19-18.4-.09-36.3-.14-9.4 1.07-14.22c4.04-16.07 22.8-33.85 39.68-35.64 9.99-1.06 17.34 2.46 25.8 8.78z" + ], + right: [ + "M1227.3 316.44c14.62 9.44 25.48 21.03 25.46 39.51q-.02 20.56-.01 41.37a.37.37 0 01-.51.35c-5.08-2.06-10.41-3.98-14.9-6.97-7.84-5.24-21.14-14.95-21.77-24.95-.69-10.75-2.81-20.85-9.76-29.25-4.68-5.65-12.96-10.58-19.6-15.26q-1.23-.87.01-1.71c4.6-3.13 9.91-6.78 15.25-7.98q13.58-3.03 25.83 4.89z" + ] + ), ] } diff --git a/Sources/MuscleMap/Data/MaleFrontPaths.swift b/Sources/MuscleMap/Data/MaleFrontPaths.swift index 724db80..c1d2113 100644 --- a/Sources/MuscleMap/Data/MaleFrontPaths.swift +++ b/Sources/MuscleMap/Data/MaleFrontPaths.swift @@ -373,6 +373,17 @@ enum MaleFrontPaths { common: [ "M418.91 167.68q-2.55 11.73-3.88 24.27a1.14 1.14 0 01-2.22.23c-3.45-10.76.93-27.48-5.64-36.36q-4.31-5.81-4.97-12.32c-.55-5.49-.15-12.85-6.11-15.22q-1.84-.74-8.75-.72-10.17.03-20.22-1.62c-4.13-.68-7.51.2-11.53.75-6.31.86-12.61.81-19 .93-5.43.09-9.3 2.68-9.77 8.21-.71 8.31-1.21 13.06-5.57 19.82-2.68 4.16-3.78 8.57-3.8 13.1-.02 5.29.16 22.47-2.63 24.22a1.02 1.02 0 01-1.56-.77q-1.14-11.78-3.78-23.29-1.48-6.99-1.9-9.7c-2.49-15.94.13-40.13 13.53-51.15 9.39-7.72 28.53-11.63 40.37-11.51 4.2.05 8.74-.3 12.68.22 13.82 1.82 31.67 5.83 39.42 18.92 9.01 15.21 9.88 35.14 5.33 51.99z" ] - ) + ), + + // Upper Trapezius (sub-group, the trapezius visible from the front is its upper part) + BodyPartPathData( + slug: .upperTrapezius, + left: [ + "M285.01 307.01a.89.89 0 01-.11-1.64q19.44-9.61 35.65-24.8 1.68-1.57 3.31-.31.4.32.45.82 1.25 12.61-1.57 25.41c-.74 3.32-2.55 4.23-5.9 4.48q-16.02 1.24-31.83-3.96z" + ], + right: [ + "M414 311.19c-5.24-.12-7.81-.64-8.9-6.27q-2.33-12.09-1.17-23.94.06-.61.61-.89 1.66-.85 3.65.99 16.12 14.87 33.97 23.63 3.65 1.79-.27 2.89-13.88 3.91-27.89 3.59z" + ] + ), ] } diff --git a/Sources/MuscleMap/Rendering/BodyRenderer.swift b/Sources/MuscleMap/Rendering/BodyRenderer.swift index 57d61e1..a49eb36 100644 --- a/Sources/MuscleMap/Rendering/BodyRenderer.swift +++ b/Sources/MuscleMap/Rendering/BodyRenderer.swift @@ -65,6 +65,21 @@ struct BodyRenderer { private var pathCache: PathCache { Self.sharedPathCache } + private static let preloadOnce: Void = { + Task.detached(priority: .utility) { + for gender in BodyGender.allCases { + for side in BodySide.allCases { + sharedPathCache.preload(BodyPathProvider.paths(gender: gender, side: side).flatMap(\.allPaths)) + } + } + } + }() + + /// Starts parsing the artwork of all four bodies in the background, once per app run. + static func preloadArtwork() { + _ = preloadOnce + } + func render(context: inout GraphicsContext, size: CGSize) { let viewBox = BodyPathProvider.viewBox(gender: gender, side: side) let scale = min( @@ -222,6 +237,12 @@ struct BodyRenderer { /// Returns the bounding rect of a muscle's combined paths in the given view size. func boundingRect(for muscle: Muscle, in size: CGSize) -> CGRect? { + boundingRect(for: muscle, muscleSide: nil, in: size) + } + + /// Returns the bounding rect of one side of a muscle (`.both` = its common paths), + /// or of all its paths when `muscleSide` is nil. + func boundingRect(for muscle: Muscle, muscleSide: MuscleSide?, in size: CGSize) -> CGRect? { let viewBox = BodyPathProvider.viewBox(gender: gender, side: side) let scale = min( size.width / viewBox.size.width, @@ -236,7 +257,14 @@ struct BodyRenderer { for bodyPart in bodyParts { guard let partMuscle = bodyPart.slug.muscle, partMuscle == muscle || partMuscle.regionHost == muscle else { continue } - for pathString in bodyPart.allPaths { + let paths: [String] + switch muscleSide { + case nil: paths = bodyPart.allPaths + case .left: paths = bodyPart.left + case .right: paths = bodyPart.right + case .both: paths = bodyPart.common + } + for pathString in paths { let path = pathCache.path(for: pathString, scale: scale, offsetX: offsetX, offsetY: offsetY) let rect = path.boundingRect guard !rect.isEmpty else { continue } diff --git a/Sources/MuscleMap/Rendering/PathCache.swift b/Sources/MuscleMap/Rendering/PathCache.swift index c81347e..ee9a79c 100644 --- a/Sources/MuscleMap/Rendering/PathCache.swift +++ b/Sources/MuscleMap/Rendering/PathCache.swift @@ -8,30 +8,45 @@ // import SwiftUI +import os -final class PathCache: @unchecked Sendable { +/// Caches parsed body paths. +/// +/// Each SVG path string is parsed once at unit scale, then scaled and offset per +/// view size, which is much cheaper than parsing again. The parsed paths are kept +/// for the life of the app (the artwork is a fixed set of strings); the per-size +/// copies are cleared once they pass `maxEntries`. +final class PathCache: Sendable { - private struct Key: Hashable { + private struct SizedKey: Hashable { let svgPath: String let scale: CGFloat let offsetX: CGFloat let offsetY: CGFloat } - private var cache: [Key: Path] = [:] - private let lock = NSLock() + private struct State { + var unit: [String: Path] = [:] + var sized: [SizedKey: Path] = [:] + } + + private let state = OSAllocatedUnfairLock(initialState: State()) private let maxEntries: Int - /// - Parameter maxEntries: The cache is cleared once it grows past this size, + /// - Parameter maxEntries: The per-size cache is cleared once it grows past this size, /// which bounds memory when a body is drawn at many sizes (e.g. while zooming). init(maxEntries: Int = 4096) { self.maxEntries = maxEntries } + /// Number of per-size paths currently cached. var count: Int { - lock.lock() - defer { lock.unlock() } - return cache.count + state.withLock { $0.sized.count } + } + + /// Number of path strings parsed so far. + var parsedCount: Int { + state.withLock { $0.unit.count } } func path( @@ -40,30 +55,41 @@ final class PathCache: @unchecked Sendable { offsetX: CGFloat, offsetY: CGFloat ) -> Path { - let key = Key(svgPath: svgPath, scale: scale, offsetX: offsetX, offsetY: offsetY) - - lock.lock() - if let cached = cache[key] { - lock.unlock() + let key = SizedKey(svgPath: svgPath, scale: scale, offsetX: offsetX, offsetY: offsetY) + if let cached = state.withLock({ $0.sized[key] }) { return cached } - lock.unlock() - let built = PathBuilder.buildPath(from: svgPath, scale: scale, offsetX: offsetX, offsetY: offsetY) + let transform = CGAffineTransform(a: scale, b: 0, c: 0, d: scale, tx: offsetX, ty: offsetY) + let built = unitPath(for: svgPath).applying(transform) - lock.lock() - if cache.count >= maxEntries { - cache.removeAll(keepingCapacity: true) + let limit = maxEntries + state.withLock { state in + if state.sized.count >= limit { + state.sized.removeAll(keepingCapacity: true) + } + state.sized[key] = built } - cache[key] = built - lock.unlock() - return built } + /// Parses the given path strings ahead of time so later draws only scale them. + func preload(_ svgPaths: [String]) { + for svgPath in svgPaths { + _ = unitPath(for: svgPath) + } + } + func invalidate() { - lock.lock() - cache.removeAll() - lock.unlock() + state.withLock { $0 = State() } + } + + private func unitPath(for svgPath: String) -> Path { + if let cached = state.withLock({ $0.unit[svgPath] }) { + return cached + } + let built = PathBuilder.buildPath(from: svgPath, scale: 1, offsetX: 0, offsetY: 0) + state.withLock { $0.unit[svgPath] = built } + return built } } diff --git a/Sources/MuscleMap/Resources/ar.lproj/Localizable.strings b/Sources/MuscleMap/Resources/ar.lproj/Localizable.strings index dae4a3d..c5cf370 100644 --- a/Sources/MuscleMap/Resources/ar.lproj/Localizable.strings +++ b/Sources/MuscleMap/Resources/ar.lproj/Localizable.strings @@ -52,6 +52,7 @@ "accessibility.hint.longPress" = "اضغط مطولاً للتفاصيل"; "accessibility.bodyMap" = "خريطة عضلات الجسم"; "accessibility.muscleRegion" = "منطقة عضلية"; +"accessibility.muscleWithSide" = "%1$@، %2$@"; "accessibility.heatmapLegend" = "مفتاح شدة الخريطة الحرارية"; /* Legend */ diff --git a/Sources/MuscleMap/Resources/de.lproj/Localizable.strings b/Sources/MuscleMap/Resources/de.lproj/Localizable.strings index 6354195..eb380b3 100644 --- a/Sources/MuscleMap/Resources/de.lproj/Localizable.strings +++ b/Sources/MuscleMap/Resources/de.lproj/Localizable.strings @@ -52,6 +52,7 @@ "accessibility.hint.longPress" = "Für Details lange drücken"; "accessibility.bodyMap" = "Körper-Muskelkarte"; "accessibility.muscleRegion" = "Muskelbereich"; +"accessibility.muscleWithSide" = "%1$@, %2$@"; "accessibility.heatmapLegend" = "Heatmap-Intensitätslegende"; /* Legend */ diff --git a/Sources/MuscleMap/Resources/en.lproj/Localizable.strings b/Sources/MuscleMap/Resources/en.lproj/Localizable.strings index e8155bd..34d4ce3 100644 --- a/Sources/MuscleMap/Resources/en.lproj/Localizable.strings +++ b/Sources/MuscleMap/Resources/en.lproj/Localizable.strings @@ -52,6 +52,7 @@ "accessibility.hint.longPress" = "Long press for details"; "accessibility.bodyMap" = "Body muscle map"; "accessibility.muscleRegion" = "Muscle region"; +"accessibility.muscleWithSide" = "%1$@, %2$@"; "accessibility.heatmapLegend" = "Heatmap intensity legend"; /* Legend */ diff --git a/Sources/MuscleMap/Resources/es.lproj/Localizable.strings b/Sources/MuscleMap/Resources/es.lproj/Localizable.strings index 67e8b91..bfae302 100644 --- a/Sources/MuscleMap/Resources/es.lproj/Localizable.strings +++ b/Sources/MuscleMap/Resources/es.lproj/Localizable.strings @@ -52,6 +52,7 @@ "accessibility.hint.longPress" = "Mantén presionado para detalles"; "accessibility.bodyMap" = "Mapa muscular del cuerpo"; "accessibility.muscleRegion" = "Región muscular"; +"accessibility.muscleWithSide" = "%1$@, %2$@"; "accessibility.heatmapLegend" = "Leyenda de intensidad del mapa de calor"; /* Legend */ diff --git a/Sources/MuscleMap/Resources/fr.lproj/Localizable.strings b/Sources/MuscleMap/Resources/fr.lproj/Localizable.strings index ba62cb7..7322ada 100644 --- a/Sources/MuscleMap/Resources/fr.lproj/Localizable.strings +++ b/Sources/MuscleMap/Resources/fr.lproj/Localizable.strings @@ -52,6 +52,7 @@ "accessibility.hint.longPress" = "Appui long pour les détails"; "accessibility.bodyMap" = "Carte musculaire du corps"; "accessibility.muscleRegion" = "Région musculaire"; +"accessibility.muscleWithSide" = "%1$@, %2$@"; "accessibility.heatmapLegend" = "Légende d'intensité de la carte thermique"; /* Legend */ diff --git a/Sources/MuscleMap/Resources/ja.lproj/Localizable.strings b/Sources/MuscleMap/Resources/ja.lproj/Localizable.strings index c5db5f9..b0e141e 100644 --- a/Sources/MuscleMap/Resources/ja.lproj/Localizable.strings +++ b/Sources/MuscleMap/Resources/ja.lproj/Localizable.strings @@ -52,6 +52,7 @@ "accessibility.hint.longPress" = "長押しで詳細"; "accessibility.bodyMap" = "筋肉マップ"; "accessibility.muscleRegion" = "筋肉部位"; +"accessibility.muscleWithSide" = "%1$@、%2$@"; "accessibility.heatmapLegend" = "ヒートマップ強度の凡例"; /* Legend */ diff --git a/Sources/MuscleMap/Resources/ko.lproj/Localizable.strings b/Sources/MuscleMap/Resources/ko.lproj/Localizable.strings index e846550..2e71a80 100644 --- a/Sources/MuscleMap/Resources/ko.lproj/Localizable.strings +++ b/Sources/MuscleMap/Resources/ko.lproj/Localizable.strings @@ -52,6 +52,7 @@ "accessibility.hint.longPress" = "길게 눌러 상세 보기"; "accessibility.bodyMap" = "신체 근육 지도"; "accessibility.muscleRegion" = "근육 부위"; +"accessibility.muscleWithSide" = "%1$@, %2$@"; "accessibility.heatmapLegend" = "히트맵 강도 범례"; /* Legend */ diff --git a/Sources/MuscleMap/Resources/pt-BR.lproj/Localizable.strings b/Sources/MuscleMap/Resources/pt-BR.lproj/Localizable.strings index 3fc80c5..a564b3c 100644 --- a/Sources/MuscleMap/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/MuscleMap/Resources/pt-BR.lproj/Localizable.strings @@ -52,6 +52,7 @@ "accessibility.hint.longPress" = "Pressione e segure para detalhes"; "accessibility.bodyMap" = "Mapa muscular do corpo"; "accessibility.muscleRegion" = "Região muscular"; +"accessibility.muscleWithSide" = "%1$@, %2$@"; "accessibility.heatmapLegend" = "Legenda de intensidade do mapa de calor"; /* Legend */ diff --git a/Sources/MuscleMap/Resources/ru.lproj/Localizable.strings b/Sources/MuscleMap/Resources/ru.lproj/Localizable.strings index ee27087..63e444b 100644 --- a/Sources/MuscleMap/Resources/ru.lproj/Localizable.strings +++ b/Sources/MuscleMap/Resources/ru.lproj/Localizable.strings @@ -52,6 +52,7 @@ "accessibility.hint.longPress" = "Долгое нажатие для подробностей"; "accessibility.bodyMap" = "Карта мышц тела"; "accessibility.muscleRegion" = "Область мышц"; +"accessibility.muscleWithSide" = "%1$@, %2$@"; "accessibility.heatmapLegend" = "Легенда интенсивности тепловой карты"; /* Legend */ diff --git a/Sources/MuscleMap/Resources/tr.lproj/Localizable.strings b/Sources/MuscleMap/Resources/tr.lproj/Localizable.strings index 956745e..7b7c14c 100644 --- a/Sources/MuscleMap/Resources/tr.lproj/Localizable.strings +++ b/Sources/MuscleMap/Resources/tr.lproj/Localizable.strings @@ -52,6 +52,7 @@ "accessibility.hint.longPress" = "Detaylar için uzun basın"; "accessibility.bodyMap" = "Vücut kas haritası"; "accessibility.muscleRegion" = "Kas bölgesi"; +"accessibility.muscleWithSide" = "%1$@, %2$@"; "accessibility.heatmapLegend" = "Isı haritası yoğunluk göstergesi"; /* Legend */ diff --git a/Sources/MuscleMap/Resources/zh-Hans.lproj/Localizable.strings b/Sources/MuscleMap/Resources/zh-Hans.lproj/Localizable.strings index fcb8e54..bc582bd 100644 --- a/Sources/MuscleMap/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/MuscleMap/Resources/zh-Hans.lproj/Localizable.strings @@ -52,6 +52,7 @@ "accessibility.hint.longPress" = "长按查看详情"; "accessibility.bodyMap" = "身体肌肉图"; "accessibility.muscleRegion" = "肌肉区域"; +"accessibility.muscleWithSide" = "%1$@,%2$@"; "accessibility.heatmapLegend" = "热力图强度图例"; /* Legend */ diff --git a/Sources/MuscleMap/Views/AnimatedBodyView.swift b/Sources/MuscleMap/Views/AnimatedBodyView.swift index 41461d5..056d785 100644 --- a/Sources/MuscleMap/Views/AnimatedBodyView.swift +++ b/Sources/MuscleMap/Views/AnimatedBodyView.swift @@ -311,7 +311,7 @@ struct AnimatedBodyCanvas: View, Animatable { let selectionPulseFactor: Double var hideSubGroups: Bool = true - var animatableData: Double { + nonisolated var animatableData: Double { get { animationProgress } set { animationProgress = newValue } } diff --git a/Sources/MuscleMap/Views/BodyAccessibilityOverlay.swift b/Sources/MuscleMap/Views/BodyAccessibilityOverlay.swift index 7e469a0..b589774 100644 --- a/Sources/MuscleMap/Views/BodyAccessibilityOverlay.swift +++ b/Sources/MuscleMap/Views/BodyAccessibilityOverlay.swift @@ -34,7 +34,7 @@ struct BodyAccessibilityOverlay: View { let muscles = visibleMuscles(renderer: renderer) ZStack { - ForEach(muscles, id: \.muscle) { item in + ForEach(muscles, id: \.id) { item in accessibilityElement(for: item) } } @@ -53,26 +53,27 @@ struct BodyAccessibilityOverlay: View { .frame(width: item.rect.width, height: item.rect.height) .position(x: item.rect.midX, y: item.rect.midY) .accessibilityElement() - .accessibilityLabel(item.muscle.displayName) + .accessibilityLabel(item.label) .accessibilityValue(NSLocalizedString(valueKey, bundle: .module, comment: "")) .accessibilityHint(NSLocalizedString(hintKey, bundle: .module, comment: "")) .accessibilityAddTraits(traits) .accessibilityAction(.default) { - onMuscleSelected?(item.muscle, .both) + onMuscleSelected?(item.muscle, item.side) } .accessibilityAction(named: Text(NSLocalizedString("accessibility.hint.longPress", bundle: .module, comment: ""))) { - onMuscleLongPressed?(item.muscle, .both) + onMuscleLongPressed?(item.muscle, item.side) } } // MARK: - Private /// Returns visible muscles sorted top-to-bottom for natural VoiceOver traversal. + /// Muscles drawn on both sides get one element per side, read left then right. /// Excludes cosmetic parts (e.g., head). - private func visibleMuscles(renderer: BodyRenderer) -> [MuscleAccessibilityItem] { + func visibleMuscles(renderer: BodyRenderer) -> [MuscleAccessibilityItem] { let bodyParts = BodyPathProvider.paths(gender: gender, side: side) var seen = Set() - var items: [MuscleAccessibilityItem] = [] + var groups: [[MuscleAccessibilityItem]] = [] for bodyPart in bodyParts { guard let muscle = bodyPart.slug.muscle, @@ -81,19 +82,40 @@ struct BodyAccessibilityOverlay: View { if hideSubGroups && muscle.isSubGroup && !muscle.isAlwaysVisibleSubGroup { continue } seen.insert(muscle) - if let rect = renderer.boundingRect(for: muscle, in: size), !rect.isEmpty { - items.append(MuscleAccessibilityItem(muscle: muscle, rect: rect)) + let left = renderer.boundingRect(for: muscle, muscleSide: .left, in: size) + let right = renderer.boundingRect(for: muscle, muscleSide: .right, in: size) + var group: [MuscleAccessibilityItem] = [] + if let left, let right, !left.isEmpty, !right.isEmpty { + group = [ + MuscleAccessibilityItem(muscle: muscle, side: .left, rect: left), + MuscleAccessibilityItem(muscle: muscle, side: .right, rect: right), + ].sorted { $0.rect.minX < $1.rect.minX } + } else if let rect = renderer.boundingRect(for: muscle, in: size), !rect.isEmpty { + group = [MuscleAccessibilityItem(muscle: muscle, side: .both, rect: rect)] } + if !group.isEmpty { groups.append(group) } } - // Sort top-to-bottom (by minY) for anatomical VoiceOver traversal - items.sort { $0.rect.minY < $1.rect.minY } - return items + // Sort top-to-bottom (by minY) for anatomical VoiceOver traversal, keeping sides together + groups.sort { a, b in + a.map(\.rect.minY).min()! < b.map(\.rect.minY).min()! + } + return groups.flatMap { $0 } } } -/// A muscle with its bounding rect for accessibility layout. -fileprivate struct MuscleAccessibilityItem { +/// A muscle (or one side of it) with its bounding rect for accessibility layout. +struct MuscleAccessibilityItem { let muscle: Muscle + let side: MuscleSide let rect: CGRect + + var id: String { "\(muscle.rawValue)-\(side.rawValue)" } + + /// "Biceps" for a single region, "Biceps, Left" for one side of a pair. + var label: String { + guard side != .both else { return muscle.displayName } + let format = NSLocalizedString("accessibility.muscleWithSide", bundle: .module, comment: "Muscle name, then side") + return String(format: format, muscle.displayName, side.displayName) + } } diff --git a/Sources/MuscleMap/Views/BodyView.swift b/Sources/MuscleMap/Views/BodyView.swift index 4b60db4..9091e40 100644 --- a/Sources/MuscleMap/Views/BodyView.swift +++ b/Sources/MuscleMap/Views/BodyView.swift @@ -80,6 +80,16 @@ public struct BodyView: View { self.side = side self.style = style self.highlights = [:] + BodyRenderer.preloadArtwork() + } + + /// Parses the body artwork in the background so the first `BodyView` appears faster. + /// + /// Creating a `BodyView` starts this automatically. Call it earlier, for example at + /// app launch, to have the artwork ready before the first body is shown. + /// Calling it more than once has no extra cost. + public static func preloadArtwork() { + BodyRenderer.preloadArtwork() } // MARK: - Body diff --git a/Tests/MuscleMapTests/AccessibilityTests.swift b/Tests/MuscleMapTests/AccessibilityTests.swift index 866fbb5..06f4fc8 100644 --- a/Tests/MuscleMapTests/AccessibilityTests.swift +++ b/Tests/MuscleMapTests/AccessibilityTests.swift @@ -1,6 +1,7 @@ import XCTest @testable import MuscleMap +@MainActor final class AccessibilityTests: XCTestCase { func testAccessibilityOverlayCreatesForAllCombinations() { @@ -94,3 +95,53 @@ final class AccessibilityTests: XCTestCase { } } } + +@MainActor +final class AccessibilityPerSideTests: XCTestCase { + + private let size = CGSize(width: 300, height: 600) + + private func items(_ gender: BodyGender = .male, _ side: BodySide = .front) -> [MuscleAccessibilityItem] { + let overlay = BodyAccessibilityOverlay( + gender: gender, side: side, highlights: [:], style: .default, + selectedMuscles: [], size: size, onMuscleSelected: nil, onMuscleLongPressed: nil + ) + let renderer = BodyRenderer(gender: gender, side: side, highlights: [:], style: .default, selectedMuscles: []) + return overlay.visibleMuscles(renderer: renderer) + } + + func testPairedMusclesGetOneElementPerSide() { + let biceps = items().filter { $0.muscle == .biceps } + XCTAssertEqual(biceps.map(\.side), [.left, .right]) + XCTAssertFalse(biceps[0].rect.intersects(biceps[1].rect)) + XCTAssertLessThan(biceps[0].rect.midX, biceps[1].rect.midX) + } + + func testSidesOfAMuscleAreAdjacent() { + for gender in BodyGender.allCases { + for side in BodySide.allCases { + let list = items(gender, side) + for (index, item) in list.enumerated() where item.side == .left { + XCTAssertEqual(list[index + 1].muscle, item.muscle, "\(item.muscle) \(gender) \(side)") + XCTAssertEqual(list[index + 1].side, .right) + } + } + } + } + + func testIdentifiersAreUnique() { + for gender in BodyGender.allCases { + for side in BodySide.allCases { + let ids = items(gender, side).map(\.id) + XCTAssertEqual(ids.count, Set(ids).count) + } + } + } + + func testLabels() { + let left = MuscleAccessibilityItem(muscle: .biceps, side: .left, rect: .zero) + XCTAssertEqual(left.label, "\(Muscle.biceps.displayName), \(MuscleSide.left.displayName)") + let single = MuscleAccessibilityItem(muscle: .abs, side: .both, rect: .zero) + XCTAssertEqual(single.label, Muscle.abs.displayName) + } +} diff --git a/Tests/MuscleMapTests/AnimationTests.swift b/Tests/MuscleMapTests/AnimationTests.swift index 77f0f1d..f9e62b9 100644 --- a/Tests/MuscleMapTests/AnimationTests.swift +++ b/Tests/MuscleMapTests/AnimationTests.swift @@ -2,6 +2,7 @@ import XCTest import SwiftUI @testable import MuscleMap +@MainActor final class AnimationTests: XCTestCase { // MARK: - BodyView Animation Modifier diff --git a/Tests/MuscleMapTests/HeatmapTests.swift b/Tests/MuscleMapTests/HeatmapTests.swift index c4b19ed..438d631 100644 --- a/Tests/MuscleMapTests/HeatmapTests.swift +++ b/Tests/MuscleMapTests/HeatmapTests.swift @@ -2,6 +2,7 @@ import XCTest import SwiftUI @testable import MuscleMap +@MainActor final class HeatmapTests: XCTestCase { // MARK: - MuscleIntensity @@ -285,6 +286,7 @@ final class HeatmapTests: XCTestCase { } } +@MainActor final class HeatmapScaleInterpolationTests: XCTestCase { private let data = [MuscleIntensity(muscle: .abs, intensity: 0.7)] diff --git a/Tests/MuscleMapTests/InteractiveTests.swift b/Tests/MuscleMapTests/InteractiveTests.swift index ddae095..8caa321 100644 --- a/Tests/MuscleMapTests/InteractiveTests.swift +++ b/Tests/MuscleMapTests/InteractiveTests.swift @@ -2,6 +2,7 @@ import XCTest import SwiftUI @testable import MuscleMap +@MainActor final class InteractiveTests: XCTestCase { // MARK: - MuscleSelection diff --git a/Tests/MuscleMapTests/MuscleTests.swift b/Tests/MuscleMapTests/MuscleTests.swift index 1aec01c..26a0103 100644 --- a/Tests/MuscleMapTests/MuscleTests.swift +++ b/Tests/MuscleMapTests/MuscleTests.swift @@ -111,10 +111,20 @@ final class MuscleDrawabilityTests: XCTestCase { } } - func testUndrawnSubGroupsAreReported() { - XCTAssertFalse(Muscle.rearDeltoid.isDrawable) - XCTAssertFalse(Muscle.upperTrapezius.isDrawable) - XCTAssertFalse(Muscle.lowerTrapezius.isDrawable) + func testEveryMuscleIsDrawnSomewhere() { + XCTAssertEqual(Muscle.allCases.filter { !$0.isDrawable }, []) + } + + func testNewSubGroupArtwork() { + for gender in BodyGender.allCases { + XCTAssertTrue(Muscle.rearDeltoid.isDrawable(gender: gender, side: .back)) + XCTAssertTrue(Muscle.upperTrapezius.isDrawable(gender: gender, side: .back)) + XCTAssertTrue(Muscle.upperTrapezius.isDrawable(gender: gender, side: .front)) + XCTAssertTrue(Muscle.lowerTrapezius.isDrawable(gender: gender, side: .back)) + XCTAssertTrue(Muscle.ankles.isDrawable(gender: gender, side: .back)) + } + // The hair covers the whole head on the female back view + XCTAssertFalse(Muscle.head.isDrawable(gender: .female, side: .back)) } func testDrawnMusclesAreReported() { diff --git a/Tests/MuscleMapTests/PathCacheTests.swift b/Tests/MuscleMapTests/PathCacheTests.swift index 9169a07..7785648 100644 --- a/Tests/MuscleMapTests/PathCacheTests.swift +++ b/Tests/MuscleMapTests/PathCacheTests.swift @@ -71,3 +71,41 @@ final class SharedPathCacheTests: XCTestCase { XCTAssertLessThanOrEqual(cache.count, 3) } } + +final class UnitPathCacheTests: XCTestCase { + + /// Scaling a path parsed at unit scale must give the same shape as parsing at that scale. + func testScaledUnitPathMatchesDirectParse() { + let cache = PathCache() + let scale: CGFloat = 0.37, offsetX: CGFloat = -12.5, offsetY: CGFloat = 40 + for gender in BodyGender.allCases { + for side in BodySide.allCases { + for svg in BodyPathProvider.paths(gender: gender, side: side).flatMap(\.allPaths) { + let cached = cache.path(for: svg, scale: scale, offsetX: offsetX, offsetY: offsetY).boundingRect + let direct = PathBuilder.buildPath(from: svg, scale: scale, offsetX: offsetX, offsetY: offsetY).boundingRect + XCTAssertEqual(cached.minX, direct.minX, accuracy: 0.001) + XCTAssertEqual(cached.minY, direct.minY, accuracy: 0.001) + XCTAssertEqual(cached.width, direct.width, accuracy: 0.001) + XCTAssertEqual(cached.height, direct.height, accuracy: 0.001) + } + } + } + } + + func testEachPathIsParsedOnceAcrossSizes() { + let cache = PathCache() + let svg = "M 0 0 L 100 100 L 0 100 Z" + _ = cache.path(for: svg, scale: 1, offsetX: 0, offsetY: 0) + _ = cache.path(for: svg, scale: 2, offsetX: 5, offsetY: 5) + XCTAssertEqual(cache.parsedCount, 1) + XCTAssertEqual(cache.count, 2) + } + + func testPreloadParsesWithoutSizing() { + let cache = PathCache() + let paths = BodyPathProvider.paths(gender: .female, side: .back).flatMap(\.allPaths) + cache.preload(paths) + XCTAssertEqual(cache.parsedCount, Set(paths).count) + XCTAssertEqual(cache.count, 0) + } +} diff --git a/Tests/MuscleMapTests/SnapshotTests.swift b/Tests/MuscleMapTests/SnapshotTests.swift new file mode 100644 index 0000000..5d61a2b --- /dev/null +++ b/Tests/MuscleMapTests/SnapshotTests.swift @@ -0,0 +1,144 @@ +import XCTest +import SwiftUI +import ImageIO +import UniformTypeIdentifiers +@testable import MuscleMap + +/// Renders each body and compares it with a reference image in `__Snapshots__`. +/// +/// Catches artwork and layout regressions such as a body shifted inside its view box. +/// To record new references after an intended visual change, run: +/// +/// MUSCLEMAP_RECORD_SNAPSHOTS=1 swift test --filter SnapshotTests +/// +/// Small anti-aliasing differences between machines are tolerated. +@MainActor +final class SnapshotTests: XCTestCase { + + private let size = CGSize(width: 200, height: 400) + + /// A pixel counts as different when any channel differs by more than this (0-255). + private let channelTolerance = 24 + /// The snapshot fails when more than this fraction of pixels differ. + private let maxDifferentFraction = 0.002 + + // Fixed sRGB colors: system colors such as .red and .orange change between OS + // releases (they did in macOS 26), which would fail snapshots that are still correct. + private static let orange = Color(.sRGB, red: 1.0, green: 0.55, blue: 0.1) + private static let red = Color(.sRGB, red: 0.9, green: 0.2, blue: 0.2) + private static let purple = Color(.sRGB, red: 0.55, green: 0.3, blue: 0.85) + private static let blue = Color(.sRGB, red: 0.15, green: 0.45, blue: 0.95) + private static let teal = Color(.sRGB, red: 0.1, green: 0.65, blue: 0.65) + private static let yellow = Color(.sRGB, red: 1.0, green: 0.85, blue: 0.2) + + private let colorScale = HeatmapColorScale(colors: [ + Color(.sRGB, red: 0.8, green: 0.8, blue: 0.8), SnapshotTests.yellow, SnapshotTests.orange, SnapshotTests.red, + ]) + + private let heatmap: [MuscleIntensity] = [ + MuscleIntensity(muscle: .chest, intensity: 0.9), + MuscleIntensity(muscle: .trapezius, intensity: 0.7), + MuscleIntensity(muscle: .deltoids, intensity: 0.5), + MuscleIntensity(muscle: .upperBack, intensity: 0.8), + MuscleIntensity(muscle: .quadriceps, intensity: 0.6), + MuscleIntensity(muscle: .hamstring, intensity: 0.4), + MuscleIntensity(muscle: .feet, intensity: 1.0), + ] + + func testBodies() throws { + for gender in BodyGender.allCases { + for side in BodySide.allCases { + try assertSnapshot(BodyView(gender: gender, side: side), named: "\(gender.rawValue)_\(side.rawValue)") + try assertSnapshot( + BodyView(gender: gender, side: side).heatmap(heatmap, colorScale: colorScale), + named: "\(gender.rawValue)_\(side.rawValue)_heatmap" + ) + } + } + } + + func testBackRegions() throws { + for gender in BodyGender.allCases { + let view = BodyView(gender: gender, side: .back) + .showSubGroups() + .highlight(.upperTrapezius, color: Self.orange) + .highlight(.rhomboids, color: Self.red) + .highlight(.lowerTrapezius, color: Self.purple) + .highlight(.rearDeltoid, color: Self.blue) + .highlight(.rotatorCuff, color: Self.teal) + try assertSnapshot(view, named: "\(gender.rawValue)_back_regions") + } + } + + // MARK: - Helpers + + private func assertSnapshot(_ view: V, named name: String, file: StaticString = #filePath, line: UInt = #line) throws { + let content = view + .frame(width: size.width, height: size.height) + .background(Color.white) + .environment(\.colorScheme, .light) + let renderer = ImageRenderer(content: content) + renderer.scale = 1 + let actual = try XCTUnwrap(renderer.cgImage, "could not render \(name)", file: file, line: line) + let referenceURL = Self.snapshotDirectory.appendingPathComponent("\(name).png") + + if ProcessInfo.processInfo.environment["MUSCLEMAP_RECORD_SNAPSHOTS"] != nil { + try FileManager.default.createDirectory(at: Self.snapshotDirectory, withIntermediateDirectories: true) + try Self.writePNG(actual, to: referenceURL) + return + } + + guard let source = CGImageSourceCreateWithURL(referenceURL as CFURL, nil), + let reference = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + XCTFail("Missing reference \(referenceURL.lastPathComponent). Record it with MUSCLEMAP_RECORD_SNAPSHOTS=1.", file: file, line: line) + return + } + + let actualPixels = Self.rgba(actual, width: Int(size.width), height: Int(size.height)) + let referencePixels = Self.rgba(reference, width: Int(size.width), height: Int(size.height)) + var different = 0 + for pixel in stride(from: 0, to: actualPixels.count, by: 4) { + for channel in 0..<4 where abs(Int(actualPixels[pixel + channel]) - Int(referencePixels[pixel + channel])) > channelTolerance { + different += 1 + break + } + } + let fraction = Double(different) / Double(actualPixels.count / 4) + if fraction > maxDifferentFraction { + let failureDirectory = ProcessInfo.processInfo.environment["MUSCLEMAP_SNAPSHOT_FAILURES"] + .map { URL(fileURLWithPath: $0) } ?? FileManager.default.temporaryDirectory + try? FileManager.default.createDirectory(at: failureDirectory, withIntermediateDirectories: true) + let failureURL = failureDirectory.appendingPathComponent("\(name)_actual.png") + try? Self.writePNG(actual, to: failureURL) + XCTFail( + "\(name): \(different) pixels differ (\(String(format: "%.2f", fraction * 100))%). Actual image: \(failureURL.path)", + file: file, line: line + ) + } + } + + private static var snapshotDirectory: URL { + URL(fileURLWithPath: #filePath).deletingLastPathComponent().appendingPathComponent("__Snapshots__") + } + + private static func rgba(_ image: CGImage, width: Int, height: Int) -> [UInt8] { + var pixels = [UInt8](repeating: 0, count: width * height * 4) + pixels.withUnsafeMutableBytes { buffer in + let context = CGContext( + data: buffer.baseAddress, width: width, height: height, bitsPerComponent: 8, + bytesPerRow: width * 4, space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + context?.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + } + return pixels + } + + private static func writePNG(_ image: CGImage, to url: URL) throws { + guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else { + throw CocoaError(.fileWriteUnknown) + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { throw CocoaError(.fileWriteUnknown) } + } +} diff --git a/Tests/MuscleMapTests/SubGroupTests.swift b/Tests/MuscleMapTests/SubGroupTests.swift index 5153365..a23ac6b 100644 --- a/Tests/MuscleMapTests/SubGroupTests.swift +++ b/Tests/MuscleMapTests/SubGroupTests.swift @@ -214,10 +214,10 @@ final class SubGroupTests: XCTestCase { func testNewMusclePathsExistInMaleBack() { let paths = BodyPathProvider.paths(gender: .male, side: .back) let slugs = paths.map { $0.slug } - // rearDeltoid, upperTrapezius, lowerTrapezius removed (tiny circular placeholders) - XCTAssertFalse(slugs.contains(.rearDeltoid)) - XCTAssertFalse(slugs.contains(.upperTrapezius)) - XCTAssertFalse(slugs.contains(.lowerTrapezius)) + // Drawn from the deltoid and trapezius outlines, around the rhomboid band + XCTAssertTrue(slugs.contains(.rearDeltoid)) + XCTAssertTrue(slugs.contains(.upperTrapezius)) + XCTAssertTrue(slugs.contains(.lowerTrapezius)) } func testNewMusclePathsExistInFemaleFront() { @@ -233,10 +233,10 @@ final class SubGroupTests: XCTestCase { func testNewMusclePathsExistInFemaleBack() { let paths = BodyPathProvider.paths(gender: .female, side: .back) let slugs = paths.map { $0.slug } - // rearDeltoid, upperTrapezius, lowerTrapezius removed (tiny circular placeholders) - XCTAssertFalse(slugs.contains(.rearDeltoid)) - XCTAssertFalse(slugs.contains(.upperTrapezius)) - XCTAssertFalse(slugs.contains(.lowerTrapezius)) + // Drawn from the deltoid and trapezius outlines, around the rhomboid band + XCTAssertTrue(slugs.contains(.rearDeltoid)) + XCTAssertTrue(slugs.contains(.upperTrapezius)) + XCTAssertTrue(slugs.contains(.lowerTrapezius)) } // MARK: - hideSubGroups diff --git a/Tests/MuscleMapTests/__Snapshots__/female_back.png b/Tests/MuscleMapTests/__Snapshots__/female_back.png new file mode 100644 index 0000000..89ad920 Binary files /dev/null and b/Tests/MuscleMapTests/__Snapshots__/female_back.png differ diff --git a/Tests/MuscleMapTests/__Snapshots__/female_back_heatmap.png b/Tests/MuscleMapTests/__Snapshots__/female_back_heatmap.png new file mode 100644 index 0000000..157463d Binary files /dev/null and b/Tests/MuscleMapTests/__Snapshots__/female_back_heatmap.png differ diff --git a/Tests/MuscleMapTests/__Snapshots__/female_back_regions.png b/Tests/MuscleMapTests/__Snapshots__/female_back_regions.png new file mode 100644 index 0000000..7205edd Binary files /dev/null and b/Tests/MuscleMapTests/__Snapshots__/female_back_regions.png differ diff --git a/Tests/MuscleMapTests/__Snapshots__/female_front.png b/Tests/MuscleMapTests/__Snapshots__/female_front.png new file mode 100644 index 0000000..b096796 Binary files /dev/null and b/Tests/MuscleMapTests/__Snapshots__/female_front.png differ diff --git a/Tests/MuscleMapTests/__Snapshots__/female_front_heatmap.png b/Tests/MuscleMapTests/__Snapshots__/female_front_heatmap.png new file mode 100644 index 0000000..c00f7f7 Binary files /dev/null and b/Tests/MuscleMapTests/__Snapshots__/female_front_heatmap.png differ diff --git a/Tests/MuscleMapTests/__Snapshots__/male_back.png b/Tests/MuscleMapTests/__Snapshots__/male_back.png new file mode 100644 index 0000000..f513ccc Binary files /dev/null and b/Tests/MuscleMapTests/__Snapshots__/male_back.png differ diff --git a/Tests/MuscleMapTests/__Snapshots__/male_back_heatmap.png b/Tests/MuscleMapTests/__Snapshots__/male_back_heatmap.png new file mode 100644 index 0000000..38d4feb Binary files /dev/null and b/Tests/MuscleMapTests/__Snapshots__/male_back_heatmap.png differ diff --git a/Tests/MuscleMapTests/__Snapshots__/male_back_regions.png b/Tests/MuscleMapTests/__Snapshots__/male_back_regions.png new file mode 100644 index 0000000..04958ef Binary files /dev/null and b/Tests/MuscleMapTests/__Snapshots__/male_back_regions.png differ diff --git a/Tests/MuscleMapTests/__Snapshots__/male_front.png b/Tests/MuscleMapTests/__Snapshots__/male_front.png new file mode 100644 index 0000000..9d3be0e Binary files /dev/null and b/Tests/MuscleMapTests/__Snapshots__/male_front.png differ diff --git a/Tests/MuscleMapTests/__Snapshots__/male_front_heatmap.png b/Tests/MuscleMapTests/__Snapshots__/male_front_heatmap.png new file mode 100644 index 0000000..03f9630 Binary files /dev/null and b/Tests/MuscleMapTests/__Snapshots__/male_front_heatmap.png differ diff --git a/Tools/ScreenshotGenerator/Package.swift b/Tools/ScreenshotGenerator/Package.swift new file mode 100644 index 0000000..d5040cf --- /dev/null +++ b/Tools/ScreenshotGenerator/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +// Renders the README screenshots from code: +// swift run --package-path Tools/ScreenshotGenerator ScreenshotGenerator Screenshots +let package = Package( + name: "ScreenshotGenerator", + platforms: [.macOS(.v14)], + dependencies: [ + .package(name: "MuscleMap", path: "../..") + ], + targets: [ + .executableTarget( + name: "ScreenshotGenerator", + dependencies: ["MuscleMap"] + ) + ] +) diff --git a/Tools/ScreenshotGenerator/Sources/ScreenshotGenerator/main.swift b/Tools/ScreenshotGenerator/Sources/ScreenshotGenerator/main.swift new file mode 100644 index 0000000..3416a58 --- /dev/null +++ b/Tools/ScreenshotGenerator/Sources/ScreenshotGenerator/main.swift @@ -0,0 +1,142 @@ +// +// main.swift +// ScreenshotGenerator +// +// Renders the body screenshots used in the README, so they can be +// regenerated whenever the artwork or rendering changes. +// +// Usage: swift run --package-path Tools/ScreenshotGenerator ScreenshotGenerator +// + +import AppKit +import MuscleMap +import SwiftUI + +struct Screenshot { + let name: String + let background: Color + let body: AnyView + + init(_ name: String, background: Color = .white, @ViewBuilder body: () -> V) { + self.name = name + self.background = background + self.body = AnyView(body()) + } +} + +let screenshots: [Screenshot] = [ + Screenshot("male_front_highlight") { + BodyView(gender: .male, side: .front) + .intensities([.chest: 4, .deltoids: 3, .abs: 1, .biceps: 2, .forearm: 1, .quadriceps: 4, .obliques: 2]) + }, + Screenshot("male_back_highlight") { + BodyView(gender: .male, side: .back) + .intensities([.trapezius: 3, .upperBack: 4, .lowerBack: 2, .triceps: 2, .gluteal: 3, .hamstring: 4, .deltoids: 1]) + }, + Screenshot("female_front_highlight") { + BodyView(gender: .female, side: .front) + .intensities([.chest: 4, .deltoids: 4, .abs: 3, .quadriceps: 4, .calves: 1, .biceps: 2]) + }, + Screenshot("female_back_highlight") { + BodyView(gender: .female, side: .back) + .intensities([.trapezius: 3, .upperBack: 2, .gluteal: 4, .hamstring: 4, .calves: 1]) + }, + Screenshot("gradient_linear") { + BodyView(gender: .male, side: .front) + .highlight(.chest, linearGradient: [.red, .orange], startPoint: .top, endPoint: .bottom) + .highlight(.abs, linearGradient: [.orange, .yellow], startPoint: .top, endPoint: .bottom) + .highlight(.deltoids, linearGradient: [.orange, .yellow], startPoint: .top, endPoint: .bottom) + .highlight(.biceps, radialGradient: [.white, .blue], center: .center, endRadius: 40) + .highlight(.quadriceps, color: .purple) + }, + Screenshot("gradient_radial") { + BodyView(gender: .male, side: .front) + .highlight(.chest, radialGradient: [.white, .red], center: .center, endRadius: 60) + .highlight(.deltoids, radialGradient: [.yellow, .orange], center: .center, endRadius: 40) + .highlight(.biceps, radialGradient: [.white, .blue], center: .center, endRadius: 40) + .highlight(.quadriceps, radialGradient: [.white, .purple], center: .center, endRadius: 60) + }, + Screenshot("gradient_neon", background: .black) { + BodyView(gender: .male, side: .front) + .bodyStyle(.neon) + .highlight(.chest, linearGradient: [.cyan, .blue], startPoint: .top, endPoint: .bottom) + .highlight(.biceps, linearGradient: [.green, .teal], startPoint: .top, endPoint: .bottom) + .highlight(.quadriceps, linearGradient: [.cyan, .blue], startPoint: .top, endPoint: .bottom) + }, + Screenshot("heatmap_workout") { + BodyView(gender: .male, side: .front) + .intensities([.chest: 3, .biceps: 2, .quadriceps: 4, .abs: 1, .deltoids: 3, .forearm: 1]) + }, + Screenshot("heatmap_thermal") { + BodyView(gender: .male, side: .back) + .heatmap([ + MuscleIntensity(muscle: .trapezius, intensity: 0.7), + MuscleIntensity(muscle: .upperBack, intensity: 1.0), + MuscleIntensity(muscle: .triceps, intensity: 0.5), + MuscleIntensity(muscle: .lowerBack, intensity: 0.6), + MuscleIntensity(muscle: .gluteal, intensity: 1.0), + MuscleIntensity(muscle: .hamstring, intensity: 0.8), + MuscleIntensity(muscle: .calves, intensity: 0.3), + ], colorScale: .thermal) + }, + Screenshot("heatmap_stepped") { + BodyView(gender: .male, side: .front) + .heatmap([ + MuscleIntensity(muscle: .chest, intensity: 0.95), + MuscleIntensity(muscle: .deltoids, intensity: 0.7), + MuscleIntensity(muscle: .abs, intensity: 0.45), + MuscleIntensity(muscle: .biceps, intensity: 0.3), + MuscleIntensity(muscle: .quadriceps, intensity: 0.85), + MuscleIntensity(muscle: .forearm, intensity: 0.25), + ], colorScale: .workoutStepped) + }, + Screenshot("back_regions") { + BodyView(gender: .male, side: .back) + .showSubGroups() + .highlight(.upperTrapezius, color: .orange) + .highlight(.rhomboids, color: .red) + .highlight(.lowerTrapezius, color: .purple) + .highlight(.rearDeltoid, color: .blue) + .highlight(.rotatorCuff, color: .teal) + }, + Screenshot("style_neon", background: .black) { + BodyView(gender: .male, side: .front) + .bodyStyle(.neon) + .highlight(.chest, color: .red) + .highlight(.deltoids, color: .orange) + .highlight(.quadriceps, color: .red, opacity: 0.8) + }, + Screenshot("style_medical") { + BodyView(gender: .female, side: .front) + .bodyStyle(.medical) + .highlight(.abs, color: .cyan) + .highlight(.chest, color: .blue) + .highlight(.quadriceps, color: .blue, opacity: 0.8) + }, +] + +@MainActor +func render(_ screenshot: Screenshot, to directory: URL) throws { + let content = screenshot.body + .frame(width: 270, height: 470) + .frame(width: 300, height: 500) + .background(screenshot.background) + .environment(\.colorScheme, .light) + let renderer = ImageRenderer(content: content) + renderer.scale = 2 + guard let image = renderer.cgImage, + let png = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) else { + throw CocoaError(.fileWriteUnknown) + } + let url = directory.appendingPathComponent("\(screenshot.name).png") + try png.write(to: url) + print("Wrote \(url.path)") +} + +let outputDirectory = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "Screenshots") +try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) +try MainActor.assumeIsolated { + for screenshot in screenshots { + try render(screenshot, to: outputDirectory) + } +}