Zum Inhalt

apple/PhysarumApp/Sources/Model/AppState+Exploration.swift

import SwiftUI
import PhysarumEngine

extension AppState {
    // MARK: - Undo / Redo

    private static let undoCap = 50

    var canUndo: Bool { !undoStack.isEmpty }
    var canRedo: Bool { !redoStack.isEmpty }

    /// Record a restore point. Call BEFORE a committed mutation (slider gesture
    /// start, tap-to-type, reset, randomize, mutate, morph-apply). Pass an explicit
    /// snapshot when the mutation has already happened (e.g. morph preview).
    func pushUndo(_ snapshot: PresetParams? = nil) {
        undoStack.append(snapshot ?? edited)
        if undoStack.count > Self.undoCap { undoStack.removeFirst() }
        redoStack.removeAll()
    }

    func undoEdit() {
        guard let prev = undoStack.popLast() else { return }
        redoStack.append(edited)
        edited = prev
    }

    func redoEdit() {
        guard let next = redoStack.popLast() else { return }
        undoStack.append(edited)
        edited = next
    }

    // MARK: - Locks

    func isLocked(_ id: ParamID) -> Bool { locked.contains(id) }

    func toggleLock(_ id: ParamID) {
        if locked.contains(id) { locked.remove(id) } else { locked.insert(id) }
    }

    // MARK: - Randomize

    /// Replace every non-locked param with a fresh value sampled from its safe band.
    /// Scalars come from `RandomizeProfiles`; non-scalars (enums/colors) get bespoke,
    /// taste-tuned randomization. Pushes one undo snapshot.
    func randomize<G: RandomNumberGenerator>(using g: inout G) {
        pushUndo()
        var p = edited
        for d in ParamRegistry.all where !isLocked(d.id) {
            d.set(&p, RandomizeProfiles.strategy(for: d.id).sample(using: &g))
        }
        randomizeNonScalars(into: &p, using: &g)
        edited = p
    }

    /// Convenience entry point for the UI (system RNG).
    func randomize() {
        var g = SystemRandomNumberGenerator()
        randomize(using: &g)
    }

    private func randomizeNonScalars<G: RandomNumberGenerator>(into p: inout PresetParams, using g: inout G) {
        if !isLocked(.spawnPattern) {
            let r = Double.random(in: 0...1, using: &g)
            p.spawnPattern = r < 0.6 ? .random : (r < 0.8 ? .clusters : (r < 0.92 ? .center : .ring))
        }
        if !isLocked(.blendMode) {
            p.blendMode = Double.random(in: 0...1, using: &g) < 0.85 ? .additive : .paint
        }
        for i in 0..<3 where !isLocked(.speciesColor(i)) {
            // vivid: random hue, high saturation/value so trails read against a dark field
            p.species[i].color = hsv2rgb(Double.random(in: 0...1, using: &g),
                                         Double.random(in: 0.6...1, using: &g),
                                         Double.random(in: 0.7...1, using: &g))
        }
        if !isLocked(.backgroundColor) {
            // keep luminance very low, vary hue subtly
            p.backgroundColor = hsv2rgb(Double.random(in: 0...1, using: &g),
                                        Double.random(in: 0...0.4, using: &g),
                                        Double.random(in: 0...0.06, using: &g))
        }
    }

    // MARK: - Mutate

    /// Perturb every non-locked scalar by ±strength × its safe-band width, clamped
    /// to the safe band. Discrete enums are left as-is; colors get a small nudge.
    func mutate<G: RandomNumberGenerator>(strength: Double = 0.15, using g: inout G) {
        pushUndo()
        var p = edited
        for d in ParamRegistry.all where !isLocked(d.id) {
            let band = RandomizeProfiles.strategy(for: d.id).span
            let width = band.upperBound - band.lowerBound
            guard width > 0 else { continue }     // simSpeed (1...1) etc. — no-op
            let delta = Double.random(in: -1...1, using: &g) * strength * width
            // clamp to the safe band, but never outside the descriptor's legal range
            let lo = max(band.lowerBound, d.range.lowerBound)
            let hi = min(band.upperBound, d.range.upperBound)
            let v = min(max(d.get(p) + delta, lo), hi)
            d.set(&p, v)
        }
        // small color nudge (hue drift) for variety without jumping palettes
        for i in 0..<3 where !isLocked(.speciesColor(i)) {
            p.species[i].color = nudgeColor(p.species[i].color, strength: strength, using: &g)
        }
        edited = p
    }

    func mutate(strength: Double = 0.15) {
        var g = SystemRandomNumberGenerator()
        mutate(strength: strength, using: &g)
    }

    private func nudgeColor<G: RandomNumberGenerator>(_ c: RGB, strength: Double, using g: inout G) -> RGB {
        let d = Float(strength) * 0.3
        func j(_ x: Float) -> Float { min(max(x + Float.random(in: -d...d, using: &g), 0), 1) }
        return RGB(j(c.x), j(c.y), j(c.z))
    }
}