OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / Shared / IfsNodesSerializer.cs

179 lines · csharp ref:
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));
        }
    }
}