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