Zum Inhalt

apple/PhysarumApp/Sources/Model/PresetCatalog.swift

import Foundation
import CryptoKit
import PhysarumEngine

enum PresetCatalog {
    /// A stable, content-addressed thumbnail key: the SHA-256 of the canonically-encoded params. Any
    /// change to the look (a local edit OR a synced change) yields a new key, so a stale render is
    /// never shown and identical content dedupes — and it is device-consistent (the key derives from
    /// the synced params, even though the rendered PNG itself stays device-local).
    static func thumbnailKey(forParams params: PresetParams) -> String {
        let enc = JSONEncoder(); enc.outputFormatting = .sortedKeys
        guard let data = try? enc.encode(params) else { return "params" }
        let hex = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
        return "h-" + String(hex.prefix(16))
    }
    static func thumbnailKey(for preset: Preset) -> String { thumbnailKey(forParams: preset.params) }
    /// All built-in presets bundled under Resources/Presets, decoded with the
    /// engine's Codable model. Sorted by display name for a stable browser order.
    static func loadAll(bundle: Bundle = .main) -> [Preset] {
        let urls = bundle.urls(forResourcesWithExtension: "json", subdirectory: "Presets")
            ?? bundle.urls(forResourcesWithExtension: "json", subdirectory: nil)
            ?? []
        let decoder = JSONDecoder()
        return urls
            .compactMap { try? decoder.decode(Preset.self, from: Data(contentsOf: $0)) }
            .sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
    }

    /// Thumbnail file URL for a preset id (Resources/Thumbnails/<id>.png), if present.
    static func thumbnailURL(for id: String, bundle: Bundle = .main) -> URL? {
        bundle.url(forResource: id, withExtension: "png", subdirectory: "Thumbnails")
            ?? bundle.url(forResource: id, withExtension: "png", subdirectory: nil)
    }

    /// Thumbnail URL preferring a custom (user-saved) PNG over a bundled one.
    static func thumbnailURL(for id: String, store: PresetStore, bundle: Bundle = .main) -> URL? {
        store.thumbnailURL(for: id) ?? thumbnailURL(for: id, bundle: bundle)
    }

    /// Thumbnail URL for a *slot's* preset. The user's local render (keyed by the live id, e.g.
    /// `slot-03`) wins; else the curated bundled art keyed by the stable `thumbnail` key — a factory
    /// slot carries its original content id there, so the bundled art still resolves even though the
    /// slot cutover rewrote the preset id to `slot-NN`; else a bundled PNG named after the id.
    static func thumbnailURL(for preset: Preset, store: PresetStore, bundle: Bundle = .main) -> URL? {
        // The user's local render is content-addressed (so it tracks the live params, not the slot
        // position); else the curated bundled art (stable `thumbnail` key for factory slots); else a
        // bundled PNG named after the id.
        store.thumbnailURL(for: thumbnailKey(for: preset))
            ?? preset.thumbnail.flatMap { thumbnailURL(for: $0, bundle: bundle) }
            ?? thumbnailURL(for: preset.id, bundle: bundle)
    }

    /// A safe default preset so the app launches even if no presets are bundled
    /// (keeps the browser non-empty → no index-out-of-range on selection).
    static var fallbackPreset: Preset {
        let json = #"""
        {"id":"_fallback","name":"Default","builtIn":true,"tags":[],"tagSignal":"pearl","params":{
          "agentCount":50000,"spawnPattern":"random",
          "species":[{"color":[0.9,0.6,0.2],"populationShare":1,"sensorAngle":0.5,"sensorDistance":12,"turnSpeed":0.3,"moveSpeed":1,"depositAmount":4},
                     {"color":[0,0,0],"populationShare":0,"sensorAngle":0,"sensorDistance":1,"turnSpeed":0,"moveSpeed":0,"depositAmount":0},
                     {"color":[0,0,0],"populationShare":0,"sensorAngle":0,"sensorDistance":1,"turnSpeed":0,"moveSpeed":0,"depositAmount":0}],
          "interactionMatrix":[[1,0,0],[0,1,0],[0,0,1]],
          "decayRate":0.96,"diffusionFreq":2,"fadeStrength":0.05,"trailSaturation":150,
          "chaosInterval":0,"chaosStrength":0,"rhythmAmplitude":0,"simSpeed":1,
          "backgroundColor":[0,0,0],"blendMode":"additive","exposure":1.2,"headIntensity":0,"kaleidoscope":0,
          "feedbackAmount":0.2,"bloomThreshold":0.2,"bloomIntensity":1.5,"vignette":0.3,"saturation":1.1,"contrast":1,
          "audioReactive":{"enabled":false,"gain":1,"autoGain":false,"routes":[]}}}
        """#
        // Force-unwrap is safe: this is a known-valid literal compiled into the app.
        return try! JSONDecoder().decode(Preset.self, from: Data(json.utf8))
    }

    static var fallbackParams: PresetParams { fallbackPreset.params }
}