using System.Runtime.Serialization;
using System.Text;
using IFSEngine.Model;
using IFSEngine.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace IFSRenderer.Shared;
/// <summary>
/// Reads and writes the real <c>.ifsjson</c> format: every field the engine's
/// <see cref="IfsSerializer"/> emits, plus the <c>Nodes</c> array of graph positions.
/// </summary>
/// <remarks>
/// <para>
/// The engine's serializer knows nothing about <c>Nodes</c>, so saving through it
/// <strong>silently discards node layout</strong> written by the Windows app. Every load and
/// save must go through this type instead.
/// </para>
/// <para>
/// It deliberately delegates the IFS body to <see cref="IfsSerializer"/> and only adds/strips the
/// one extra key. The engine's serializer settings are <c>internal</c>, so re-declaring them here
/// would mean maintaining a copy that could silently drift; wrapping guarantees the body stays
/// byte-identical to what the engine produces.
/// </para>
/// <para>
/// Format details matched against files written by the WPF app: single-line JSON
/// (<see cref="Formatting.None"/>), UTF-8 <em>with</em> BOM, and <c>Nodes</c> appended last.
/// Positions pair to iterators purely by index, so both directions must enumerate
/// <see cref="IFS.Iterators"/> in the same order the writer did.
/// </para>
/// </remarks>
public static class IfsNodesSerializer
{
private const string NodesKey = "Nodes";
// Encoding.UTF8 emits a BOM, which is what the WPF app writes.
private static readonly Encoding _utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
public static IFS DeserializeJsonString(string ifsState, IEnumerable<TransformPlugin> transforms,
IEnumerable<EffectPlugin> postfxs, bool ignorePluginVersions)
{
IFS ifs = IfsSerializer.DeserializeJsonString(ifsState, transforms, postfxs, ignorePluginVersions);
ApplyNodePositions(ifs, ifsState);
UpgradeLegacyPalette(ifs, ifsState);
return ifs;
}
/// <summary>
/// Converts a pre-ColorPalette gradient so older documents still render.
/// </summary>
/// <remarks>
/// <para>
/// The engine replaced <c>FlamePalette</c> — a flat array of 400 baked samples under
/// <c>Colors</c> — with <c>ColorPalette</c>, which holds KEY colours under
/// <c>KeyColors</c>. Every .ifsjson written before that, INCLUDING the shipped templates,
/// carries the old shape, and the new model simply ignores it: the gradient deserializes
/// empty and <c>SampleGradient</c> then returns the background colour at every position, so
/// the fractal renders flat.
/// </para>
/// <para>
/// The conversion is the one the engine sketches and leaves commented out: spread the
/// samples evenly across 0..1 as key colours. Reducing 400 samples to keys would be lossy,
/// so all of them are kept — the gradient is then reproduced exactly.
/// </para>
/// </remarks>
private static void UpgradeLegacyPalette(IFS ifs, string ifsState)
{
if (ifs.Palette is { KeyColors.Count: > 0 })
return; // already a ColorPalette, nothing to do
JObject root;
try { root = JObject.Parse(ifsState); }
catch (JsonException) { return; }
if (root["Palette"] is not JObject palette)
return;
if (palette["Colors"] is not JArray colors || colors.Count == 0)
return;
var upgraded = new ColorPalette
{
Name = palette["Name"]?.Value<string>() ?? "Imported palette",
InterpolationMode = InterpolationMode.LinearRGB,
};
for (int i = 0; i < colors.Count; i++)
{
if (colors[i] is not JObject c)
continue;
double position = colors.Count == 1 ? 0.0 : (double)i / (colors.Count - 1);
upgraded.KeyColors[position] = new System.Numerics.Vector4(
c["X"]?.Value<float>() ?? 0f,
c["Y"]?.Value<float>() ?? 0f,
c["Z"]?.Value<float>() ?? 0f,
c["W"]?.Value<float>() ?? 1f);
}
if (upgraded.KeyColors.Count > 0)
ifs.Palette = upgraded;
}
public static string SerializeJsonString(IFS ifs)
{
string body = IfsSerializer.SerializeJsonString(ifs);
JObject jo;
try
{
jo = JObject.Parse(body);
}
catch (JsonException ex)
{
throw new SerializationException("Engine produced JSON that could not be re-parsed.", ex);
}
var nodes = new JArray();
foreach (Iterator iterator in ifs.Iterators)
{
NodePosition? p = iterator.GetPosition();
// An iterator with no position serialises as null, as it does in the WPF app.
nodes.Add(p is null
? JValue.CreateNull()
: new JObject { ["X"] = p.Value.X, ["Y"] = p.Value.Y });
}
jo[NodesKey] = nodes;
return jo.ToString(Formatting.None);
}
public static IFS LoadJsonFile(string path, IEnumerable<TransformPlugin> transforms,
IEnumerable<EffectPlugin> postfxs, bool ignorePluginVersions) =>
DeserializeJsonString(File.ReadAllText(path, Encoding.UTF8), transforms, postfxs, ignorePluginVersions);
public static async Task<IFS> LoadJsonFileAsync(string path, IEnumerable<TransformPlugin> transforms,
IEnumerable<EffectPlugin> postfxs, bool ignorePluginVersions) =>
DeserializeJsonString(await File.ReadAllTextAsync(path, Encoding.UTF8), transforms, postfxs, ignorePluginVersions);
public static void SaveJsonFile(IFS ifs, string path) =>
File.WriteAllText(path, SerializeJsonString(ifs), _utf8Bom);
public static async Task SaveJsonFileAsync(IFS ifs, string path) =>
await File.WriteAllTextAsync(path, SerializeJsonString(ifs), _utf8Bom);
/// <summary>
/// Reads the <c>Nodes</c> array, if present, onto the iterators by index.
/// A file with no positions (or fewer than there are iterators) is valid - the
/// remaining iterators simply keep no position and the editor lays them out.
/// </summary>
private static void ApplyNodePositions(IFS ifs, string ifsState)
{
JObject jo;
try
{
jo = JObject.Parse(ifsState);
}
catch (JsonException)
{
return; // the engine already parsed it; a Nodes read is best-effort
}
if (jo[NodesKey] is not JArray nodes)
return;
var iterators = ifs.Iterators.ToList();
int count = Math.Min(nodes.Count, iterators.Count);
for (int i = 0; i < count; i++)
{
if (nodes[i] is not JObject point)
continue; // null entry: iterator had no stored position
double? x = (double?)point["X"];
double? y = (double?)point["Y"];
if (x is not null && y is not null)
iterators[i].SetPosition(new NodePosition(x.Value, y.Value));
}
}
}