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 }
}