import Foundation import SwiftUI // ============================================================================= // MARK: - Grid-Based Embroidery Pattern Generator // ============================================================================= // MARK: - Grid Cell struct GridCell { let row: Int let col: Int let smellType: String? let stitchType: StitchType? let color: String? let flowAngle: Double // Rotation angle for stitch } enum StitchType: String, CaseIterable { case cross case backStitch case frenchKnot case satin case seed case running } // MARK: - Embroidery Spec struct EmbroiderySpec { let gridSize: Int let grid: [[GridCell]] let stitchCount: Int let hoopSize: String let hoopWidthMM: Double let hoopHeightMM: Double let threadColors: Int let estimatedMinutes: Int let threadPalette: [ThreadColor] let warnings: [String] let isWithinLimits: Bool let svgContent: String } struct ThreadColor: Identifiable { let id = UUID() let smellName: String let hex: String let dmcCode: String let brotherCode: String let stitchType: StitchType let length: String } // MARK: - Embroidery Generator class EmbroideryGenerator { static let shared = EmbroideryGenerator() private let maxHoopWidth: Double = 127 private let maxHoopHeight: Double = 178 private let maxStitches: Int = 120000 private let smellMapping: [(smell: String, keyPath: KeyPath, color: String, hex: String, dmc: String, brother: String, stitch: StitchType)] = [ ("Fruity", \SmellProfile.fruity, "Tangerine", "#FF8033", "DMC 740", "BET 208", .cross), ("Floral", \SmellProfile.floral, "Rose Pink", "#FF4DB8", "DMC 603", "BET 107", .frenchKnot), ("Green", \SmellProfile.green, "Spring Green", "#4DF24D", "DMC 704", "BET 502", .backStitch), ("Woody", \SmellProfile.woody, "Chestnut", "#B36633", "DMC 975", "BET 339", .satin), ("Spicy", \SmellProfile.spicy, "Crimson", "#FF3333", "DMC 321", "BET 800", .cross), ("Chemical", \SmellProfile.chemical, "Electric Blue", "#4DB3FF", "DMC 996", "BET 019", .running), ("Decay", \SmellProfile.decay, "Deep Violet", "#9A4D9A", "DMC 552", "BET 614", .seed) ] // MARK: - Generate func generateEmbroidery(from profile: SmellProfile, gridSize: Int = 24) -> EmbroiderySpec { let activeSmells = getActiveSmells(from: profile) guard !activeSmells.isEmpty else { return emptySpec(gridSize: gridSize) } let grid = buildGrid(size: gridSize, activeSmells: activeSmells) let stitchCount = countStitches(grid: grid) let svgContent = generateSVG(grid: grid, gridSize: gridSize, activeSmells: activeSmells) let threadPalette = activeSmells.map { smell in ThreadColor( smellName: smell.smell, hex: smell.hex, dmcCode: smell.dmc, brotherCode: smell.brother, stitchType: smell.stitch, length: "~\(max(1, smell.value * 3)) yd" ) } // Match SVG scale: 4.5mm per cell let cellSizeMM: Double = 4.5 let designSizeMM = Double(gridSize) * cellSizeMM // 108mm for 24 grid var warnings: [String] = [] var isWithinLimits = true if designSizeMM > maxHoopWidth { warnings.append("Design is \(Int(designSizeMM))mm wide") isWithinLimits = false } return EmbroiderySpec( gridSize: gridSize, grid: grid, stitchCount: stitchCount, hoopSize: "5×7 in (\(Int(designSizeMM))mm)", hoopWidthMM: designSizeMM, hoopHeightMM: designSizeMM, threadColors: activeSmells.count, estimatedMinutes: max(1, stitchCount / 600), threadPalette: threadPalette, warnings: warnings, isWithinLimits: isWithinLimits, svgContent: svgContent ) } // MARK: - Build Grid (Layered Coexistence System) // Each smell has a characteristic flow pattern private func smellFlow(_ smell: String) -> (angle: Double, curve: Double, speed: Double) { switch smell { case "Fruity": return (angle: 0, curve: 0.3, speed: 0.6) case "Floral": return (angle: .pi / 6, curve: 0.5, speed: 0.4) case "Green": return (angle: .pi / 4, curve: 0.6, speed: 0.5) case "Woody": return (angle: .pi / 2, curve: 0.1, speed: 0.3) case "Spicy": return (angle: 0, curve: 0.2, speed: 0.8) case "Chemical": return (angle: -.pi / 6, curve: 0.05, speed: 0.9) case "Decay": return (angle: .pi / 2, curve: 0.4, speed: 0.2) default: return (angle: 0, curve: 0.3, speed: 0.5) } } private func buildGrid(size: Int, activeSmells: [(smell: String, value: Int, color: String, hex: String, dmc: String, brother: String, stitch: StitchType)]) -> [[GridCell]] { let center = Double(size) / 2.0 let radius = Double(size) / 2.0 - 0.5 // Place smell sources var sources: [(smell: String, stitch: StitchType, x: Double, y: Double, strength: Double, flow: (angle: Double, curve: Double, speed: Double))] = [] let totalStrength = Double(activeSmells.reduce(0) { $0 + $1.value }) let count = activeSmells.count for (i, smell) in activeSmells.enumerated() { let theta = Double(i) * (2.0 * .pi / Double(count)) - .pi / 2 let normalizedStrength = Double(smell.value) / totalStrength let dist = radius * (0.15 + (1.0 - normalizedStrength) * 0.55) sources.append(( smell.smell, smell.stitch, center + cos(theta) * dist, center + sin(theta) * dist, Double(smell.value), smellFlow(smell.smell) )) } // Noise functions func noise(_ x: Double, _ y: Double, _ seed: Double) -> Double { let n = sin(seed * 12.9898 + x * 78.233 + y * 43.758) * 43758.5453 return n - floor(n) } func layeredNoise(_ x: Double, _ y: Double, _ seed: Double) -> Double { var val = 0.0 var amp = 1.0 var freq = 1.0 for _ in 0..<3 { val += noise(x * freq, y * freq, seed) * amp amp *= 0.5 freq *= 2.0 } return val / 1.75 } var grid: [[GridCell]] = [] for row in 0.. $1.presence } // Normalize presences let presences = sorted.map { $0.presence / totalPresence } // LAYERED DECISION: // - Primary (>40% presence): controls stitch type // - Secondary (>20%): can interrupt with its stitch // - Tertiary: influences which stitch appears guard let primary = sorted.first else { rowCells.append(GridCell(row: row, col: col, smellType: nil, stitchType: nil, color: nil, flowAngle: 0)) continue } var finalStitch = primary.stitch var finalSmell = primary.smell var finalFlowAngle = primary.flowAngle // Secondary can "interrupt" with its stitch type if sorted.count > 1 && presences[1] > 0.2 { let secondary = sorted[1] let interruptChance = presences[1] * 0.6 if layeredNoise(px * 0.35, py * 0.35, 123.0) < interruptChance { finalStitch = secondary.stitch finalSmell = secondary.smell finalFlowAngle = secondary.flowAngle } } // Tertiary can also appear occasionally if sorted.count > 2 && presences[2] > 0.15 { let tertiary = sorted[2] let tertiaryChance = presences[2] * 0.4 if layeredNoise(px * 0.5, py * 0.5, 456.0) < tertiaryChance { finalStitch = tertiary.stitch finalSmell = tertiary.smell finalFlowAngle = tertiary.flowAngle } } rowCells.append(GridCell( row: row, col: col, smellType: finalSmell, stitchType: finalStitch, color: nil, flowAngle: finalFlowAngle )) } else { rowCells.append(GridCell(row: row, col: col, smellType: nil, stitchType: nil, color: nil, flowAngle: 0)) } } grid.append(rowCells) } return grid } // MARK: - Count Stitches private func countStitches(grid: [[GridCell]]) -> Int { var count = 0 for row in grid { for cell in row { guard let stitch = cell.stitchType else { continue } switch stitch { case .cross: count += 4 case .backStitch: count += 2 case .frenchKnot: count += 8 case .satin: count += 6 case .seed: count += 3 case .running: count += 2 } } } return count } // MARK: - Generate SVG (Ink/Stitch Compatible) private func generateSVG(grid: [[GridCell]], gridSize: Int, activeSmells: [(smell: String, value: Int, color: String, hex: String, dmc: String, brother: String, stitch: StitchType)]) -> String { // Brother SE1900 hoop: 127mm × 178mm (5" × 7") // Target design: fit within 120mm width with margin // 24 cells × 4.5mm = 108mm design + 10mm padding = 118mm total let cellSizeMM: Double = 4.5 let designSizeMM = Double(gridSize) * cellSizeMM // 108mm for 24 grid let paddingMM: Double = 5.0 let totalSizeMM = designSizeMM + paddingMM * 2 // 118mm fits in 127mm hoop let stitchColor = "#333333" var stitchPaths = "" for row in grid { for cell in row { guard let stitch = cell.stitchType else { continue } let cx = paddingMM + Double(cell.col) * cellSizeMM + cellSizeMM / 2 let cy = paddingMM + Double(cell.row) * cellSizeMM + cellSizeMM / 2 let size = cellSizeMM / 2 - 0.5 let inkstitchAttrs = inkstitchAttributes(for: stitch) stitchPaths += renderInkstitchPath( stitch: stitch, cx: cx, cy: cy, size: size, color: stitchColor, attrs: inkstitchAttrs, angle: cell.flowAngle ) } } let sizeLabel = "\(gridSize) × \(gridSize)" return """ Whiff Embroidery Pattern - \(sizeLabel) \(stitchPaths) """ } // MARK: - Ink/Stitch Attributes private func inkstitchAttributes(for stitch: StitchType) -> String { switch stitch { case .cross: return "inkstitch:running_stitch_length_mm=\"2\" inkstitch:repeats=\"1\"" case .backStitch: return "inkstitch:running_stitch_length_mm=\"2.5\" inkstitch:repeats=\"1\"" case .frenchKnot: return "inkstitch:running_stitch_length_mm=\"1\" inkstitch:bean_stitch_repeats=\"2\" inkstitch:repeats=\"3\"" case .satin: return "inkstitch:satin_column=\"true\" inkstitch:zigzag_spacing_mm=\"0.4\" inkstitch:pull_compensation_mm=\"0.2\"" case .seed: return "inkstitch:running_stitch_length_mm=\"1.5\" inkstitch:repeats=\"1\"" case .running: return "inkstitch:running_stitch_length_mm=\"3\" inkstitch:repeats=\"1\"" } } // MARK: - Render Ink/Stitch Path (with rotation) private func renderInkstitchPath(stitch: StitchType, cx: Double, cy: Double, size: Double, color: String, attrs: String, angle: Double) -> String { // Helper to rotate a point around center func rotate(_ x: Double, _ y: Double) -> (Double, Double) { let cos_a = cos(angle) let sin_a = sin(angle) let rx = (x - cx) * cos_a - (y - cy) * sin_a + cx let ry = (x - cx) * sin_a + (y - cy) * cos_a + cy return (rx, ry) } switch stitch { case .cross: // X rotated by flow angle let (x1, y1) = rotate(cx - size, cy - size) let (x2, y2) = rotate(cx + size, cy + size) let (x3, y3) = rotate(cx + size, cy - size) let (x4, y4) = rotate(cx - size, cy + size) return """ """ case .backStitch: // Horizontal line rotated let (x1, y1) = rotate(cx - size, cy) let (x2, y2) = rotate(cx + size, cy) return """ """ case .frenchKnot: // Circle doesn't need rotation, but we can offset slightly along flow let r = size * 0.4 return """ """ case .satin: // Vertical satin column rotated by flow let halfWidth = size * 0.3 let (x1, y1) = rotate(cx - halfWidth, cy - size) let (x2, y2) = rotate(cx - halfWidth, cy + size) let (x3, y3) = rotate(cx + halfWidth, cy + size) let (x4, y4) = rotate(cx + halfWidth, cy - size) return """ """ case .seed: // Scattered dots rotated let offsets: [(Double, Double)] = [(-size * 0.4, -size * 0.4), (size * 0.4, 0), (0, size * 0.4)] var paths = "" for (dx, dy) in offsets { let (x1, y1) = rotate(cx + dx - 0.8, cy + dy - 0.8) let (x2, y2) = rotate(cx + dx + 0.8, cy + dy + 0.8) paths += "\n" } return paths case .running: // Dashed line rotated let gap = size * 0.3 let (x1, y1) = rotate(cx - size, cy) let (x2, y2) = rotate(cx - gap, cy) let (x3, y3) = rotate(cx + gap, cy) let (x4, y4) = rotate(cx + size, cy) return """ """ } } // MARK: - Helpers private func getActiveSmells(from profile: SmellProfile) -> [(smell: String, value: Int, color: String, hex: String, dmc: String, brother: String, stitch: StitchType)] { return smellMapping.compactMap { mapping in let value = profile[keyPath: mapping.keyPath] return value > 0 ? (mapping.smell, value, mapping.color, mapping.hex, mapping.dmc, mapping.brother, mapping.stitch) : nil }.sorted { $0.value > $1.value } } private func emptySpec(gridSize: Int) -> EmbroiderySpec { return EmbroiderySpec( gridSize: gridSize, grid: [], stitchCount: 0, hoopSize: "5x7 in", hoopWidthMM: 127, hoopHeightMM: 178, threadColors: 0, estimatedMinutes: 0, threadPalette: [], warnings: ["No smell data"], isWithinLimits: true, svgContent: "" ) } } // ============================================================================= // MARK: - Preview Views (Matching ContentView Design System) // ============================================================================= struct EmbroideryPreviewView: View { let spec: EmbroiderySpec let profile: SmellProfile let onExport: () -> Void let onClose: () -> Void @State private var showingExportOptions = false @Environment(\.colorScheme) var colorScheme private var isDark: Bool { colorScheme == .dark } var body: some View { ZStack { // Background - matches WhiffColors.cream Color(uiColor: .systemBackground) .ignoresSafeArea() ScrollView(showsIndicators: false) { VStack(spacing: 0) { // Header HStack { Button(action: onClose) { Image(systemName: "chevron.left") .font(.system(size: 18, weight: .semibold)) .foregroundColor(Color(uiColor: .label)) } Spacer() } .padding(.horizontal, 24) .padding(.top, 16) Spacer().frame(height: 40) // Section header - matches "SMELL ATMOSPHERE" style Text("STITCH PATTERN") .font(.system(size: 11, weight: .semibold, design: .rounded)) .foregroundColor(Color(uiColor: .secondaryLabel)) .tracking(0.9) Spacer().frame(height: 24) // The stitch grid StitchGridView(spec: spec) .frame(width: 280, height: 280) // Grid size Text("\(spec.gridSize) × \(spec.gridSize)") .font(.system(size: 10, weight: .regular, design: .monospaced)) .foregroundColor(Color(uiColor: .tertiaryLabel)) .padding(.top, 12) Spacer().frame(height: 48) // Stitches section VStack(alignment: .leading, spacing: 16) { Text("STITCHES") .font(.system(size: 10, weight: .semibold, design: .rounded)) .foregroundColor(Color(uiColor: .secondaryLabel)) .tracking(1) .padding(.leading, 4) ForEach(spec.threadPalette) { thread in StitchRow(thread: thread, isDark: isDark) } } .padding(.horizontal, 24) Spacer().frame(height: 40) // Export button - matches Save/Stitch button style Button(action: { showingExportOptions = true }) { HStack(spacing: 8) { Image(systemName: "square.and.arrow.up") .font(.system(size: 14, weight: .semibold)) Text("Export SVG") .font(.system(size: 13, weight: .medium, design: .rounded)) } .foregroundColor(Color(red: 0.0, green: 0.48, blue: 1.0)) .padding(.horizontal, 20) .padding(.vertical, 12) .background(Color(uiColor: .secondarySystemBackground)) .cornerRadius(20) } Spacer().frame(height: 40) } } } .confirmationDialog("Export", isPresented: $showingExportOptions) { Button("Export SVG") { exportSVG() } Button("Save to Photos") { onExport() } Button("Cancel", role: .cancel) {} } } private func exportSVG() { guard let data = spec.svgContent.data(using: .utf8) else { return } let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("whiff_stitch_pattern.svg") do { try data.write(to: tempURL) let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first { window.rootViewController?.present(activityVC, animated: true) } } catch { print("SVG export failed: \(error)") } } } // MARK: - Stitch Row struct StitchRow: View { let thread: ThreadColor let isDark: Bool var body: some View { HStack(spacing: 14) { // Stitch symbol StitchSymbolView(stitch: thread.stitchType) .frame(width: 28, height: 28) // Smell name Text(thread.smellName) .font(.system(size: 14, weight: .medium, design: .rounded)) .foregroundColor(Color(uiColor: .label)) Spacer() // Stitch type Text(stitchName(thread.stitchType)) .font(.system(size: 12, weight: .regular, design: .rounded)) .foregroundColor(Color(uiColor: .secondaryLabel)) } .padding(.horizontal, 16) .padding(.vertical, 12) .background(Color(uiColor: .secondarySystemBackground)) .cornerRadius(12) } private func stitchName(_ stitch: StitchType) -> String { switch stitch { case .cross: return "Cross" case .backStitch: return "Back" case .frenchKnot: return "Knot" case .satin: return "Satin" case .seed: return "Seed" case .running: return "Running" } } } // MARK: - Stitch Grid View struct StitchGridView: View { let spec: EmbroiderySpec private let stitchColor = Color(uiColor: .label) var body: some View { GeometryReader { geo in let size = min(geo.size.width, geo.size.height) let cellSize = size / CGFloat(spec.gridSize) Canvas { context, canvasSize in context.fill(Path(CGRect(origin: .zero, size: canvasSize)), with: .color(Color(uiColor: .systemBackground))) for row in spec.grid { for cell in row { guard let stitch = cell.stitchType else { continue } let cx = CGFloat(cell.col) * cellSize + cellSize / 2 let cy = CGFloat(cell.row) * cellSize + cellSize / 2 let halfCell = cellSize / 2 - 2 drawStitch(context: context, stitch: stitch, cx: cx, cy: cy, size: halfCell, angle: CGFloat(cell.flowAngle)) } } } .frame(width: size, height: size) } .aspectRatio(1, contentMode: .fit) } private func drawStitch(context: GraphicsContext, stitch: StitchType, cx: CGFloat, cy: CGFloat, size: CGFloat, angle: CGFloat) { let lw: CGFloat = 1.5 // Helper to rotate a point around center func rotate(_ x: CGFloat, _ y: CGFloat) -> CGPoint { let cos_a = cos(angle) let sin_a = sin(angle) let rx = (x - cx) * cos_a - (y - cy) * sin_a + cx let ry = (x - cx) * sin_a + (y - cy) * cos_a + cy return CGPoint(x: rx, y: ry) } switch stitch { case .cross: var path = Path() path.move(to: rotate(cx - size, cy - size)) path.addLine(to: rotate(cx + size, cy + size)) context.stroke(path, with: .color(stitchColor), style: StrokeStyle(lineWidth: lw, lineCap: .round)) path = Path() path.move(to: rotate(cx + size, cy - size)) path.addLine(to: rotate(cx - size, cy + size)) context.stroke(path, with: .color(stitchColor), style: StrokeStyle(lineWidth: lw, lineCap: .round)) case .backStitch: var path = Path() path.move(to: rotate(cx - size, cy)) path.addLine(to: rotate(cx + size, cy)) context.stroke(path, with: .color(stitchColor), style: StrokeStyle(lineWidth: lw, lineCap: .round)) case .frenchKnot: // Circle doesn't rotate let rect = CGRect(x: cx - size * 0.4, y: cy - size * 0.4, width: size * 0.8, height: size * 0.8) context.fill(Path(ellipseIn: rect), with: .color(stitchColor)) case .satin: let sp = size * 0.4 for offset in [-sp, 0, sp] { var path = Path() path.move(to: rotate(cx + offset, cy - size)) path.addLine(to: rotate(cx + offset, cy + size)) context.stroke(path, with: .color(stitchColor), style: StrokeStyle(lineWidth: lw, lineCap: .round)) } case .seed: for (dx, dy) in [(-size * 0.4, -size * 0.4), (size * 0.4, CGFloat(0)), (CGFloat(0), size * 0.4)] { let center = rotate(cx + dx, cy + dy) let rect = CGRect(x: center.x - 1.5, y: center.y - 1.5, width: 3, height: 3) context.fill(Path(ellipseIn: rect), with: .color(stitchColor)) } case .running: var path = Path() path.move(to: rotate(cx - size, cy)) path.addLine(to: rotate(cx - size * 0.4, cy)) context.stroke(path, with: .color(stitchColor), style: StrokeStyle(lineWidth: lw, lineCap: .round)) path = Path() path.move(to: rotate(cx + size * 0.4, cy)) path.addLine(to: rotate(cx + size, cy)) context.stroke(path, with: .color(stitchColor), style: StrokeStyle(lineWidth: lw, lineCap: .round)) } } } // MARK: - Stitch Symbol View struct StitchSymbolView: View { let stitch: StitchType private let color = Color(uiColor: .label) var body: some View { Canvas { context, size in let cx = size.width / 2 let cy = size.height / 2 let s = min(size.width, size.height) / 2 - 4 switch stitch { case .cross: var path = Path() path.move(to: CGPoint(x: cx - s, y: cy - s)) path.addLine(to: CGPoint(x: cx + s, y: cy + s)) context.stroke(path, with: .color(color), lineWidth: 2) path = Path() path.move(to: CGPoint(x: cx + s, y: cy - s)) path.addLine(to: CGPoint(x: cx - s, y: cy + s)) context.stroke(path, with: .color(color), lineWidth: 2) case .backStitch: var path = Path() path.move(to: CGPoint(x: cx - s, y: cy)) path.addLine(to: CGPoint(x: cx + s, y: cy)) context.stroke(path, with: .color(color), lineWidth: 2) case .frenchKnot: context.fill(Path(ellipseIn: CGRect(x: cx - s * 0.5, y: cy - s * 0.5, width: s, height: s)), with: .color(color)) case .satin: for offset in [-s * 0.4, CGFloat(0), s * 0.4] { var path = Path() path.move(to: CGPoint(x: cx + offset, y: cy - s)) path.addLine(to: CGPoint(x: cx + offset, y: cy + s)) context.stroke(path, with: .color(color), lineWidth: 1.5) } case .seed: for (dx, dy) in [(-s * 0.4, -s * 0.4), (s * 0.4, CGFloat(0)), (CGFloat(0), s * 0.4)] { context.fill(Path(ellipseIn: CGRect(x: cx + dx - 2, y: cy + dy - 2, width: 4, height: 4)), with: .color(color)) } case .running: var path = Path() path.move(to: CGPoint(x: cx - s, y: cy)) path.addLine(to: CGPoint(x: cx - 2, y: cy)) context.stroke(path, with: .color(color), lineWidth: 2) path = Path() path.move(to: CGPoint(x: cx + 2, y: cy)) path.addLine(to: CGPoint(x: cx + s, y: cy)) context.stroke(path, with: .color(color), lineWidth: 2) } } } }