using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using IFSEngine.Animation;
using IFSEngine.Generation;
using IFSEngine.Model;
using IFSEngine.Rendering;
using IFSEngine.Serialization;
using IFSRenderer.AvaloniaShell.Controls;
using IFSRenderer.AvaloniaShell.Rendering;
using IFSRenderer.Shared;
namespace IFSRenderer.AvaloniaShell;
public partial class MainWindow : Window
{
private readonly IRenderQueue _service;
private readonly IFSHistoryTracker _history = new();
private readonly AppSettings _settings = AppSettings.Load();
private WriteableBitmap _bitmap;
private bool _framePending;
/// <summary>Set while pushing engine state INTO the controls, so their change handlers no-op.</summary>
private bool _syncingUi;
private Point _lastPointer;
private PointerUpdateKind _dragButton = PointerUpdateKind.Other;
private bool _dragging;
/// <summary>Whether this camera drag has already recorded its undo state.</summary>
private bool _cameraDragSnapshotted;
private Iterator _selectedIterator;
/// <summary>Keys currently held, driving the fly navigation tick.</summary>
private readonly HashSet<Key> _keysDown = [];
private bool _renderingPaused;
private bool _confirmedExit;
/// <summary>Path of the open document, or null if it has never been saved.</summary>
private string _editedFilePath;
/// <summary>The window as the application runs it, on a real OpenGL render thread.</summary>
public MainWindow() : this(new RenderService()) { }
/// <summary>
/// The window against a supplied renderer.
/// </summary>
/// <remarks>
/// Tests pass a queue that runs its actions immediately and drives no GPU, which is what
/// lets the add/remove, camera and document paths be exercised headlessly. Nothing else
/// about the window changes — it is the same visual tree and the same wiring.
/// </remarks>
public MainWindow(IRenderQueue service)
{
_service = service;
InitializeComponent();
Opened += OnOpened;
// NOT disposed here. Closing fires on every close ATTEMPT, and the confirm-exit handler
// cancels the first one — so this tore the renderer down while the app kept running, and
// then ran a second time when the user confirmed, adding to a disposed queue and aborting
// the process. The renderer is disposed once, after the exit is actually confirmed.
WireUi();
}
// ------------------------------------------------------------------ startup
private async void OnOpened(object sender, EventArgs e)
{
string library = AssetPaths.Library;
string transforms = AssetPaths.Transforms;
if (!AssetPaths.Found)
{
OverlayText.Text = "Could not find the plugin library.\n" + AssetPaths.Describe();
return;
}
_service.FrameReady += OnFrameReady;
// A failed dispatch pauses the loop; mirror that into the UI so the View menu offers
// "Resume rendering" instead of the app just appearing to freeze.
_service.Failed += ex => Dispatcher.UIThread.Post(() =>
{
if (_service.LastError is null)
return;
_renderingPaused = true;
MenuToggleRender.Header = "Resume _rendering";
});
try
{
OverlayText.Text = "Compiling shaders…";
await _service.StartAsync(library, transforms);
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
OverlayText.Text = "Failed to start renderer:\n" + (ex.InnerException ?? ex).Message;
return;
}
GpuText.Text = _service.GpuName;
ProjectionCombo.ItemsSource = Enum.GetValues<ProjectionType>();
ShadingCombo.ItemsSource = Enum.GetValues<ShadingMode>();
string presetFile = Path.Combine(library, "ResolutionPresets.json");
if (File.Exists(presetFile))
{
var parsed = Newtonsoft.Json.JsonConvert
.DeserializeObject<Dictionary<string, int[]>>(await File.ReadAllTextAsync(presetFile));
_resolutionPresets = parsed?.ToList() ?? [];
ResolutionPresets.ItemsSource = _resolutionPresets.Select(kv => $"{kv.Key} ({kv.Value[0]}x{kv.Value[1]})").ToList();
}
TransformCombo.ItemsSource = _service.Transforms.Select(t => t.Name).ToList();
TransformBrowserView.SetTransforms(_service.Transforms);
PostFxAddCombo.ItemsSource = _service.Effects.Select(t => t.Name).ToList();
BuildTemplatesMenu(library);
RebuildRecentMenu();
string template = Path.Combine(library, "Templates", "nebula_sample.ifsjson");
if (File.Exists(template))
LoadFractal(await IfsNodesSerializer.LoadJsonFileAsync(template, _service.Transforms, _service.Effects, true), template);
else
GenerateRandom();
OverlayText.IsVisible = false;
_syncingUi = true;
try { Sensitivity.Value = 1.0; }
finally { _syncingUi = false; }
MenuToggleHints.Header = "Hide control _hints";
MenuPanelIterators.Header = "✓ _Tone mapping panel";
MenuPanelProperties.Header = "✓ _Properties panel";
MenuPanelGraph.Header = "✓ Node _graph panel";
ApplySettings();
StartNavigationTick();
StartStatusTicker();
if (_settings.ShowWelcomeOnStartup)
{
var templateFiles = Directory.Exists(Path.Combine(library, "Templates"))
? Directory.GetFiles(Path.Combine(library, "Templates"), "*.ifsjson").OrderBy(f => f).ToList()
: [];
string picked = await Dialogs.ShowWelcome(this, _settings, templateFiles,
GenerateRandom, RenderFilePreviewAsync);
if (picked == Dialogs.OpenFileSentinel)
await OpenFractal();
else if (picked is not null)
await OpenPath(picked, addToRecents: false);
}
}
/// <summary>
/// Renders a 100x100 preview of a fractal file, for the welcome screen's template and
/// recent-file tiles. Returns null if the file will not load.
/// </summary>
private async Task<Avalonia.Media.Imaging.Bitmap> RenderFilePreviewAsync(string path)
{
const int size = 100, level = 7;
try
{
IFS fractal = await IfsNodesSerializer.LoadJsonFileAsync(
path, _service.Transforms, _service.Effects, true);
byte[] bgra = await _service.RenderThumbnailAsync(fractal, size, size, level);
var bitmap = new WriteableBitmap(new PixelSize(size, size),
new Avalonia.Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Opaque);
using (ILockedFramebuffer fb = bitmap.Lock())
{
int rowBytes = size * 4;
for (int y = 0; y < size; y++)
{
// GL reads bottom-up.
Marshal.Copy(bgra, (size - 1 - y) * rowBytes, fb.Address + y * fb.RowBytes, rowBytes);
}
}
return bitmap;
}
catch
{
return null;
}
}
private void StartStatusTicker()
{
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
timer.Tick += (_, _) =>
{
IFS f = _service.Current;
if (f is null)
return;
// A stalled renderer keeps saying so — the progress line would otherwise paint
// over the one message explaining why nothing is being drawn.
if (_service.LastError is Exception failure)
{
StatusText.Text = "Rendering stopped: " + failure.Message
+ " · View ▸ Resume rendering to retry";
return;
}
StatusText.Text =
$"{f.Title} · level {_service.IterationLevel}/{f.TargetIterationLevel:F0} · " +
$"{_service.TotalIterations:N0} iterations · {_service.FrameWidth}x{_service.FrameHeight} preview · " +
$"{_service.Fps:F0} fps";
RefreshCameraFields();
PerfSpeed.Text = _service.IterationSpeed > 1e6
? $"{_service.IterationSpeed / 1e6:F1}M/s"
: $"{_service.IterationSpeed / 1e3:F0}K/s";
PerfInvocations.Text = _service.InvocationIters.ToString("N0") + " iterations";
PerfFps.Text = $"{_service.Fps:F0} fps";
PerfTotal.Text = _service.TotalIterations.ToString("N0");
// MainViewModel.cs:60-69 - progress is the iteration count as a fraction of the
// target level's, not the level as a fraction of the target level.
int level = _service.IterationLevel;
double target = f.TargetIterationLevel;
ProgressText.Text = $"Progress: {level} / {target:F0}";
double denominator = Math.Pow(2, target) - 1;
double reached = denominator > 0 && _service.FrameWidth > 0 && _service.FrameHeight > 0
? _service.TotalIterations / (double)(_service.FrameWidth * _service.FrameHeight) / denominator
: 0;
IterationProgress.Value = Math.Clamp(reached * 100.0, 0, 100);
if (!IsTypingTarget() && WorkgroupBox.Value != _service.WorkgroupCount && _service.WorkgroupCount > 0)
{
_syncingUi = true;
try { WorkgroupBox.Value = _service.WorkgroupCount; }
finally { _syncingUi = false; }
}
};
timer.Start();
}
// ------------------------------------------------------------------- frames
private void OnFrameReady()
{
// Coalesce: never queue more than one pending UI update.
if (_framePending)
return;
_framePending = true;
Dispatcher.UIThread.Post(PresentFrame, DispatcherPriority.Render);
}
private void PresentFrame()
{
_framePending = false;
lock (_service.FrameLock)
{
int w = _service.FrameWidth, h = _service.FrameHeight;
byte[] src = _service.FrameBuffer;
if (w <= 0 || h <= 0 || src.Length < w * h * 4)
return;
if (_bitmap is null || _bitmap.PixelSize.Width != w || _bitmap.PixelSize.Height != h)
{
_bitmap = new WriteableBitmap(new PixelSize(w, h), new Avalonia.Vector(96, 96),
PixelFormat.Bgra8888, AlphaFormat.Opaque);
Viewport.Source = _bitmap;
}
using ILockedFramebuffer fb = _bitmap.Lock();
int rowBytes = w * 4;
for (int y = 0; y < h; y++)
{
// OpenGL reads bottom-up; the bitmap is top-down.
int srcOffset = (h - 1 - y) * rowBytes;
IntPtr dst = fb.Address + y * fb.RowBytes;
Marshal.Copy(src, srcOffset, dst, rowBytes);
}
}
Viewport.InvalidateVisual();
}
// -------------------------------------------------------------------- state
private void LoadFractal(IFS fractal, string path = null)
{
// Replacing the open document is an undoable edit, and the snapshot belongs HERE rather
// than at the call sites — Workspace.LoadParams does exactly this (Workspace.cs:152-154),
// which is why the canonical's callers never snapshot themselves. Without it New, Random,
// Open, Templates and Recents were all unrecoverable: Ctrl+Z had nothing to return to,
// which is what made undo look broken.
//
// At startup Current is still null and Snapshot() guards on that, so the first load
// records nothing, as it should.
Snapshot();
// Workspace.LoadParamsFileAsync takes an isTemplate flag and passes null as the edited
// path for one, "so it cannot be saved over". Templates ship inside the install tree,
// which is a read-only squashfs mount in an AppImage — writing back to one threw out of
// an async void handler and took the process down. Ctrl+S on a template falls through
// to Save as instead, exactly as on Windows.
_editedFilePath = path is not null && IsShippedTemplate(path) ? null : path;
Title = "IFSRenderer — " + (path is null ? fractal.Title : Path.GetFileName(path));
var size = ViewportPixelSize();
_service.LoadFractal(fractal, size.Width, size.Height);
if (FinalRenderMode.IsChecked != true)
_service.SetViewport(size.Width, size.Height, fillViewport: true);
PushStateToUi(fractal);
NodeEditorView.SetFractal(fractal);
AnimationView.SetFractal(fractal);
RebuildIteratorList(fractal);
RebuildPostFxList(fractal);
_connection = null;
ConnectionName.Text = "(none — click an arrow)";
ConnectionWeight.IsEnabled = false;
OverlayText.IsVisible = false;
}
private PixelSize ViewportPixelSize()
{
double scale = RenderScaling;
int w = (int)(ViewportHost.Bounds.Width * scale);
int h = (int)(ViewportHost.Bounds.Height * scale);
return new PixelSize(Math.Max(w, 16), Math.Max(h, 16));
}
private void PushStateToUi(IFS f)
{
_syncingUi = true;
try
{
TitleBox.Text = f.Title ?? "";
FovSlider.Value = f.Camera.FieldOfView;
FocusSlider.Value = f.Camera.FocusDistance;
ApertureSlider.Value = f.Camera.Aperture;
DofSlider.Value = f.Camera.DepthOfField;
ProjectionCombo.SelectedItem = f.Camera.Projection;
// No clamping: these parameters are unbounded above in the original.
BrightnessSlider.Value = f.Brightness;
GammaSlider.Value = f.Gamma;
GammaThresholdSlider.Value = f.GammaThreshold;
VibrancySlider.Value = f.Vibrancy;
QualitySlider.Value = f.TargetIterationLevel;
ExportWidth.Value = f.ImageResolution.Width;
ExportHeight.Value = f.ImageResolution.Height;
WarmupSlider.Value = f.Warmup;
// The canonical UI shows syntropy, the inverse of entropy.
SyntropySlider.Value = f.Entropy > 0 ? 1.0 / f.Entropy : 0;
FogSlider.Value = f.FogEffect;
BgR.Value = f.BackgroundColor.R;
BgG.Value = f.BackgroundColor.G;
BgB.Value = f.BackgroundColor.B;
}
finally { _syncingUi = false; }
RecomputeAspect();
ShowBackgroundColour();
ShowPalette(f.Palette);
ShowAuthors(f);
}
/// <summary>
/// Rebinds the iterator list, keeping the same row selected.
/// </summary>
/// <remarks>
/// Selection is preserved BY INDEX, because undo and redo hand back a freshly deserialized
/// fractal whose Iterator instances are new objects — matching by reference would always
/// miss. Snapping back to the first iterator made undo look broken: the panel would show a
/// different iterator's values, which is indistinguishable from the values not reverting.
/// </remarks>
/// <summary>
/// Lists the fractal's authors as links, as the title box's popup does.
/// </summary>
/// <remarks>
/// TitleBox.xaml:32-41 renders each author as a Hyperlink to their site. Author.Unknown
/// carries "-" as its link, so that one is shown as plain text rather than a dead link.
/// </remarks>
private void ShowAuthors(IFS f)
{
AuthorsList.Children.Clear();
var authors = f?.Authors?.ToList() ?? [];
if (authors.Count == 0)
{
AuthorsList.Children.Add(new TextBlock
{
Text = "(none recorded)",
Foreground = Brushes.Gray,
FontSize = 12,
Margin = new Thickness(8, 2),
});
return;
}
foreach (Author author in authors)
{
bool hasLink = !string.IsNullOrWhiteSpace(author.Link) && author.Link != "-";
if (!hasLink)
{
AuthorsList.Children.Add(new TextBlock
{
Text = author.Name,
Margin = new Thickness(8, 2),
});
continue;
}
var link = new Button
{
Classes = { "Flat" },
Content = author.Name,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Padding = new Thickness(8, 2),
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
Foreground = new SolidColorBrush(Color.Parse("#8ab4f8")),
};
ToolTip.SetTip(link, author.Link);
string target = author.Link;
link.Click += (_, _) => Dialogs.OpenUrl(target);
AuthorsList.Children.Add(link);
}
}
private void RebuildIteratorList(IFS f)
{
int previous = IteratorList.SelectedIndex;
_syncingUi = true;
try
{
var items = f.Iterators.ToList();
IteratorList.ItemsSource = items.Select(Describe).ToList();
_iterators = items;
IteratorList.SelectedIndex = previous >= 0 && previous < items.Count
? previous
: items.Count > 0 ? 0 : -1;
}
finally { _syncingUi = false; }
int index = IteratorList.SelectedIndex;
Iterator selected = index >= 0 && index < _iterators.Count ? _iterators[index] : null;
SelectIterator(selected);
NodeEditorView.Select(selected);
}
private List<Iterator> _iterators = [];
private static string Describe(Iterator it) =>
(string.IsNullOrWhiteSpace(it.Name) ? it.Transform?.Name ?? "iterator" : it.Name)
+ $" ({it.BaseWeight:F2})";
private void SelectIterator(Iterator it)
{
_selectedIterator = it;
_syncingUi = true;
try
{
IteratorName.Text = it is null ? "(none)" : (it.Transform?.Name ?? "iterator");
if (it is not null)
{
ItWeight.Value = it.BaseWeight;
ItOpacity.Value = it.Opacity;
ItColorIndex.Value = it.ColorIndex;
ItColorSpeed.Value = it.ColorSpeed;
ItStartWeight.Value = it.StartWeight;
ItMix.Value = it.Mix;
ItAdd.Value = it.Add;
ShadingCombo.SelectedItem = it.ShadingMode;
IteratorNameBox.Text = it.Name ?? string.Empty;
TransformCombo.SelectedIndex = it.Transform is null
? -1
: _service.Transforms.ToList().FindIndex(t => t == it.Transform);
}
else
{
TransformCombo.SelectedIndex = -1;
ShadingCombo.SelectedIndex = -1;
IteratorNameBox.Text = string.Empty;
}
}
finally { _syncingUi = false; }
PaletteBar.ColorIndex = it?.ColorIndex ?? 0;
WireIteratorSliders(it);
if (it is null)
IteratorParams.Children.Clear();
else
BuildParamSliders(IteratorParams, it.RealParams, it.Vec3Params,
() => _service.Post(r => r.InvalidateParamsBuffer()),
it.Transform?.RealParams, it.Transform?.Vec3Params, $"[{it.Id}]");
}
// ----------------------------------------------------------------- ui wiring
private void WireUi()
{
MenuOpen.Click += async (_, _) => await OpenFractal();
MenuSave.Click += async (_, _) => await SaveFractal();
MenuSaveAs.Click += async (_, _) => await SaveFractalAs();
MenuNew.Click += (_, _) => NewFractal();
MenuExportPng.Click += async (_, _) => await Export(".png");
MenuExportExr.Click += async (_, _) => await Export(".exr");
MenuExit.Click += (_, _) => Close();
Closing += async (_, e) =>
{
if (_confirmedExit)
return;
e.Cancel = true;
Dialogs.ExitChoice choice = await Dialogs.ConfirmExit(this);
if (choice == Dialogs.ExitChoice.Stay)
return;
if (choice == Dialogs.ExitChoice.Save)
{
await SaveFractal();
// A cancelled file picker, or a save that failed, leaves the document unwritten —
// quitting anyway would discard exactly what the user just asked to keep.
if (_editedFilePath is null)
return;
}
_confirmedExit = true;
_service.Dispose();
Close();
};
MenuRandom.Click += (_, _) => GenerateRandom();
MenuResetCamera.Click += (_, _) => ResetCamera();
// Camera
ProjectionCombo.SelectionChanged += (_, _) =>
{
if (_syncingUi || ProjectionCombo.SelectedItem is not ProjectionType p)
return;
Snapshot();
CameraChange(c => c.Projection = p);
// Some projections are only correct at a particular FOV and aspect, so the canonical
// sets both when one is chosen (CameraSettingsViewModel.cs:221-231).
IFS f = _service.Current;
if (f is null)
return;
if (p is ProjectionType.Equirectangular)
ApplyProjectionFraming(f, 90, f.ImageResolution.Width, f.ImageResolution.Width / 2);
else if (p is ProjectionType.Fisheye)
ApplyProjectionFraming(f, 90, f.ImageResolution.Width, f.ImageResolution.Width);
};
// Tone mapping - consumed as uniforms at RenderImage time, so no buffer reset
// (resetting would throw away the accumulated histogram).
WireKeyframing();
WireToneMapping();
WireQualitySliders();
// Warmup, entropy and the spatial filter feed the settings struct that is
// uploaded every dispatch, so they take effect immediately - but they change
// how samples are generated, so the accumulated histogram must be discarded
// rather than mixed.
// Density estimation is a post-pass over the finished histogram, so these
// only affect the next RenderImage - no reset.
EnableDe.IsCheckedChanged += (_, _) =>
{
if (_syncingUi) return;
bool on = EnableDe.IsChecked == true;
_service.Post(r => r.EnableDE = on);
};
// Final render mode renders the histogram 1:1 instead of scaling it to the
// viewport - the same switch the WPF quality panel makes.
FinalRenderMode.IsCheckedChanged += (_, _) =>
{
if (_syncingUi) return;
bool final = FinalRenderMode.IsChecked == true;
var size = ViewportPixelSize();
if (final)
{
IFS f = _service.Current;
int w = (int)ExportWidth.Value, h = (int)ExportHeight.Value;
_service.Post(r =>
{
if (f is null)
return;
f.ImageResolution = new System.Drawing.Size(w, h);
r.LoadParams(f);
r.SetHistogramScale(1.0);
});
}
else
{
_service.SetViewport(size.Width, size.Height, fillViewport: true);
}
};
ResolutionPresets.SelectionChanged += (_, _) =>
{
if (_syncingUi || ResolutionPresets.SelectedIndex < 0)
return;
if (ResolutionPresets.SelectedIndex >= _resolutionPresets.Count)
return;
int[] dims = _resolutionPresets[ResolutionPresets.SelectedIndex].Value;
_syncingUi = true;
try
{
ExportWidth.Value = dims[0];
ExportHeight.Value = dims[1];
}
finally { _syncingUi = false; }
ApplyExportResolution();
};
ExportWidth.ValueChanged += (_, _) =>
{
if (_syncingUi) return;
if (LinkAspect.IsChecked == true && _aspect > 0)
{
_syncingUi = true;
try { ExportHeight.Value = (int)Math.Round((double)(ExportWidth.Value ?? 0) / _aspect); }
finally { _syncingUi = false; }
}
ApplyExportResolution();
};
ExportHeight.ValueChanged += (_, _) =>
{
if (_syncingUi) return;
if (LinkAspect.IsChecked == true && _aspect > 0)
{
_syncingUi = true;
try { ExportWidth.Value = (int)Math.Round((double)(ExportHeight.Value ?? 0) * _aspect); }
finally { _syncingUi = false; }
}
ApplyExportResolution();
};
LinkAspect.IsCheckedChanged += (_, _) => RecomputeAspect();
// Background colour is a RenderImage uniform, so it needs no invalidation -
// it simply appears on the next presented frame.
WireBackgroundChannel(BgR, "Red");
WireBackgroundChannel(BgG, "Green");
WireBackgroundChannel(BgB, "Blue");
BgHex.TextChanged += (_, _) => ApplyBackgroundHex();
BgHex.LostFocus += (_, _) => ShowBackgroundColour();
// Fog is consumed while points accumulate, so changing it invalidates the histogram.
BtnLoadPalette.Click += async (_, _) => await LoadPaletteFile();
BtnPickPalette.Click += async (_, _) => await PickPalette();
PaletteBar.DragStarted += Snapshot;
PaletteBar.ColorIndexChanged += v =>
{
if (_selectedIterator is null)
return;
IteratorChange(i => i.ColorIndex = v);
_syncingUi = true;
try { ItColorIndex.Value = v; }
finally { _syncingUi = false; }
};
// Palette choice is a visual decision, so it happens in the gradient picker.
// Iterator params - these live in GPU buffers, so they must invalidate.
ShadingCombo.SelectionChanged += (_, _) =>
{
if (_syncingUi || _selectedIterator is null || ShadingCombo.SelectedItem is not ShadingMode mode)
return;
IteratorChange(i => i.ShadingMode = mode);
};
// Renaming only changes what the list and the node show - no GPU state.
IteratorNameBox.TextChanged += (_, _) =>
{
if (_syncingUi || _selectedIterator is null)
return;
string name = string.IsNullOrWhiteSpace(IteratorNameBox.Text) ? null : IteratorNameBox.Text;
Iterator it = _selectedIterator;
_service.Post(_ => it.Name = name);
RefreshIteratorLabel(it);
NodeEditorView.InvalidateVisual();
};
IteratorList.SelectionChanged += (_, _) =>
{
if (_syncingUi)
return;
int i = IteratorList.SelectedIndex;
Iterator picked = i >= 0 && i < _iterators.Count ? _iterators[i] : null;
SelectIterator(picked);
NodeEditorView.Select(picked);
};
// AddIteratorWith snapshots; doing it here too would record the same state twice.
BtnAddIterator.Click += (_, _) => AddIterator();
BtnRemoveIterator.Click += (_, _) => RemoveSelected();
BtnRemoveSelected.Click += (_, _) => RemoveSelected();
BtnDuplicate.Click += (_, _) => { Snapshot(); DuplicateIterator(splitWeights: false); };
BtnSplit.Click += (_, _) => { Snapshot(); DuplicateIterator(splitWeights: true); };
// Both flips are a 0 <-> 1 toggle, matching IteratorViewModel.
BtnFlipOpacity.Click += (_, _) =>
{
if (_selectedIterator is null) return;
Snapshot();
IteratorChange(i => i.Opacity = i.Opacity > 0.0 ? 0.0 : 1.0);
SelectIterator(_selectedIterator);
NodeEditorView.InvalidateVisual();
};
BtnFlipWeight.Click += (_, _) =>
{
if (_selectedIterator is null) return;
Snapshot();
IteratorChange(i => i.BaseWeight = i.BaseWeight > 0.0 ? 0.0 : 1.0);
SelectIterator(_selectedIterator);
NodeEditorView.InvalidateVisual();
};
MenuAutoLayout.Click += (_, _) => { Snapshot(); NodeEditorView.ResetView(); };
// Transform browser - the canonical left column of the Editor window.
TransformBrowserView.TransformActivated += AddIteratorWith;
TransformBrowserView.ReloadRequested += async () => await ReloadPlugins();
TransformBrowserView.OpenDirectoryRequested += () => OpenDirectory(AssetPaths.Transforms);
TransformBrowserView.EditSourceRequested += p => OpenPluginSource(p?.FilePath, "transform");
MenuToggleRender.Click += (_, _) => ToggleRendering();
BtnToggleRender.Click += (_, _) => ToggleRendering();
MenuGenerator.Click += async (_, _) => await ShowGenerator();
WorkgroupBox.ValueChanged += async (_, _) =>
{
if (_syncingUi) return;
await _service.SetWorkgroupCountAsync((int)(WorkgroupBox.Value ?? 256));
};
BtnEditTransform.Click += (_, _) =>
OpenPluginSource(_selectedIterator?.Transform?.FilePath, "transform");
BtnEditEffect.Click += (_, _) =>
OpenPluginSource(_selectedLayer?.Effect?.FilePath, "effect");
BtnReloadPlugins.Click += async (_, _) => await ReloadPlugins();
// ---- animation ----
AnimationView.ParamsProvider = BuildAnimatableParams;
AnimationView.SnapshotRequested = Snapshot;
AnimationView.AudioFilePicker = PickAudioFile;
AnimationView.ChannelCsvPicker = PickChannelCsv;
AnimationView.ImportFailed += message => StatusText.Text = message;
AnimationView.TimeChanged += t =>
{
IFS f = _service.Current;
if (f is null || f.Dopesheet.Channels.Count == 0)
return;
// The engine writes the evaluated values straight back onto the fractal.
_service.Post(r =>
{
f.EvaluateAt(TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(t)));
r.InvalidateParamsBuffer();
});
// Moving the playhead changes both "is there a key here" and "has the value
// drifted", so every diamond has to be repainted.
ValueSlider.NotifyAnimationStateChanged();
};
// Raised by every keyframe edit - a value, an interpolation type, a handle, a drag.
//
// This used to push the panel state and stop there, so an edit did NOT reach the
// viewport: the fractal kept whatever values the last EvaluateAt had written onto it,
// and TimeChanged above is the only other thing that calls one. The edit appeared the
// moment the playhead moved, which is exactly how it was reported - "if you edit a
// keyframe there it won't update the viewport until you change the frame".
AnimationView.Changed += () =>
{
IFS f = _service.Current;
if (f is null)
return;
if (f.Dopesheet.Channels.Count == 0)
{
PushStateToUi(f);
ValueSlider.NotifyAnimationStateChanged();
return;
}
PostThenRefresh(
r =>
{
f.EvaluateAt(TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(AnimationView.CurrentTime)));
r.InvalidateParamsBuffer();
},
() =>
{
// Ordered AFTER the evaluation, so the sliders show the values that were
// just written rather than the ones being replaced.
PushStateToUi(f);
ValueSlider.NotifyAnimationStateChanged();
});
};
AnimationView.ExportRequested += async () => await ExportClip();
// Toolbar quick commands and the right-hand help menu.
MenuSaveQuick.Click += async (_, _) => await SaveFractal();
MenuExportFrames.Click += async (_, _) => await ExportClip();
MenuExportVideo.Click += async (_, _) => await ExportVideo();
BtnCancelExport.Click += (_, _) =>
{
_exportCancelled = true;
OverlayText.Text = "Cancelling…";
};
MenuExportClipboard.Click += async (_, _) => await ExportToClipboard();
MenuTutorials.Click += (_, _) => Dialogs.OpenUrl("https://github.com/bezo97/IFSRenderer/wiki");
MenuIssues.Click += (_, _) => Dialogs.OpenUrl("https://github.com/bezo97/IFSRenderer/issues");
MenuCheckUpdates.Click += (_, _) =>
Dialogs.OpenUrl("https://github.com/bezo97/IFSRenderer/releases");
// The title box edits the fractal's title in place, as TitleBox.xaml does.
TitleBox.TextChanged += (_, _) =>
{
IFS f = _service.Current;
if (_syncingUi || f is null)
return;
string title = TitleBox.Text ?? "";
_service.Post(_ => f.Title = title);
Title = "IFSRenderer — " + (_editedFilePath is null
? title
: Path.GetFileName(_editedFilePath));
};
MenuSettings.Click += async (_, _) => await Dialogs.ShowSettings(this, _settings, ApplySettings);
MenuAbout.Click += async (_, _) => await Dialogs.ShowAbout(this, _service.GpuName);
MenuWiki.Click += (_, _) => Dialogs.OpenUrl("https://github.com/bezo97/IFSRenderer/wiki");
MenuForum.Click += (_, _) => Dialogs.OpenUrl("https://github.com/bezo97/IFSRenderer/discussions");
MenuReportBug.Click += (_, _) => Dialogs.OpenUrl(
"https://github.com/bezo97/IFSRenderer/issues/new?assignees=&labels=&template=bug_report.md");
// Panel visibility, in place of AvalonDock's floating/dockable panes.
MenuPanelIterators.Click += (_, _) =>
TogglePanel(LeftPanel, MenuPanelIterators, "_Tone mapping panel", 0, LeftSplitter, 200);
MenuPanelProperties.Click += (_, _) =>
TogglePanel(PropertiesPanel, MenuPanelProperties, "_Properties panel", 4, RightSplitter, 250);
MenuPanelGraph.Click += (_, _) =>
{
bool show = !GraphPanel.IsVisible;
GraphPanel.IsVisible = show;
GraphSplitter.IsVisible = show;
ViewportSplit.RowDefinitions[2].Height = show ? new GridLength(300) : new GridLength(0);
ViewportSplit.RowDefinitions[1].Height = show ? new GridLength(4) : new GridLength(0);
MenuPanelGraph.Header = (show ? "✓ " : "") + "Node _graph panel";
};
MenuToggleHints.Click += (_, _) =>
{
HintText.IsVisible = !HintText.IsVisible;
MenuToggleHints.Header = HintText.IsVisible ? "Hide control _hints" : "Show control _hints";
};
WireShortcuts();
WireCameraSliders();
BtnAnimateCamera.Click += (_, _) => AnimateCamera();
// Navigation pad - orbit around the focus point, or dolly along the view axis.
NavLeft.Click += (_, _) => Orbit(-1, 0);
NavRight.Click += (_, _) => Orbit(1, 0);
NavUp.Click += (_, _) => Orbit(0, -1);
NavDown.Click += (_, _) => Orbit(0, 1);
NavIn.Click += (_, _) => CameraChange(c => c.Translate(new Vector3(0, 0, 0.25f)));
NavOut.Click += (_, _) => CameraChange(c => c.Translate(new Vector3(0, 0, -0.25f)));
NavHome.Click += (_, _) => ResetCamera();
void Orbit(float dx, float dy)
{
CameraChange(c =>
{
float speed = (float)c.FieldOfView / 180.0f * (float)Sensitivity.Value;
float y = InvertY.IsChecked == true ? -dy : dy;
Vector3 pivot = c.Position + c.ForwardDirection * (float)c.FocusDistance;
c.OrbitAround(pivot, new Vector3(dx * speed * 0.15f, y * speed * 0.15f, 0));
});
}
MenuUndo.Click += (_, _) => Undo();
MenuRedo.Click += (_, _) => Redo();
MenuCopyParams.Click += async (_, _) => await CopyParamsToClipboard();
MenuPasteParams.Click += async (_, _) => await PasteParamsFromClipboard();
AddHandler(DragDrop.DragOverEvent, (_, e) =>
{
e.DragEffects = e.Data.Contains(DataFormats.Files) ? DragDropEffects.Copy : DragDropEffects.None;
});
AddHandler(DragDrop.DropEvent, async (_, e) =>
{
var file = e.Data.GetFiles()?.FirstOrDefault();
if (file?.Path?.LocalPath is string dropped)
await OpenPath(dropped);
});
// Node graph <-> list/panel selection stay in sync.
NodeEditorView.IteratorSelected += it =>
{
SelectIterator(it);
int idx = _iterators.IndexOf(it);
if (idx >= 0)
{
_syncingUi = true;
try { IteratorList.SelectedIndex = idx; }
finally { _syncingUi = false; }
}
};
// Xaos weights live in the iterator GPU buffer, so a graph edit must invalidate.
NodeEditorView.GraphChanged += () => _service.Post(r => r.InvalidateParamsBuffer());
NodeEditorView.SnapshotRequested = Snapshot;
NodeEditorView.ConnectionSelected += OnConnectionSelected;
// The node's own hover controls, mirroring Node.xaml's floating action buttons.
// Each selects the node first, so the action and the panel agree on the target.
NodeEditorView.FlipOpacityRequested += it =>
{
SelectFromGraph(it);
Snapshot();
IteratorChange(i => i.Opacity = i.Opacity > 0.0 ? 0.0 : 1.0);
SelectIterator(it);
NodeEditorView.InvalidateVisual();
};
NodeEditorView.FlipWeightRequested += it =>
{
SelectFromGraph(it);
Snapshot();
IteratorChange(i => i.BaseWeight = i.BaseWeight > 0.0 ? 0.0 : 1.0);
SelectIterator(it);
NodeEditorView.InvalidateVisual();
};
NodeEditorView.DuplicateRequested += it =>
{
SelectFromGraph(it);
Snapshot();
DuplicateIterator(splitWeights: false);
};
NodeEditorView.SplitRequested += it =>
{
SelectFromGraph(it);
Snapshot();
DuplicateIterator(splitWeights: true);
};
// Transform picker
TransformCombo.SelectionChanged += (_, _) =>
{
if (_syncingUi || _selectedIterator is null)
return;
if (TransformCombo.SelectedIndex < 0 || TransformCombo.SelectedIndex >= _service.Transforms.Count)
return;
Snapshot();
TransformPlugin plugin = _service.Transforms[TransformCombo.SelectedIndex];
Iterator it = _selectedIterator;
if (plugin == it.Transform)
return;
// SetTransform keeps parameter values whose names still exist and adds
// defaults for new ones, so the iterator buffer must be rebuilt.
_service.Post(r =>
{
it.SetTransform(plugin);
r.InvalidateParamsBuffer();
});
IteratorName.Text = plugin.Name;
BuildParamSliders(IteratorParams, it.RealParams, it.Vec3Params,
() => _service.Post(r => r.InvalidateParamsBuffer()),
it.Transform?.RealParams, it.Transform?.Vec3Params, $"[{it.Id}]");
RefreshIteratorLabel(it);
NodeEditorView.InvalidateVisual();
};
ConnectionWeight.SliderSettings = new ValueSliderSettings
{
Label = "Weight",
ToolTip = "The weight of the connection controls the transition probability between "
+ "the two iterators. 0 weight means no connection.",
DefaultValue = 1.0,
MinValue = 0,
Increment = 0.01,
ValueWillChange = Snapshot,
ValueChanged = v =>
{
if (_connection is null)
return;
(Iterator from, Iterator to) = _connection.Value;
_service.Post(r =>
{
from.WeightTo[to] = v;
r.InvalidateParamsBuffer();
});
NodeEditorView.InvalidateVisual();
},
};
// Post-processing. Each layer owns its own controls now, so the panel only wires the
// add-layer combo and the two tool buttons - selecting the combo adds immediately, as
// PostFxPanel.xaml does with its SelectionChanged trigger.
PostFxAddCombo.SelectionChanged += (_, _) =>
{
if (_syncingUi || PostFxAddCombo.SelectedIndex < 0)
return;
AddPostFx();
};
BtnReloadEffects.Click += async (_, _) => await ReloadPlugins();
BtnOpenEffectDir.Click += (_, _) => OpenDirectory(AssetPaths.Library);
// Viewport interaction
ViewportHost.PointerPressed += OnPointerPressed;
ViewportHost.PointerMoved += OnPointerMoved;
ViewportHost.PointerReleased += (_, _) => _dragging = false;
ViewportHost.PointerWheelChanged += OnPointerWheel;
ViewportHost.PropertyChanged += (_, e) =>
{
if (e.Property == BoundsProperty && _service.Current is not null)
{
var s = ViewportPixelSize();
_service.SetViewport(s.Width, s.Height, fillViewport: FinalRenderMode.IsChecked != true);
}
};
}
/// <summary>
/// Configures the tone-mapping ValueSliders from the canonical settings in
/// <c>WpfDisplay/ViewModels/ToneMappingViewModel.cs</c> — same labels, same defaults,
/// same increments, and the same absence of an upper bound. The previous Fluent sliders
/// invented maxima (10 / 6 / 1 / 3) that the original never had, so values above them
/// were unreachable.
/// </summary>
/// <summary>
/// Connects the keyframe diamond on every ValueSlider to the dopesheet.
/// </summary>
/// <remarks>
/// Ported rule-for-rule from <c>WpfDisplay/ViewModels/AnimationViewModel.cs</c>:
/// <list type="bullet">
/// <item><c>GetChannelCurrentState</c> — null when no channel exists, otherwise whether a
/// keyframe sits exactly at the current time (<c>kf.t == currentTimeSeconds</c>).</item>
/// <item><c>IsChannelBeingModified</c> — the live value has drifted from what the channel
/// evaluates to, by more than 0.001. This is what turns the diamond orange.</item>
/// <item><c>AnimateValue</c> — snapshot for undo FIRST, then add or update the channel.</item>
/// </list>
/// </remarks>
private void WireKeyframing()
{
ValueSlider.ChannelStateProvider = path =>
{
IFS f = _service.Current;
if (f is null || !f.Dopesheet.Channels.TryGetValue(path, out Channel channel))
return null;
double now = AnimationView.CurrentTime;
return channel.Keyframes.Any(kf => kf.t == now);
};
ValueSlider.ChannelModifiedProvider = (path, value) =>
{
IFS f = _service.Current;
if (f is null || !f.Dopesheet.Channels.TryGetValue(path, out Channel channel))
return false;
return Math.Abs(channel.EvaluateAt(AnimationView.CurrentTime) - value) > 0.001;
};
ValueSlider.AnimateRequested = (label, path, value) =>
{
IFS f = _service.Current;
if (f is null)
return;
Snapshot();
f.Dopesheet.AddOrUpdateChannel(label, path,
TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(AnimationView.CurrentTime)), value);
AnimationView.ReloadChannels();
ValueSlider.NotifyAnimationStateChanged();
};
}
private void WireToneMapping()
{
BrightnessSlider.SliderSettings = new ValueSliderSettings
{
Label = "💡 Brightness",
DefaultValue = IFS.Default.Brightness,
MinValue = 0,
Increment = 0.05,
AnimationPath = nameof(IFS.Brightness),
ValueWillChange = Snapshot,
ValueChanged = v => TonemapChange(f => f.Brightness = v),
};
GammaSlider.SliderSettings = new ValueSliderSettings
{
Label = "◑ 1/Gamma",
DefaultValue = IFS.Default.Gamma,
MinValue = 0,
Increment = 0.005,
AnimationPath = nameof(IFS.Gamma),
ValueWillChange = Snapshot,
ValueChanged = v => TonemapChange(f => f.Gamma = v),
};
GammaThresholdSlider.SliderSettings = new ValueSliderSettings
{
Label = "◓ Gamma threshold",
DefaultValue = IFS.Default.GammaThreshold,
MinValue = 0,
Increment = 0.0001,
AnimationPath = nameof(IFS.GammaThreshold),
ValueWillChange = Snapshot,
ValueChanged = v => TonemapChange(f => f.GammaThreshold = v),
};
VibrancySlider.SliderSettings = new ValueSliderSettings
{
Label = "🌷 Vibrancy",
DefaultValue = IFS.Default.Vibrancy,
Increment = 0.005,
AnimationPath = nameof(IFS.Vibrancy),
ValueWillChange = Snapshot,
ValueChanged = v => TonemapChange(f => f.Vibrancy = v),
};
}
/// <summary>
/// Camera sliders, from <c>WpfDisplay/ViewModels/CameraSettingsViewModel.cs</c>.
/// </summary>
/// <remarks>
/// Position, orientation and tilt use <c>IsLabelShown = false</c>: the group heading names
/// them, so the keyframe diamond sits inline at the right of the -/value/+ row. Focus
/// distance and depth of field set <c>MarkAreaInFocus</c> while dragging, which makes the
/// renderer draw the focal plane — the reason those two have dragging callbacks at all.
/// </remarks>
private void WireCameraSliders()
{
FovSlider.SliderSettings = new ValueSliderSettings
{
Label = "🔬 Field of View",
DefaultValue = 60,
MinValue = 1,
MaxValue = 179,
Increment = 0.1,
AnimationPath = "Camera.FieldOfView",
ValueWillChange = Snapshot,
ValueChanged = v => CameraChange(c => c.FieldOfView = v),
};
ApertureSlider.SliderSettings = new ValueSliderSettings
{
Label = "✨ Aperture",
DefaultValue = 0,
MinValue = 0,
Increment = 0.0001,
AnimationPath = "Camera.Aperture",
ValueWillChange = Snapshot,
ValueChanged = v => CameraChange(c => c.Aperture = v),
};
FocusSlider.SliderSettings = new ValueSliderSettings
{
Label = "📏 Focus Distance",
DefaultValue = 10,
Increment = 0.01,
AnimationPath = "Camera.FocusDistance",
ValueWillChange = Snapshot,
ValueChanged = v => CameraChange(c => c.FocusDistance = v),
ValueDraggingStarted = () => _service.Post(r => r.MarkAreaInFocus = true),
ValueDraggingCompleted = _ => _service.Post(r =>
{
r.MarkAreaInFocus = false;
r.InvalidateHistogramBuffer();
}),
};
DofSlider.SliderSettings = new ValueSliderSettings
{
Label = "🔾 Depth of Field",
ToolTip = "a.k.a. Range of focus",
DefaultValue = 0,
MinValue = 0,
Increment = 0.01,
AnimationPath = "Camera.DepthOfField",
ValueWillChange = Snapshot,
ValueChanged = v => CameraChange(c => c.DepthOfField = v),
ValueDraggingStarted = () => _service.Post(r => r.MarkAreaInFocus = true),
ValueDraggingCompleted = _ => _service.Post(r =>
{
r.MarkAreaInFocus = false;
r.InvalidateHistogramBuffer();
}),
};
// Each axis writes ONLY its own component, exactly as the matching property setter in
// CameraSettingsViewModel does — and, as there, the orientation quaternion is NOT
// re-normalised. Writing all four at once and normalising (what this used to do) made
// the orientation sliders inert: normalising projects the value straight back onto the
// unit sphere, so any purely radial change is cancelled outright. From the default
// pose (0,0,0,1) the W slider is exactly that, and the 500 ms camera refresh then wrote
// the normalised value back over the slider, so nothing moved and nothing stuck.
Axis(PosX, "Camera-X", "Camera position on X axis.", "Camera.Position.X", 0.01,
(c, v) => c.Position = new Vector3((float)v, c.Position.Y, c.Position.Z));
Axis(PosY, "Camera-Y", "Camera position on Y axis.", "Camera.Position.Y", 0.01,
(c, v) => c.Position = new Vector3(c.Position.X, (float)v, c.Position.Z));
Axis(PosZ, "Camera-Z", "Camera position on Z axis.", "Camera.Position.Z", 0.01,
(c, v) => c.Position = new Vector3(c.Position.X, c.Position.Y, (float)v));
Axis(OriX, "Orientation-X", "Camera rotation around X axis.", "Camera.Orientation.X", 0.001,
(c, v) => c.Orientation = new Quaternion((float)v, c.Orientation.Y, c.Orientation.Z, c.Orientation.W));
Axis(OriY, "Orientation-Y", "Camera rotation around Y axis.", "Camera.Orientation.Y", 0.001,
(c, v) => c.Orientation = new Quaternion(c.Orientation.X, (float)v, c.Orientation.Z, c.Orientation.W));
Axis(OriZ, "Orientation-Z", "Camera rotation around Z axis.", "Camera.Orientation.Z", 0.001,
(c, v) => c.Orientation = new Quaternion(c.Orientation.X, c.Orientation.Y, (float)v, c.Orientation.W));
Axis(OriW, "Orientation-W", "Camera rotation around W axis.", "Camera.Orientation.W", 0.001,
(c, v) => c.Orientation = new Quaternion(c.Orientation.X, c.Orientation.Y, c.Orientation.Z, (float)v));
Axis(TiltX, "Tilt-X", "Projection tilt on X axis.", "Camera.ProjectionTilt.X", 1,
(c, v) => c.ProjectionTilt = new Vector3((float)v, c.ProjectionTilt.Y, c.ProjectionTilt.Z));
Axis(TiltY, "Tilt-Y", "Projection tilt on Y axis.", "Camera.ProjectionTilt.Y", 1,
(c, v) => c.ProjectionTilt = new Vector3(c.ProjectionTilt.X, (float)v, c.ProjectionTilt.Z));
Axis(TiltZ, "Tilt-Z", "Projection tilt on Z axis.", "Camera.ProjectionTilt.Z", 1,
(c, v) => c.ProjectionTilt = new Vector3(c.ProjectionTilt.X, c.ProjectionTilt.Y, (float)v));
void Axis(ValueSlider slider, string label, string tip, string path, double increment,
Action<Camera, double> apply) =>
slider.SliderSettings = new ValueSliderSettings
{
Label = label,
IsLabelShown = false,
ToolTip = tip,
DefaultValue = 0,
Increment = increment,
AnimationPath = path,
ValueWillChange = Snapshot,
ValueChanged = v => CameraChange(c => apply(c, v)),
};
Sensitivity.SliderSettings = new ValueSliderSettings
{
Label = "Sensitivity",
ToolTip = "Fine-tune the sensitivity of camera movement controls. This applies to "
+ "mouse, keyboard and gamepad thumbsticks. Default value is 1.",
DefaultValue = 1,
MinValue = 0.1,
MaxValue = 10.0,
Increment = 0.1,
};
}
/// <summary>One menu accelerator: the keystroke, what it does, and whether it beats a text box.</summary>
private readonly record struct Shortcut(KeyGesture Gesture, Action Invoke, bool BeatsTextInput);
private readonly List<Shortcut> _shortcuts = [];
/// <summary>The accelerators actually bound, for the test that keeps the menus honest.</summary>
internal IReadOnlyList<KeyGesture> BoundGestures =>
_shortcuts.Select(s => s.Gesture).ToList();
/// <summary>
/// Binds the accelerators the File and Export menus advertise.
/// </summary>
/// <remarks>
/// <para>
/// <c>MenuItem.InputGesture</c> only DRAWS the accelerator text — it binds nothing. Every
/// shortcut this app displayed was therefore decorative: fourteen of them, none listening.
/// Space and Delete worked only because they had their own explicit key handler.
/// </para>
/// <para>
/// The set matches <c>WpfDisplay/Views/MainWindow.xaml</c>, which declares it in two
/// places: explicit <c>InputBindings</c> for the file and export gestures, and
/// <c>CommandBindings</c> on <c>ApplicationCommands.Undo/Redo/Copy/Paste</c>, which carry
/// WPF's own default gestures — Ctrl+Z, Ctrl+Y, Ctrl+C, Ctrl+V. Those four are easy to
/// miss when reading the XAML, because the keystroke never appears in it.
/// </para>
/// <para>
/// One deviation: the canonical halts rendering with the PAUSE key. Space is kept because
/// this port has always used it and the menu advertises it, with Pause added alongside.
/// </para>
/// <para>
/// <see cref="Shortcut.BeatsTextInput"/> is false for the accelerators that collide with
/// ordinary text editing — Ctrl+C, Ctrl+V and Ctrl+A — so typing in the title box still
/// copies, pastes and selects text rather than exporting a clipboard image.
/// </para>
/// </remarks>
private void WireShortcuts()
{
void Bind(string gesture, Action invoke, bool beatsTextInput = true) =>
_shortcuts.Add(new Shortcut(KeyGesture.Parse(gesture), invoke, beatsTextInput));
Bind("Ctrl+N", NewFractal);
Bind("Ctrl+O", () => _ = OpenFractal());
Bind("Ctrl+S", () => _ = SaveFractal());
Bind("Ctrl+Shift+S", () => _ = SaveFractalAs());
Bind("Ctrl+R", GenerateRandom);
Bind("Ctrl+Shift+C", () => _ = ExportToClipboard(), beatsTextInput: false);
Bind("Ctrl+Shift+E", () => _ = Export(".exr"));
Bind("Ctrl+E", () => _ = Export(".png"));
Bind("Ctrl+A", () => _ = ExportClip(), beatsTextInput: false);
Bind("Ctrl+Shift+A", () => _ = ExportVideo(), beatsTextInput: false);
Bind("Ctrl+C", () => _ = CopyParamsToClipboard(), beatsTextInput: false);
Bind("Ctrl+V", () => _ = PasteParamsFromClipboard(), beatsTextInput: false);
Bind("F1", () => Dialogs.OpenUrl("https://github.com/bezo97/IFSRenderer/wiki"));
Bind("Pause", ToggleRendering);
// ApplicationCommands.Undo / .Redo in the canonical; these are their WPF gestures.
Bind("Ctrl+Z", Undo);
Bind("Ctrl+Y", Redo);
// Tunnel, so an accelerator is seen before a focused control swallows the key; the
// BeatsTextInput flag is what hands the ordinary editing keys back to a text box.
AddHandler(KeyDownEvent, (_, e) =>
{
if (e.Handled)
return;
bool typing = IsTypingTarget();
foreach (Shortcut shortcut in _shortcuts)
{
if (!shortcut.Gesture.Matches(e))
continue;
if (typing && !shortcut.BeatsTextInput)
return;
shortcut.Invoke();
e.Handled = true;
return;
}
}, RoutingStrategies.Tunnel);
}
/// <summary>
/// Restores every camera setting, as <c>CameraSettingsViewModel.ResetCamera</c> does by
/// replacing the whole <see cref="Camera"/> (CameraSettingsViewModel.cs:293-300).
/// </summary>
/// <remarks>
/// Resetting only orientation and position, as this used to, left a wrecked field of view,
/// aperture, focus distance, depth of field, projection and tilt in place — so "reset" did
/// not recover from the thing people most often need to recover from.
/// </remarks>
private void ResetCamera()
{
IFS f = _service.Current;
if (f is null)
return;
Snapshot();
var fresh = new Camera();
CameraChange(c =>
{
c.Position = fresh.Position;
c.Orientation = fresh.Orientation;
c.FieldOfView = fresh.FieldOfView;
c.Aperture = fresh.Aperture;
c.FocusDistance = fresh.FocusDistance;
c.DepthOfField = fresh.DepthOfField;
c.Projection = fresh.Projection;
c.ProjectionTilt = fresh.ProjectionTilt;
});
PushStateToUi(f);
}
/// <summary>Sets the field of view and image aspect a projection requires.</summary>
private void ApplyProjectionFraming(IFS f, double fieldOfView, int width, int height)
{
_service.Post(r =>
{
f.Camera.FieldOfView = fieldOfView;
f.ImageResolution = new System.Drawing.Size(width, Math.Max(height, 1));
r.LoadParams(f);
r.InvalidateHistogramBuffer();
});
_syncingUi = true;
try
{
FovSlider.Value = fieldOfView;
ExportWidth.Value = width;
ExportHeight.Value = Math.Max(height, 1);
}
finally { _syncingUi = false; }
RecomputeAspect();
}
/// <summary>Keys all seven camera channels at once, as the canonical Animate Camera does.</summary>
private void AnimateCamera()
{
IFS f = _service.Current;
if (f is null)
return;
Snapshot();
foreach (ValueSlider s in new[] { PosX, PosY, PosZ, OriX, OriY, OriZ, OriW })
f.Dopesheet.AddOrUpdateChannel(s.Label, s.AnimationPath,
TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(AnimationView.CurrentTime)), s.Value);
AnimationView.ReloadChannels();
ValueSlider.NotifyAnimationStateChanged();
}
/// <summary>
/// Quality and density-estimation sliders, from <c>QualitySettingsViewModel.cs</c>.
/// </summary>
private void WireQualitySliders()
{
QualitySlider.SliderSettings = new ValueSliderSettings
{
Label = "🎌 Target Iteration Level",
ToolTip = "The image is considered finished when the rendering progress reaches this "
+ "level. It is recommended to animate this value when rendering animations, "
+ "since certain frames may require a lot more iterations than others.",
AnimationPath = "TargetIterationLevel",
DefaultValue = IFS.Default.TargetIterationLevel,
MinValue = 1,
MaxValue = 50,
Increment = 1,
ValueWillChange = Snapshot,
ValueChanged = v => TonemapChange(f => f.TargetIterationLevel = v),
};
// Syntropy is the inverse of entropy: the tendency to iterate deeper before the state
// resets. The engine stores entropy, the UI shows syntropy — as on Windows.
SyntropySlider.SliderSettings = new ValueSliderSettings
{
Label = "🧵 Syntropy",
ToolTip = "Syntropy represents the tendency of the system to move towards more "
+ "structure. High syntropy lets the system iterate deeper before the state "
+ "resets. Syntropy is the inverse of entropy, which is the chance to reset "
+ "state in each iteration.",
DefaultValue = (int)(1.0 / IFS.Default.Entropy),
MinValue = 10,
MaxValue = 1000000,
Increment = 10,
ValueWillChange = Snapshot,
ValueChanged = v => QualityChange(f => f.Entropy = 1.0 / v, null),
};
WarmupSlider.SliderSettings = new ValueSliderSettings
{
Label = "🌡 Warmup",
ToolTip = "A.k.a. 'fuse count', the number of iterations before plotting starts.",
DefaultValue = IFS.Default.Warmup,
MinValue = 0,
Increment = 10,
ValueWillChange = Snapshot,
ValueChanged = v => QualityChange(f => f.Warmup = (int)v, null),
};
FilterRadiusSlider.SliderSettings = new ValueSliderSettings
{
Label = "Filter Radius",
DefaultValue = 0,
MinValue = 0,
MaxValue = 3,
Increment = 1,
ValueChanged = v => QualityChange(null, r => r.MaxFilterRadius = (int)v),
};
DeRadiusSlider.SliderSettings = new ValueSliderSettings
{
Label = "Radius",
DefaultValue = 0,
MinValue = 0,
MaxValue = 20,
Increment = 1,
ValueChanged = v => _service.Post(r => r.DEMaxRadius = (int)v),
};
DePowerSlider.SliderSettings = new ValueSliderSettings
{
Label = "Power",
DefaultValue = 0.4,
MinValue = 0,
MaxValue = 1,
Increment = 0.01,
ValueChanged = v => _service.Post(r => r.DEPower = v),
};
DeThresholdSlider.SliderSettings = new ValueSliderSettings
{
Label = "Threshold",
DefaultValue = 0,
MinValue = 0,
MaxValue = 1,
Increment = 0.01,
ValueChanged = v => _service.Post(r => r.DEThreshold = v),
};
FogSlider.SliderSettings = new ValueSliderSettings
{
Label = "🌫 Fog effect",
// No maximum: IFSViewModel.cs:107-116 declares MinValue = 0 and nothing above, so
// capping it at 1 put values the original can reach out of reach. Every other
// maximum in this app matches the canonical; this was the last invented one.
ToolTip = "Fades parts that are out of focus.",
DefaultValue = IFS.Default.FogEffect,
MinValue = 0,
Increment = 0.01,
AnimationPath = "FogEffect",
ValueWillChange = Snapshot,
ValueChanged = v => QualityChange(f => f.FogEffect = v, null),
};
}
/// <summary>
/// Per-iterator sliders, from <c>IteratorViewModel.cs</c>. Re-applied on every selection
/// because each iterator has its own animation channel paths — <c>[id].PropertyName</c>.
/// </summary>
private void WireIteratorSliders(Iterator it)
{
string Path(string property) => it is null ? null : $"[{it.Id}].{property}";
ItStartWeight.SliderSettings = new ValueSliderSettings
{
Label = "Start Weight",
ToolTip = "Controls the probability that the iteration starts in this state.",
DefaultValue = 1.0,
MinValue = 0,
Increment = 0.01,
AnimationPath = Path(nameof(Iterator.StartWeight)),
ValueWillChange = Snapshot,
ValueChanged = v => IteratorChange(i => i.StartWeight = v),
};
ItWeight.SliderSettings = new ValueSliderSettings
{
Label = "Base Weight",
ToolTip = "Multiplies incoming connection weights for ease of use. 0 base weight means "
+ "this iterator does not take part in the iteration process, because the IFS "
+ "never transitions here.",
DefaultValue = 1.0,
MinValue = 0,
Increment = 0.01,
AnimationPath = Path(nameof(Iterator.BaseWeight)),
ValueWillChange = Snapshot,
ValueChanged = v => IteratorChange(i => i.BaseWeight = v),
};
ItOpacity.SliderSettings = new ValueSliderSettings
{
Label = "Opacity",
ToolTip = "Setting this to 0 means this iterator will not draw anything.",
DefaultValue = 1.0,
MinValue = -1,
MaxValue = 1,
Increment = 0.01,
AnimationPath = Path(nameof(Iterator.Opacity)),
ValueWillChange = Snapshot,
ValueChanged = v => IteratorChange(i => i.Opacity = v),
};
ItColorIndex.SliderSettings = new ValueSliderSettings
{
Label = "Color Index",
ToolTip = "Indexes to the color on the palette. 0 -> Left most color, 1 -> Right most color.",
DefaultValue = 0.0,
MinValue = 0,
MaxValue = 1,
Increment = 0.01,
AnimationPath = Path(nameof(Iterator.ColorIndex)),
ValueWillChange = Snapshot,
ValueChanged = v =>
{
IteratorChange(i => i.ColorIndex = v);
PaletteBar.ColorIndex = v;
},
};
ItColorSpeed.SliderSettings = new ValueSliderSettings
{
Label = "Color Speed",
ToolTip = "The Color Speed controls how fast the color state of the IFS progresses "
+ "towards this iterator's Color Index. 0 -> no effect on colors. 1 -> Draw "
+ "with the selected color index immediately.",
DefaultValue = 0.0,
Increment = 0.01,
AnimationPath = Path(nameof(Iterator.ColorSpeed)),
ValueWillChange = Snapshot,
ValueChanged = v => IteratorChange(i => i.ColorSpeed = v),
};
ItMix.SliderSettings = new ValueSliderSettings
{
Label = "Mix",
ToolTip = "Linearly interpolate between the states before/after the transform. "
+ "0 -> The transform has no effect on the position. 1 -> Default.",
DefaultValue = 1.0,
Increment = 0.001,
AnimationPath = Path(nameof(Iterator.Mix)),
ValueWillChange = Snapshot,
ValueChanged = v => IteratorChange(i => i.Mix = v),
};
ItAdd.SliderSettings = new ValueSliderSettings
{
Label = "Add",
ToolTip = "Add up the positions before/after the transform.",
DefaultValue = 0.0,
Increment = 0.001,
AnimationPath = Path(nameof(Iterator.Add)),
ValueWillChange = Snapshot,
ValueChanged = v => IteratorChange(i => i.Add = v),
};
}
/// <summary>One 0-255 channel of the background colour, inside the swatch's picker.</summary>
/// <remarks>
/// A whole increment makes ValueSlider format the value as N0, which is what a colour
/// channel wants, and - unlike the stock Slider this replaced - it can be typed exactly.
/// </remarks>
private void WireBackgroundChannel(ValueSlider slider, string label) =>
slider.SliderSettings = new ValueSliderSettings
{
Label = label,
DefaultValue = 0,
MinValue = 0,
MaxValue = 255,
Increment = 1,
ValueWillChange = Snapshot,
ValueChanged = _ => ApplyBackgroundColour(),
};
/// <summary>Applies a typed hex colour, ignoring anything not yet a complete value.</summary>
private void ApplyBackgroundHex()
{
if (_syncingUi)
return;
string text = (BgHex.Text ?? string.Empty).Trim();
if (!text.StartsWith('#'))
text = "#" + text;
// Partial input is normal while typing; only act on something parseable.
if (!Color.TryParse(text, out Color parsed))
return;
_syncingUi = true;
try
{
BgR.Value = parsed.R;
BgG.Value = parsed.G;
BgB.Value = parsed.B;
}
finally { _syncingUi = false; }
ApplyBackgroundColour(updateHex: false);
}
// ------------------------------------------------------------------ actions
private void CameraChange(Action<Camera> change)
{
IFS f = _service.Current;
if (f is null)
return;
_service.Post(r =>
{
change(f.Camera);
r.InvalidateHistogramBuffer();
});
}
/// <summary>
/// Applies a structural change to the fractal on the GL thread, then rebuilds the UI from
/// it — in that order.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="RenderService.Post"/> only QUEUES work; it returns before the engine has run
/// it. Rebuilding the node graph or the effect stack on the line after a Post therefore
/// read the fractal as it was BEFORE the edit, so every add/remove appeared to take effect
/// one action late — and the rebuild enumerated <c>Iterators</c> / <c>PostEffects</c> on
/// the UI thread while the GL thread was mutating the very same list, which could throw
/// mid-render and leave the renderer permanently paused.
/// </para>
/// <para>
/// Posting the refresh back from inside the queued action fixes both: the rebuild is
/// ordered after the mutation, and it never runs concurrently with it.
/// </para>
/// </remarks>
private void PostThenRefresh(Action<IRenderer> mutate, Action refresh)
{
_service.Post(r =>
{
mutate(r);
Dispatcher.UIThread.Post(refresh);
});
}
private void TonemapChange(Action<IFS> change)
{
IFS f = _service.Current;
if (f is null)
return;
_service.Post(_ => change(f));
}
private void IteratorChange(Action<Iterator> change)
{
Iterator it = _selectedIterator;
if (it is null)
return;
_service.Post(r =>
{
change(it);
r.InvalidateParamsBuffer();
});
int idx = _iterators.IndexOf(it);
if (idx >= 0 && IteratorList.ItemsSource is List<string> labels)
{
labels[idx] = Describe(it);
_syncingUi = true;
try
{
IteratorList.ItemsSource = null;
IteratorList.ItemsSource = labels;
IteratorList.SelectedIndex = idx;
}
finally { _syncingUi = false; }
}
}
private void AddIterator()
{
if (_service.Transforms.Count == 0)
return;
AddIteratorWith(_service.Transforms[Random.Shared.Next(_service.Transforms.Count)]);
}
/// <summary>
/// Adds an iterator using a chosen transform, as IFSViewModel.AddIterator does: the new
/// iterator is connected to and from the current selection with weight 1, and then becomes
/// the selection.
/// </summary>
private void AddIteratorWith(TransformPlugin plugin)
{
IFS f = _service.Current;
if (f is null || plugin is null)
return;
Snapshot();
Iterator selected = _selectedIterator;
var created = new Iterator(plugin);
PostThenRefresh(r =>
{
f.AddIterator(created, false);
if (selected is not null)
{
selected.WeightTo[created] = 1.0;
created.WeightTo[selected] = 1.0;
}
r.LoadParams(f);
r.SetHistogramScaleToDisplay();
},
() =>
{
NodeEditorView.SetFractal(f);
RebuildIteratorList(f);
int index = _iterators.IndexOf(created);
if (index < 0)
return;
_syncingUi = true;
try { IteratorList.SelectedIndex = index; }
finally { _syncingUi = false; }
SelectIterator(created);
NodeEditorView.Select(created);
});
}
/// <summary>
/// Duplicates the selected iterator. <paramref name="splitWeights"/> false is the
/// classic flame "duplicate" (both connected, weights untouched); true halves the
/// base weights and leaves the pair unconnected, preserving the overall look.
/// </summary>
private void DuplicateIterator(bool splitWeights)
{
IFS f = _service.Current;
Iterator original = _selectedIterator;
if (f is null || original is null)
return;
// Place the copy beside its source rather than letting auto-layout fling it away.
NodePosition? from = original.GetPosition();
PostThenRefresh(r =>
{
Iterator dupe = f.DuplicateIterator(original, splitWeights);
if (from is not null)
dupe.SetPosition(new NodePosition(from.Value.X + 28, from.Value.Y + 34));
r.LoadParams(f);
},
() =>
{
NodeEditorView.SetFractal(f);
RebuildIteratorList(f);
});
}
/// <summary>
/// Records the current state for undo. Called before discrete edits (add, remove,
/// duplicate, flip, transform swap, palette change) - deliberately NOT on every slider
/// tick, which would bury the history in noise.
/// </summary>
private void Snapshot()
{
if (_service.Current is not null)
_history.TakeSnapshot(_service.Current);
RefreshHistoryCommands();
}
/// <summary>
/// Greys the undo/redo items when there is nothing to go back or forward to.
/// </summary>
/// <remarks>
/// Parity with <c>IFSViewModel.UndoCommand</c>/<c>RedoCommand</c>, whose
/// <c>CanExecute</c> is <c>IsHistoryUndoable</c>/<c>IsHistoryRedoable</c>
/// (IFSViewModel.cs:403-409). Also the honest signal: a fresh document has no history, so
/// Ctrl+Z genuinely has nothing to do, and the menu should say so rather than look inert.
/// </remarks>
private void RefreshHistoryCommands()
{
MenuUndo.IsEnabled = _history.IsHistoryUndoable;
MenuRedo.IsEnabled = _history.IsHistoryRedoable;
}
private void Undo()
{
if (!_history.IsHistoryUndoable || _service.Current is null)
return;
IFS restored = _history.Undo(_service.Current, _service.Transforms, _service.Effects);
ReloadAfterHistory(restored, "Undo");
RefreshHistoryCommands();
}
private void Redo()
{
if (!_history.IsHistoryRedoable || _service.Current is null)
return;
IFS restored = _history.Redo(_service.Current, _service.Transforms, _service.Effects);
ReloadAfterHistory(restored, "Redo");
RefreshHistoryCommands();
}
/// <summary>
/// Undo/redo hand back a freshly deserialized fractal, so the whole UI must rebind to it.
/// Node positions come along because the tracker serializes through IfsNodesSerializer.
/// </summary>
private void ReloadAfterHistory(IFS restored, string what)
{
var size = ViewportPixelSize();
_service.LoadFractal(restored, size.Width, size.Height);
PushStateToUi(restored);
NodeEditorView.SetFractal(restored);
AnimationView.SetFractal(restored);
RebuildIteratorList(restored);
RebuildPostFxList(restored);
StatusText.Text = what;
}
/// <summary>
/// The canonical RemoveSelected: a selected CONNECTION is disconnected (its weight set to
/// 0); otherwise the selected iterator is removed.
/// </summary>
private void RemoveSelected()
{
if (_connection is { } link)
{
Snapshot();
(Iterator from, Iterator to) = link;
IFS current = _service.Current;
PostThenRefresh(r =>
{
from.WeightTo[to] = 0.0;
r.InvalidateParamsBuffer();
},
() =>
{
_connection = null;
ConnectionName.Text = "(none — click an arrow)";
ConnectionWeight.IsEnabled = false;
NodeEditorView.SetFractal(current);
});
return;
}
Snapshot();
RemoveIterator();
}
private void RemoveIterator()
{
IFS f = _service.Current;
Iterator it = _selectedIterator;
if (f is null || it is null || f.Iterators.Count <= 1)
return;
PostThenRefresh(r =>
{
f.RemoveIterator(it);
r.LoadParams(f);
r.SetHistogramScaleToDisplay();
},
() =>
{
NodeEditorView.SetFractal(f);
RebuildIteratorList(f);
});
}
private void GenerateRandom()
{
var generator = new Generator(_service.Transforms);
IFS fractal = generator.GenerateOne(new GeneratorOptions());
fractal.Title = "Random fractal";
LoadFractal(fractal);
}
// -------------------------------------------------------------- interaction
private void OnPointerPressed(object sender, PointerPressedEventArgs e)
{
// NOT snapshotted here: TakeSnapshot clears the redo stack, so recording on a bare
// press meant any click in the viewport destroyed redo. The snapshot happens on the
// first actual movement instead - the canonical's InteractionStarted semantics.
_cameraDragSnapshotted = false;
// MainViewModel.cs:345-350 hides the hints on the first camera interaction - it is a
// first-run prompt, not a permanent overlay. Ours sat there until toggled off by hand.
if (HintText.IsVisible)
{
HintText.IsVisible = false;
MenuToggleHints.Header = "Show control _hints";
}
var props = e.GetCurrentPoint(ViewportHost).Properties;
_dragButton = props.PointerUpdateKind;
_lastPointer = e.GetPosition(ViewportHost);
_dragging = true;
e.Pointer.Capture(ViewportHost);
}
private void OnPointerMoved(object sender, PointerEventArgs e)
{
if (!_dragging || _service.Current is null)
return;
Point pos = e.GetPosition(ViewportHost);
var delta = new Vector2((float)(pos.X - _lastPointer.X), (float)(pos.Y - _lastPointer.Y));
_lastPointer = pos;
if (delta.LengthSquared() <= 0)
return;
// One snapshot per drag, taken once the camera is actually about to move.
if (!_cameraDragSnapshotted)
{
_cameraDragSnapshotted = true;
Snapshot();
}
Camera cam = _service.Current.Camera;
// Rotation speed scales with field of view, as in the WPF display.
float speed = (float)cam.FieldOfView / 180.0f;
// Both settings apply to mouse look in the canonical; neither did here.
float sensitivity = (float)Sensitivity.Value;
float yaw = delta.X * speed;
float pitch = delta.Y * speed * (InvertY.IsChecked == true ? -1f : 1f);
switch (_dragButton)
{
case PointerUpdateKind.LeftButtonPressed:
CameraChange(c => c.Rotate(new Vector3(yaw, pitch, 0) * 0.01f * sensitivity));
break;
case PointerUpdateKind.MiddleButtonPressed:
CameraChange(c =>
{
Vector3 pivot = c.Position + c.ForwardDirection * (float)c.FocusDistance;
c.OrbitAround(pivot, new Vector3(yaw, pitch, 0) * 0.01f * sensitivity);
});
break;
case PointerUpdateKind.RightButtonPressed:
{
var center = new Vector2((float)ViewportHost.Bounds.Width / 2, (float)ViewportHost.Bounds.Height / 2);
var lastOffset = new Vector2((float)(pos.X - delta.X), (float)(pos.Y - delta.Y)) - center;
var offset = new Vector2((float)pos.X, (float)pos.Y) - center;
if (lastOffset.Length() > 0.001f && offset.Length() > 0.001f)
{
Vector2 dir = Vector2.Normalize(lastOffset);
float roll = dir.X * delta.Y - dir.Y * delta.X;
float fovScale = lastOffset.Length() / offset.Length();
CameraChange(c =>
{
c.FieldOfView = Math.Clamp(c.FieldOfView * fovScale, 1, 179);
c.Rotate(new Vector3(0, 0, roll * 0.01f));
});
_syncingUi = true;
try { FovSlider.Value = Math.Clamp(cam.FieldOfView, 1, 179); }
finally { _syncingUi = false; }
}
break;
}
}
}
private void OnPointerWheel(object sender, PointerWheelEventArgs e)
{
if (_service.Current is null)
return;
double factor = 1 + e.Delta.Y * 0.1;
CameraChange(c => c.FocusDistance = Math.Max(0.01, c.FocusDistance * factor));
_syncingUi = true;
try
{
FocusSlider.Value = _service.Current.Camera.FocusDistance;
}
finally { _syncingUi = false; }
}
// ---------------------------------------------------------------- file i/o
private async Task OpenFractal()
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Open fractal",
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType("Fractals and palettes")
{ Patterns = ["*.ifsjson", "*.png", "*.gradient", "*.ugr"] },
],
});
if (files.Count == 0)
return;
await OpenPath(files[0].Path.LocalPath);
}
/// <summary>
/// True for a file that ships with the application — a template under <c>Library/</c>.
/// Those live in the install tree, which is read-only in a packaged build.
/// </summary>
internal static bool IsShippedTemplate(string path)
{
try
{
string library = Path.GetFullPath(AssetPaths.Library);
return Path.GetFullPath(path).StartsWith(
library + Path.DirectorySeparatorChar, StringComparison.Ordinal);
}
catch
{
// A malformed path is not a template; let the normal save path report on it.
return false;
}
}
/// <summary>Ctrl+S: writes back to the open file, or falls through to Save as.</summary>
private async Task SaveFractal()
{
if (_service.Current is null)
return;
if (_editedFilePath is null)
{
await SaveFractalAs();
return;
}
// Saving is I/O and can fail for reasons the app does not control — a read-only
// location, a full disk, a path that vanished. This runs from an async void click
// handler, where an escaping exception is fatal, so it is reported, never thrown.
try
{
await IfsNodesSerializer.SaveJsonFileAsync(_service.Current, _editedFilePath);
StatusText.Text = "Saved " + _editedFilePath;
}
catch (Exception ex)
{
StatusText.Text = $"Could not save {_editedFilePath}: {ex.Message} — try Save as…";
}
}
private async Task SaveFractalAs()
{
if (_service.Current is null)
return;
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Save fractal",
DefaultExtension = "ifsjson",
SuggestedFileName = _service.Current.Title,
FileTypeChoices = [new FilePickerFileType("IFS fractal") { Patterns = ["*.ifsjson"] }],
});
if (file is null)
return;
string path = file.Path.LocalPath;
if (!path.EndsWith(".ifsjson", StringComparison.OrdinalIgnoreCase))
path += ".ifsjson";
try
{
await IfsNodesSerializer.SaveJsonFileAsync(_service.Current, path);
}
catch (Exception ex)
{
StatusText.Text = $"Could not save {path}: {ex.Message}";
return;
}
_editedFilePath = path;
_settings.AddRecent(path);
RebuildRecentMenu();
Title = "IFSRenderer — " + Path.GetFileName(path);
StatusText.Text = "Saved " + path;
}
/// <summary>
/// Blank parameters. A random palette by default so it isn't a white screen, or the plain
/// default palette when Settings > User settings > White blank params is set — the same
/// choice Workspace.LoadBlankParams makes.
/// </summary>
private void NewFractal()
{
var blank = new IFS();
if (!_settings.UseWhiteForBlankParams)
blank.Palette = IqPaletteGenerator.Generate(
new Vector4(0.6f, 0.6f, 0.6f, 1f),
new Vector4(0.5f, 0.5f, 0.5f, 1f),
new Vector4(0.5f, 0.5f, 0.5f, 1f),
new Vector4((float)Random.Shared.NextDouble(), (float)Random.Shared.NextDouble(),
(float)Random.Shared.NextDouble(), 1f),
IFSEngine.Model.InterpolationMode.Mixbox,
10);
LoadFractal(blank);
StatusText.Text = "Blank parameters loaded";
}
private async Task Export(string extension)
{
if (_service.Current is null)
return;
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export image",
DefaultExtension = extension.TrimStart('.'),
SuggestedFileName = _service.Current.Title,
FileTypeChoices = [new FilePickerFileType(extension.ToUpperInvariant())
{ Patterns = ["*" + extension] }],
});
if (file is null)
return;
string path = file.Path.LocalPath;
if (!path.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
path += extension;
int w = (int)(ExportWidth.Value ?? 1920);
int h = (int)(ExportHeight.Value ?? 1080);
int level = (int)QualitySlider.Value;
OverlayText.IsVisible = true;
OverlayText.Text = $"Rendering {w}x{h} to level {level}…";
try
{
await _service.ExportAsync(path, w, h, level,
lvl => Dispatcher.UIThread.Post(() => OverlayText.Text = $"Rendering {w}x{h} … level {lvl}/{level}"),
new ExportMetadata
{
SaveMetadata = _settings.SaveMetadata,
IncludeParams = _settings.SaveMetadata && _settings.IncludeParamsInMetadata,
Author = _settings.AuthorName,
CreatedUtc = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ",
System.Globalization.CultureInfo.InvariantCulture),
});
StatusText.Text = "Exported " + path;
}
catch (Exception ex)
{
StatusText.Text = "Export failed: " + ex.Message;
}
finally
{
OverlayText.IsVisible = false;
}
}
/// <summary>Palettes parsed from the most recently opened .gradient / .ugr file.</summary>
private List<ColorPalette> _loadedPalettes = [];
private void ApplyBackgroundColour(bool updateHex = true)
{
IFS f = _service.Current;
if (f is null)
return;
int r = (int)BgR.Value, g = (int)BgG.Value, b = (int)BgB.Value;
_service.Post(_ => f.BackgroundColor = System.Drawing.Color.FromArgb(255, r, g, b));
BackgroundSwatch.Background = new SolidColorBrush(Color.FromRgb((byte)r, (byte)g, (byte)b));
if (updateHex)
SyncHexBox((byte)r, (byte)g, (byte)b);
}
/// <summary>Repaints the swatch and hex box from the open fractal.</summary>
private void ShowBackgroundColour()
{
IFS f = _service.Current;
if (f is null)
return;
System.Drawing.Color c = f.BackgroundColor;
BackgroundSwatch.Background = new SolidColorBrush(Color.FromRgb(c.R, c.G, c.B));
SyncHexBox(c.R, c.G, c.B);
}
private void SyncHexBox(byte r, byte g, byte b)
{
_syncingUi = true;
try { BgHex.Text = $"#{r:X2}{g:X2}{b:X2}"; }
finally { _syncingUi = false; }
}
/// <summary>
/// A .gradient / .ugr file holds MANY palettes, so parsing it fills a picker
/// rather than applying one directly - the same shape as the WPF palette dialog.
/// </summary>
private async Task LoadPaletteFile()
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Load palette file",
AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType("Flame palettes") { Patterns = ["*.gradient", "*.ugr"] }],
});
if (files.Count == 0)
return;
string path = files[0].Path.LocalPath;
try
{
_loadedPalettes = (await FlameFormatImporter.ImportFromFileAsync(path)).Palettes;
}
catch (Exception ex)
{
StatusText.Text = "Could not read palette file: " + ex.Message;
return;
}
if (_loadedPalettes.Count == 0)
{
StatusText.Text = "No palettes found in " + Path.GetFileName(path);
return;
}
BtnPickPalette.IsVisible = _loadedPalettes.Count > 1;
ApplyPalette(_loadedPalettes[0]);
StatusText.Text = $"{_loadedPalettes.Count} palette(s) loaded from {Path.GetFileName(path)}";
if (_loadedPalettes.Count > 1)
await PickPalette();
}
/// <summary>Offers every palette in the loaded file as a gradient, and applies the pick.</summary>
private async Task PickPalette()
{
if (_loadedPalettes.Count == 0)
return;
int index = await Dialogs.ShowPalettePicker(this, _loadedPalettes);
if (index < 0 || index >= _loadedPalettes.Count)
return;
Snapshot();
ApplyPalette(_loadedPalettes[index]);
}
private void ApplyPalette(ColorPalette palette)
{
IFS f = _service.Current;
if (f is null || palette is null)
return;
// The gradient lives in its own GPU buffer now, so the params buffer is not enough.
_service.Post(r =>
{
f.Palette = palette;
r.InvalidatePaletteBuffer();
r.InvalidateParamsBuffer();
});
ShowPalette(palette);
NodeEditorView.InvalidateVisual();
}
/// <summary>Shows the palette on the gradient bar and names it.</summary>
private void ShowPalette(ColorPalette palette)
{
// ColorPalette holds KEY colours - stops - rather than FlamePalette's 400 baked samples,
// so the count means something different and is worth saying plainly.
PaletteName.Text = palette is null || palette.KeyColors.Count == 0
? "(none)"
: $"{palette.Name} · {palette.KeyColors.Count} stops";
PaletteBar.Palette = palette;
PaletteBar.ColorIndex = _selectedIterator?.ColorIndex ?? 0;
if (palette is null || palette.KeyColors.Count == 0)
return;
}
/// <summary>Resolution presets loaded from the shipped Library/ResolutionPresets.json.</summary>
private List<KeyValuePair<string, int[]>> _resolutionPresets = [];
/// <summary>Width divided by height, captured when the aspect lock is engaged.</summary>
private double _aspect;
private void RecomputeAspect()
{
double w = (double)(ExportWidth.Value ?? 0), h = (double)(ExportHeight.Value ?? 0);
_aspect = h > 0 ? w / h : 0;
}
/// <summary>
/// Applies the export resolution to the fractal. In final-render mode the histogram
/// follows it 1:1; otherwise the preview stays scaled to the viewport.
/// </summary>
private void ApplyExportResolution()
{
IFS f = _service.Current;
if (f is null)
return;
int w = (int)(ExportWidth.Value ?? 1920), h = (int)(ExportHeight.Value ?? 1080);
if (w <= 0 || h <= 0)
return;
bool final = FinalRenderMode.IsChecked == true;
var size = ViewportPixelSize();
_service.Post(r =>
{
if (final)
{
f.ImageResolution = new System.Drawing.Size(w, h);
r.LoadParams(f);
r.SetHistogramScale(1.0);
}
});
if (!final)
_service.SetViewport(size.Width, size.Height, fillViewport: true);
}
/// <summary>
/// Applies a change that alters how samples are produced, discarding the
/// accumulated histogram so old and new samples are never mixed.
/// </summary>
private void QualityChange(Action<IFS> onFractal, Action<IRenderer> onRenderer)
{
IFS f = _service.Current;
if (f is null)
return;
_service.Post(r =>
{
onFractal?.Invoke(f);
onRenderer?.Invoke(r);
r.InvalidateHistogramBuffer();
});
}
// ------------------------------------------------------- connections / params
private (Iterator From, Iterator To)? _connection;
private EffectLayer _selectedLayer;
private List<EffectLayer> _layers = [];
/// <summary>Makes a graph-side selection the panel's selection, without re-entering the graph.</summary>
private void SelectFromGraph(Iterator it)
{
SelectIterator(it);
int idx = _iterators.IndexOf(it);
if (idx < 0)
return;
_syncingUi = true;
try { IteratorList.SelectedIndex = idx; }
finally { _syncingUi = false; }
}
private void OnConnectionSelected(Iterator from, Iterator to)
{
_connection = (from, to);
_syncingUi = true;
try
{
string a = from.Transform?.Name ?? "iterator";
string b = to.Transform?.Name ?? "iterator";
ConnectionName.Text = from == to ? $"{a} → itself" : $"{a} → {b}";
ConnectionWeight.IsEnabled = true;
// Makes the keyframe diamond live for this connection: xaos weights are animatable
// in the canonical (ConnectionViewModel.cs:55) and were not here, so the diamond sat
// permanently dead.
ConnectionWeight.AnimationPath = $"[{from.Id}].[{to.Id}]";
ConnectionWeight.Value = from.WeightTo.TryGetValue(to, out double w) ? w : 0;
}
finally { _syncingUi = false; }
}
/// <summary>
/// Builds a ValueSlider per plugin parameter, using the canonical settings from
/// <c>RealParamViewModel</c> / <c>Vec3ParamViewModel</c>: the plugin's own default, an
/// increment of 0.001, and no bounds.
/// </summary>
/// <param name="pathPrefix">
/// Animation channel prefix, e.g. <c>[iteratorId]</c>. Null for effect layers, which have
/// no path this build can resolve, so their params are simply not keyable.
/// </param>
private void BuildParamSliders(StackPanel host, Dictionary<string, double> reals,
Dictionary<string, Vector3> vec3s, Action onChanged,
IReadOnlyDictionary<string, double> realDefaults = null,
IReadOnlyDictionary<string, Vector3> vec3Defaults = null,
string pathPrefix = null)
{
host.Children.Clear();
BuildRealParamSliders(host, reals, onChanged, realDefaults, pathPrefix);
BuildVec3ParamSliders(host, vec3s, onChanged, vec3Defaults, pathPrefix);
}
private void BuildRealParamSliders(StackPanel host, Dictionary<string, double> parameters,
Action onChanged, IReadOnlyDictionary<string, double> defaults, string pathPrefix)
{
if (parameters is null || parameters.Count == 0)
return;
foreach (string key in parameters.Keys.ToList())
{
string captured = key;
var slider = new ValueSlider
{
SliderSettings = new ValueSliderSettings
{
Label = key,
DefaultValue = defaults is not null && defaults.TryGetValue(key, out double d) ? d : 0,
Increment = 0.001,
AnimationPath = pathPrefix is null ? null : $"{pathPrefix}.RealParams.[{key}]",
ValueWillChange = Snapshot,
ValueChanged = v =>
{
// Mutate on the GL thread - the render loop reads these every frame.
_service.Post(_ => parameters[captured] = v);
onChanged();
},
},
Value = parameters[key],
};
host.Children.Add(slider);
}
}
/// <summary>One labelled group of three unlabelled axis sliders, as Vec3Control does.</summary>
private void BuildVec3ParamSliders(StackPanel host, Dictionary<string, Vector3> parameters,
Action onChanged, IReadOnlyDictionary<string, Vector3> defaults, string pathPrefix)
{
if (parameters is null || parameters.Count == 0)
return;
foreach (string key in parameters.Keys.ToList())
{
string captured = key;
Vector3 fallback = defaults is not null && defaults.TryGetValue(key, out Vector3 d)
? d : default;
host.Children.Add(new TextBlock
{
Text = key,
FontSize = 13,
Foreground = Brushes.Silver,
Margin = new Thickness(0, 6, 0, 0),
});
Axis("X", parameters[key].X, fallback.X, v => Update(c => c with { X = (float)v }));
Axis("Y", parameters[key].Y, fallback.Y, v => Update(c => c with { Y = (float)v }));
Axis("Z", parameters[key].Z, fallback.Z, v => Update(c => c with { Z = (float)v }));
void Update(Func<Vector3, Vector3> change)
{
_service.Post(_ => parameters[captured] = change(parameters[captured]));
onChanged();
}
void Axis(string axis, float value, float fallbackValue, Action<double> apply) =>
host.Children.Add(new ValueSlider
{
SliderSettings = new ValueSliderSettings
{
Label = $"{key}-{axis}",
IsLabelShown = false,
DefaultValue = fallbackValue,
Increment = 0.001,
AnimationPath = pathPrefix is null
? null : $"{pathPrefix}.Vec3Params.[{key}].{axis}",
ValueWillChange = Snapshot,
ValueChanged = apply,
},
Value = value,
});
}
}
private void RefreshIteratorLabel(Iterator it)
{
int idx = _iterators.IndexOf(it);
if (idx < 0 || IteratorList.ItemsSource is not List<string> labels)
return;
labels[idx] = Describe(it);
_syncingUi = true;
try
{
IteratorList.ItemsSource = null;
IteratorList.ItemsSource = labels;
IteratorList.SelectedIndex = idx;
}
finally { _syncingUi = false; }
}
// ------------------------------------------------------------ post-processing
/// <summary>
/// Rebuilds the post-effect stack as one Expander per layer, matching PostFxPanel.xaml:
/// each carries its own enable checkbox, its name, hover-revealed info / ▲ / ▼ / ✕ buttons,
/// and its parameters inside.
/// </summary>
private void RebuildPostFxList(IFS f)
{
_layers = f.PostEffects.ToList();
PostFxLayers.Children.Clear();
PostFxEmpty.IsVisible = _layers.Count == 0;
foreach (EffectLayer layer in _layers)
PostFxLayers.Children.Add(BuildLayerCard(layer));
}
private Control BuildLayerCard(EffectLayer layer)
{
var enabled = new CheckBox
{
IsChecked = layer.Enabled,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0),
};
enabled.IsCheckedChanged += (_, _) =>
{
if (_syncingUi)
return;
Snapshot();
bool on = enabled.IsChecked == true;
_service.Post(_ => layer.Enabled = on);
};
var name = new TextBlock
{
Text = layer.Effect?.Name ?? "effect",
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
};
Grid.SetColumn(name, 1);
var tools = new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
IsVisible = false,
};
Grid.SetColumn(tools, 2);
tools.Children.Add(LayerButton("ℹ️", "Effect information", null, layer));
tools.Children.Add(LayerButton("▲", "Move up", () => MovePostFx(layer, -1), null));
tools.Children.Add(LayerButton("▼", "Move down", () => MovePostFx(layer, +1), null));
Button remove = LayerButton("✕", "Remove layer", () => RemovePostFx(layer), null);
remove.Foreground = Brushes.Red;
tools.Children.Add(remove);
var header = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
MinHeight = 32,
};
header.Children.Add(enabled);
header.Children.Add(name);
header.Children.Add(tools);
var body = new StackPanel();
// Effect params are plain uniforms set each frame, so no buffer invalidation.
BuildParamSliders(body, layer.RealParams, layer.Vec3Params, () => { },
layer.Effect?.RealParams, layer.Effect?.Vec3Params);
var expander = new Expander
{
Header = header,
Content = body,
Margin = new Thickness(0, 2),
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch,
};
// The header buttons only appear while the layer is hovered, as in the original.
expander.PointerEntered += (_, _) => tools.IsVisible = true;
expander.PointerExited += (_, _) => tools.IsVisible = false;
return expander;
}
private Button LayerButton(string glyph, string tip, Action onClick, EffectLayer infoFor)
{
var b = new Button
{
Content = glyph,
Classes = { "Flat" },
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Padding = new Thickness(3, 0),
MinWidth = 0,
FontSize = 12,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
};
ToolTip.SetTip(b, tip);
if (infoFor is not null)
b.Flyout = EffectDetails(infoFor);
else
b.Click += (_, _) => onClick();
return b;
}
/// <summary>Name, tag chips, description, include-uses, reference link and Edit source.</summary>
private Flyout EffectDetails(EffectLayer layer)
{
var stack = new StackPanel { MaxWidth = 300, Spacing = 4 };
EffectPlugin plugin = layer.Effect;
stack.Children.Add(new TextBlock
{
Text = plugin?.Name ?? "effect",
FontSize = 18,
FontWeight = FontWeight.Bold,
TextWrapping = TextWrapping.Wrap,
});
if (plugin?.Tags is { Count: > 0 })
{
var chips = new WrapPanel();
foreach (string tag in plugin.Tags)
chips.Children.Add(new Border
{
Background = new SolidColorBrush(Color.Parse("#5a5a5a")),
CornerRadius = new CornerRadius(9),
Padding = new Thickness(8, 2),
Margin = new Thickness(0, 2, 4, 2),
Child = new TextBlock { Text = tag, FontSize = 11 },
});
stack.Children.Add(chips);
}
stack.Children.Add(new TextBlock { Text = "Description", FontWeight = FontWeight.Bold, Margin = new Thickness(0, 6, 0, 0) });
stack.Children.Add(new TextBlock
{
Text = string.IsNullOrWhiteSpace(plugin?.Description) ? "—" : plugin.Description,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(8, 0, 0, 0),
});
stack.Children.Add(new TextBlock { Text = "Include uses", FontWeight = FontWeight.Bold, Margin = new Thickness(0, 6, 0, 0) });
stack.Children.Add(new TextBlock
{
Text = plugin?.IncludeUses is { Count: > 0 } ? string.Join(", ", plugin.IncludeUses) : "None",
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(8, 0, 0, 0),
});
if (plugin?.ReferenceUrl is not null)
{
var info = new Button
{
Content = "ℹ️ Information…",
Classes = { "Flat" },
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
};
info.Click += (_, _) => Dialogs.OpenUrl(plugin.ReferenceUrl.ToString());
stack.Children.Add(info);
}
var edit = new Button
{
Content = "✏️ Edit source",
Classes = { "Flat" },
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
};
edit.Click += (_, _) => OpenPluginSource(plugin?.FilePath, "effect");
stack.Children.Add(edit);
return new Flyout { Content = stack };
}
/// <summary>Adds the effect chosen in the combo, which then resets to the placeholder.</summary>
private void AddPostFx()
{
IFS f = _service.Current;
int i = PostFxAddCombo.SelectedIndex;
if (f is null || i < 0 || i >= _service.Effects.Count)
return;
Snapshot();
var layer = new EffectLayer(_service.Effects[i]);
PostThenRefresh(_ => f.AddPostEffectLayer(layer), () =>
{
RebuildPostFxList(f);
_syncingUi = true;
try { PostFxAddCombo.SelectedIndex = -1; }
finally { _syncingUi = false; }
});
}
private void RemovePostFx(EffectLayer layer)
{
IFS f = _service.Current;
if (f is null || layer is null)
return;
Snapshot();
PostThenRefresh(_ => f.RemovePostEffectLayer(layer), () => RebuildPostFxList(f));
}
private void MovePostFx(EffectLayer layer, int delta)
{
IFS f = _service.Current;
if (f is null || layer is null)
return;
int target = _layers.IndexOf(layer) + delta;
if (target < 0 || target >= _layers.Count)
return;
Snapshot();
PostThenRefresh(_ => f.MovePostEffectLayer(layer, target), () => RebuildPostFxList(f));
}
private async Task CopyParamsToClipboard()
{
if (_service.Current is null || Clipboard is null)
return;
await Clipboard.SetTextAsync(IfsNodesSerializer.SerializeJsonString(_service.Current));
StatusText.Text = "Parameters copied to clipboard";
}
private async Task PasteParamsFromClipboard()
{
if (Clipboard is null)
return;
string text = await Clipboard.GetTextAsync();
if (string.IsNullOrWhiteSpace(text))
return;
try
{
IFS pasted = IfsNodesSerializer.DeserializeJsonString(text, _service.Transforms, _service.Effects, true);
LoadFractal(pasted);
StatusText.Text = "Parameters pasted from clipboard";
}
catch (Exception ex)
{
StatusText.Text = "Clipboard does not contain valid parameters: " + ex.Message;
}
}
private void BuildTemplatesMenu(string library)
{
string dir = Path.Combine(library, "Templates");
if (!Directory.Exists(dir))
return;
var items = new List<MenuItem>();
foreach (string path in Directory.GetFiles(dir, "*.ifsjson").OrderBy(f => f))
{
string captured = path;
var item = new MenuItem { Header = Path.GetFileNameWithoutExtension(path).Replace('_', ' ') };
item.Click += async (_, _) => await OpenPath(captured, addToRecents: false);
items.Add(item);
}
MenuTemplates.ItemsSource = items;
}
private void RebuildRecentMenu()
{
var items = new List<MenuItem>();
foreach (string path in _settings.RecentFiles)
{
string captured = path;
var item = new MenuItem { Header = Path.GetFileName(path), };
ToolTip.SetTip(item, path);
item.Click += async (_, _) => await OpenPath(captured);
items.Add(item);
}
MenuRecent.ItemsSource = items;
MenuRecent.IsEnabled = items.Count > 0;
}
/// <summary>
/// Opens a document. Accepts .ifsjson, a .png exported with parameters embedded,
/// or a .gradient/.ugr palette file.
/// </summary>
private async Task OpenPath(string path, bool addToRecents = true)
{
try
{
if (path.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
{
if (!PngParams.TryExtractParams(path, _service.Transforms, _service.Effects, out IFS fromImage))
{
StatusText.Text = "That PNG has no embedded parameters";
return;
}
LoadFractal(fromImage);
StatusText.Text = "Parameters recovered from " + Path.GetFileName(path);
return;
}
if (path.EndsWith(".gradient", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".ugr", StringComparison.OrdinalIgnoreCase))
{
_loadedPalettes = (await FlameFormatImporter.ImportFromFileAsync(path)).Palettes;
if (_loadedPalettes.Count == 0)
{
StatusText.Text = "No palettes found in " + Path.GetFileName(path);
return;
}
BtnPickPalette.IsVisible = _loadedPalettes.Count > 1;
Snapshot();
ApplyPalette(_loadedPalettes[0]);
StatusText.Text = $"{_loadedPalettes.Count} palette(s) loaded";
return;
}
IFS fractal = await IfsNodesSerializer.LoadJsonFileAsync(path, _service.Transforms, _service.Effects, true);
LoadFractal(fractal, path);
if (addToRecents)
{
_settings.AddRecent(path);
RebuildRecentMenu();
}
}
catch (Exception ex)
{
StatusText.Text = "Could not open: " + ex.Message;
}
}
/// <summary>
/// Continuous keyboard fly navigation, on the same 16 ms tick and with the same
/// mapping the WPF display used: WASD/QE translate, IJKL/UO rotate. Rotation speed
/// scales with field of view so a narrow lens moves proportionally slower.
/// </summary>
private void StartNavigationTick()
{
AddHandler(KeyDownEvent, (_, e) =>
{
if (IsTypingTarget())
return;
// Fly navigation ignores Ctrl-modified keys. Without this, an accelerator we do not
// bind - Ctrl+W, Ctrl+D - reached the camera instead of doing nothing. Canonical
// avoids the clash by scoping fly nav to the viewport element; with the Editor
// folded into this window there is no such scope, so Ctrl is simply not a nav
// modifier here. Shift (slow) still is.
if (e.KeyModifiers.HasFlag(KeyModifiers.Control) || e.KeyModifiers.HasFlag(KeyModifiers.Alt))
return;
_keysDown.Add(e.Key);
if (e.Key == Key.Space)
{
ToggleRendering();
e.Handled = true;
}
else if (e.Key == Key.Delete)
{
// EditorWindow.xaml binds DELETE to RemoveSelectedCommand.
RemoveSelected();
e.Handled = true;
}
}, RoutingStrategies.Tunnel);
AddHandler(KeyUpEvent, (_, e) => _keysDown.Remove(e.Key), RoutingStrategies.Tunnel);
LostFocus += (_, _) => _keysDown.Clear();
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) };
timer.Tick += (_, _) =>
{
if (_keysDown.Count == 0 || _service.Current is null || _renderingPaused)
return;
var translate = new Vector3(
Held(Key.D) - Held(Key.A),
Held(Key.E) - Held(Key.Q),
Held(Key.W) - Held(Key.S)) * 0.005f;
var rotate = new Vector3(
Held(Key.L) - Held(Key.J),
Held(Key.K) - Held(Key.I),
Held(Key.U) - Held(Key.O)) * 0.03f;
if (translate.LengthSquared() <= 0 && rotate.LengthSquared() <= 0)
return;
float sensitivity = (float)Sensitivity.Value;
// Shift slows the fly, as in InteractiveDisplay.xaml.cs:122-131.
if (_keysDown.Contains(Key.LeftShift) || _keysDown.Contains(Key.RightShift))
sensitivity *= 0.05f;
if (InvertY.IsChecked == true)
rotate.Y = -rotate.Y;
CameraChange(c =>
{
float rotateSpeed = (float)c.FieldOfView / 180.0f;
var scaled = new Vector3(rotate.X * rotateSpeed, rotate.Y * rotateSpeed, rotate.Z);
// Translation is proportional to how far away the subject is, so the fly does not
// rocket past it up close (InteractiveDisplay.xaml.cs:169-170).
float reach = Math.Abs((float)c.FocusDistance) * 2.0f;
if (translate.LengthSquared() > 0)
c.Translate(translate * sensitivity * reach);
if (scaled.LengthSquared() > 0)
c.Rotate(scaled * sensitivity);
});
};
timer.Start();
float Held(Key k) => _keysDown.Contains(k) ? 1f : 0f;
}
/// <summary>Keyboard navigation must not fire while the user is typing in a field.</summary>
private bool IsTypingTarget() =>
FocusManager?.GetFocusedElement() is TextBox or NumericUpDown;
private void ToggleRendering()
{
_renderingPaused = !_renderingPaused;
_service.Paused = _renderingPaused;
MenuToggleRender.Header = _renderingPaused ? "Resume _rendering" : "Pause _rendering";
// MainViewModel.IsRenderingIcon (:64): play glyph when paused, pause bars when running.
BtnToggleRender.Content = _renderingPaused ? "▶" : "||";
StatusText.Text = _renderingPaused ? "Rendering paused" : "Rendering resumed";
}
/// <summary>Pushes camera values into the numeric fields, unless one is being typed into.</summary>
private void RefreshCameraFields()
{
IFS f = _service.Current;
if (f is null || IsTypingTarget())
return;
_syncingUi = true;
try
{
PosX.Value = f.Camera.Position.X;
PosY.Value = f.Camera.Position.Y;
PosZ.Value = f.Camera.Position.Z;
OriX.Value = f.Camera.Orientation.X;
OriY.Value = f.Camera.Orientation.Y;
OriZ.Value = f.Camera.Orientation.Z;
OriW.Value = f.Camera.Orientation.W;
TiltX.Value = f.Camera.ProjectionTilt.X;
TiltY.Value = f.Camera.ProjectionTilt.Y;
TiltZ.Value = f.Camera.ProjectionTilt.Z;
}
finally { _syncingUi = false; }
}
/// <summary>
/// Shows or hides a pane. The pane lives in a Grid column, so its column and the splitter
/// beside it have to collapse with it - hiding the Border alone would leave a gap.
/// </summary>
private void TogglePanel(Control panel, MenuItem item, string label,
int column, Control splitter, double width)
{
bool show = !panel.IsVisible;
panel.IsVisible = show;
splitter.IsVisible = show;
PaneGrid.ColumnDefinitions[column].Width = show ? new GridLength(width) : new GridLength(0);
PaneGrid.ColumnDefinitions[column == 0 ? 1 : 3].Width = show ? new GridLength(4) : new GridLength(0);
item.Header = (show ? "✓ " : "") + label;
}
/// <summary>Pushes stored preferences into the live UI and renderer.</summary>
private void ApplySettings()
{
_syncingUi = true;
try
{
InvertY.IsChecked = _settings.InvertAxisY;
Sensitivity.Value = Math.Clamp(_settings.Sensitivity, 0.1, 10.0);
}
finally { _syncingUi = false; }
int framerate = _settings.TargetFramerate;
int workgroups = _settings.WorkgroupCount;
bool perceptual = _settings.PerceptuallyUniformUpdates;
_service.Post(r => r.EnablePerceptualUpdates = perceptual);
_service.Post(r =>
{
r.TargetFramerate = framerate;
if (r.WorkgroupCount != workgroups)
r.SetWorkgroupCount(workgroups);
});
}
/// <summary>
/// Everything the dopesheet can key, as engine reflection paths. The iterator and
/// effect entries use the same <c>Node[id]</c> / <c>PostFx[id]</c> prefixes and the
/// <c>RealParams.[name]</c> indexer form the WPF app writes.
/// </summary>
private IReadOnlyList<AnimatableParam> BuildAnimatableParams()
{
IFS f = _service.Current;
var list = new List<AnimatableParam>();
if (f is null)
return list;
foreach (string name in new[] { "Brightness", "Gamma", "GammaThreshold", "Vibrancy", "FogEffect", "Entropy" })
list.Add(new AnimatableParam(name, name));
foreach (string name in new[] { "FieldOfView", "FocusDistance", "Aperture", "DepthOfField" })
list.Add(new AnimatableParam("Camera " + name, "Camera." + name));
foreach (string axis in new[] { "X", "Y", "Z" })
list.Add(new AnimatableParam($"Camera position {axis}", $"Camera.Position.{axis}"));
foreach (Iterator it in f.Iterators)
{
string label = string.IsNullOrWhiteSpace(it.Name) ? it.Transform?.Name ?? "iterator" : it.Name;
string prefix = $"Node[{it.Id}]";
foreach (string member in new[] { "BaseWeight", "Opacity", "ColorIndex", "ColorSpeed", "Mix", "Add", "StartWeight" })
list.Add(new AnimatableParam($"{label} · {member}", $"{prefix}.{member}"));
foreach (string key in it.RealParams.Keys)
list.Add(new AnimatableParam($"{label} · {key}", $"{prefix}.RealParams.[{key}]"));
foreach (string key in it.Vec3Params.Keys)
foreach (string axis in new[] { "X", "Y", "Z" })
list.Add(new AnimatableParam($"{label} · {key}.{axis}", $"{prefix}.Vec3Params.[{key}].{axis}"));
}
foreach (EffectLayer layer in f.PostEffects)
{
string prefix = $"PostFx[{layer.Id}]";
foreach (string key in layer.RealParams.Keys)
list.Add(new AnimatableParam($"{layer.Effect?.Name} · {key}", $"{prefix}.RealParams.[{key}]"));
}
return list;
}
/// <summary>Picks a CSV of animation channels — columns are channel paths, rows are frames.</summary>
private async Task<string> PickChannelCsv()
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Import animation channels",
AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType("CSV") { Patterns = ["*.csv"] }],
});
return files.Count == 0 ? null : files[0].Path.LocalPath;
}
private async Task<string> PickAudioFile()
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Load audio",
AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType("Audio") { Patterns = ["*.wav", "*.flac", "*.mp3", "*.ogg", "*.m4a"] }],
});
return files.Count == 0 ? null : files[0].Path.LocalPath;
}
/// <summary>
/// Renders the clip to a PNG sequence, then offers to encode it with ffmpeg.
/// The frames are kept either way - encoding is the optional step.
/// </summary>
/// <summary>Set by the overlay's Cancel button; polled by the frame-export loop.</summary>
private bool _exportCancelled;
private async Task ExportClip()
{
IFS f = _service.Current;
if (f is null)
return;
var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Choose an output folder for the frame sequence",
AllowMultiple = false,
});
if (folders.Count == 0)
return;
string dir = folders[0].Path.LocalPath;
int w = (int)(ExportWidth.Value ?? 1920), h = (int)(ExportHeight.Value ?? 1080);
int level = (int)QualitySlider.Value;
OverlayText.IsVisible = true;
OverlayText.Text = "Rendering clip…";
_exportCancelled = false;
BtnCancelExport.IsVisible = true;
bool exr = _settings.IsRawFrameExportEnabled;
// Video export streams frames into ffmpeg and never touches the disk. Raw exr has no
// streaming form, so it always writes a sequence; so does an unticked video export,
// which is the way to keep frames for re-encoding later.
bool pipeToVideo = _settings.IsExportVideoFileEnabled && !exr;
FfmpegFramePipe pipe = null;
try
{
if (pipeToVideo)
{
string video = Path.Combine(dir,
"animation" + FfmpegFramePipe.ExtensionFor(_settings.FfmpegArgs));
pipe = new FfmpegFramePipe(_settings.FfmpegPath, _settings.FfmpegArgs, video,
AnimationView.AudioPath, f.Dopesheet.Length.TotalSeconds);
// Before frame 0: a bad ffmpeg path must not cost an entire render.
pipe.Start(w, h, Math.Max(f.Dopesheet.Fps, 1));
OverlayText.Text = "Encoding clip…";
}
await _service.ExportFramesAsync(dir, w, h, level,
(frame, total) => Dispatcher.UIThread.Post(() =>
OverlayText.Text = $"Rendering frame {frame}/{total} ({w}x{h}, level {level})"),
() => _exportCancelled,
rawExr: exr,
frameSink: pipe is null ? null : (bgra, _, _) => pipe.Write(bgra));
if (pipeToVideo)
{
// Closed rather than killed even when cancelled, so ffmpeg writes the moov atom
// and a cancelled export still leaves something playable.
OverlayText.Text = "Finishing video…";
string failure = pipe.Finish();
StatusText.Text = failure is not null
? "Video encoding failed: " + failure
: _exportCancelled
? $"Cancelled — wrote the first {pipe.FramesWritten} frames to {pipe.OutputPath}"
: $"Wrote {pipe.OutputPath}";
return;
}
if (_exportCancelled)
{
StatusText.Text = $"Frame render cancelled — frames written so far are in {dir}";
return;
}
StatusText.Text = exr && _settings.IsExportVideoFileEnabled
? $"Rendered exr frames to {dir} — video encoding needs png frames"
: $"Rendered frames to {dir}";
}
catch (Exception ex)
{
StatusText.Text = "Clip render failed: " + ex.Message;
}
finally
{
pipe?.Dispose();
OverlayText.IsVisible = false;
BtnCancelExport.IsVisible = false;
}
}
/// <summary>
/// Renders the clip straight to a video file.
/// </summary>
/// <remarks>
/// Distinct from <see cref="ExportClip"/>, which is the frame-sequence export and only
/// produces a video as a side effect of a Settings checkbox. Video had no menu item at all,
/// so the capability was unreachable without knowing to tick a box two dialogs away.
///
/// Two routes, chosen by the dialog: pipe straight into ffmpeg (nothing touches the disk),
/// or render the frames and encode from them afterwards, which is what the canonical does
/// and the only way to end up with both.
/// </remarks>
private async Task ExportVideo()
{
IFS f = _service.Current;
if (f is null)
return;
int w = (int)(ExportWidth.Value ?? 1920), h = (int)(ExportHeight.Value ?? 1080);
int level = (int)QualitySlider.Value;
int fps = Math.Max(f.Dopesheet.Fps, 1);
bool exr = _settings.IsRawFrameExportEnabled;
Dialogs.VideoExportChoice choice =
await Dialogs.ShowVideoExport(this, _settings, f.Title, w, h, exr,
AnimationView.AudioPath);
if (choice is null)
return;
string dir = Path.GetDirectoryName(choice.OutputPath);
if (string.IsNullOrWhiteSpace(dir))
{
StatusText.Text = "Video export needs a full output path.";
return;
}
Directory.CreateDirectory(dir);
OverlayText.IsVisible = true;
OverlayText.Text = "Rendering clip…";
_exportCancelled = false;
BtnCancelExport.IsVisible = true;
FfmpegFramePipe pipe = null;
try
{
if (!choice.KeepFrames)
{
pipe = new FfmpegFramePipe(_settings.FfmpegPath, choice.Args, choice.OutputPath,
AnimationView.AudioPath, f.Dopesheet.Length.TotalSeconds);
// Before frame 0: a bad ffmpeg path must not cost an entire render.
pipe.Start(w, h, fps);
OverlayText.Text = "Encoding clip…";
}
await _service.ExportFramesAsync(dir, w, h, level,
(frame, total) => Dispatcher.UIThread.Post(() =>
OverlayText.Text = $"Rendering frame {frame}/{total} ({w}x{h}, level {level})"),
() => _exportCancelled,
rawExr: false, // the dialog refuses to export video while exr is selected
frameSink: pipe is null ? null : (bgra, _, _) => pipe.Write(bgra));
if (pipe is not null)
{
OverlayText.Text = "Finishing video…";
string failure = pipe.Finish();
StatusText.Text = failure is not null
? "Video encoding failed: " + failure
: _exportCancelled
? $"Cancelled — wrote the first {pipe.FramesWritten} frames to {pipe.OutputPath}"
: $"Wrote {pipe.OutputPath}";
return;
}
if (_exportCancelled)
{
StatusText.Text = $"Cancelled — frames written so far are in {dir}";
return;
}
OverlayText.Text = "Encoding video…";
(bool ok, string message) = await EncodeWithFfmpeg(dir, fps, choice.OutputPath);
StatusText.Text = ok
? $"Wrote {choice.OutputPath} (frames kept in {dir})"
: "Video encoding failed: " + message;
}
catch (Exception ex)
{
StatusText.Text = "Video export failed: " + ex.Message;
}
finally
{
pipe?.Dispose();
OverlayText.IsVisible = false;
BtnCancelExport.IsVisible = false;
}
}
private async Task<(bool, string)> EncodeWithFfmpeg(string dir, int fps, string outputPath)
{
string exe = string.IsNullOrWhiteSpace(_settings.FfmpegPath) ? "ffmpeg" : _settings.FfmpegPath;
var psi = new System.Diagnostics.ProcessStartInfo(exe)
{
UseShellExecute = false,
RedirectStandardError = true,
WorkingDirectory = dir,
};
// IFSRenderer supplies input, output and fps; everything else comes from the
// Settings > Animations args box (or a preset), as in the canonical app.
foreach (string arg in new[] { "-y", "-framerate", fps.ToString(), "-i", "frame_%05d.png" })
psi.ArgumentList.Add(arg);
string extra = _settings.FfmpegArgs;
if (string.IsNullOrWhiteSpace(extra))
extra = "-c:v libx264 -pix_fmt yuv420p -crf 18";
foreach (string arg in SplitArgs(extra))
psi.ArgumentList.Add(arg);
psi.ArgumentList.Add(outputPath);
try
{
using var process = System.Diagnostics.Process.Start(psi);
if (process is null)
return (false, "Frames rendered, but ffmpeg could not be started.");
string stderr = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
return process.ExitCode == 0
? (true, null)
: (false, "Frames rendered; ffmpeg failed: " + stderr.Split('\n').LastOrDefault(l => l.Length > 0));
}
catch (Exception ex)
{
return (false, "Frames rendered, but ffmpeg is unavailable: " + ex.Message);
}
}
/// <summary>
/// Renders at the export resolution and puts the resulting PNG on the clipboard as a file,
/// which is how a Linux desktop carries an image between applications.
/// </summary>
private async Task ExportToClipboard()
{
if (_service.Current is null || Clipboard is null)
return;
string path = Path.Combine(Path.GetTempPath(),
$"ifsrenderer-{_service.Current.Title}.png".Replace(Path.DirectorySeparatorChar, '-'));
int w = (int)ExportWidth.Value, h = (int)ExportHeight.Value;
int level = (int)QualitySlider.Value;
OverlayText.IsVisible = true;
OverlayText.Text = $"Rendering {w}x{h} for the clipboard…";
try
{
await _service.ExportAsync(path, w, h, level, null, new ExportMetadata
{
SaveMetadata = _settings.SaveMetadata,
IncludeParams = _settings.SaveMetadata && _settings.IncludeParamsInMetadata,
Author = _settings.AuthorName,
});
var data = new DataObject();
data.Set(DataFormats.Files, new[] { new System.IO.FileInfo(path).FullName });
await Clipboard.SetDataObjectAsync(data);
StatusText.Text = "Rendered image copied to the clipboard";
}
catch (Exception ex)
{
StatusText.Text = "Clipboard export failed: " + ex.Message;
}
finally
{
OverlayText.IsVisible = false;
}
}
/// <summary>Splits an ffmpeg argument string, honouring quoted runs.</summary>
private static IEnumerable<string> SplitArgs(string text)
{
var current = new System.Text.StringBuilder();
char quote = '\0';
foreach (char c in text)
{
if (quote != '\0')
{
if (c == quote)
quote = '\0';
else
current.Append(c);
}
else if (c is '"' or '\'')
{
quote = c;
}
else if (char.IsWhiteSpace(c))
{
if (current.Length > 0)
{
yield return current.ToString();
current.Clear();
}
}
else
{
current.Append(c);
}
}
if (current.Length > 0)
yield return current.ToString();
}
/// <summary>
/// Opens the generator. It stays open while you work, sending pinned candidates to the
/// editor as you click them — the canonical SendToMain behaviour, rather than a dialog
/// that closes on a pick.
/// </summary>
private async Task ShowGenerator()
{
var window = new GeneratorWindow(_service)
{
CurrentFractal = () => _service.Current,
};
window.SendToMain += fractal =>
{
LoadFractal(fractal);
StatusText.Text = "Loaded a generated fractal";
};
await window.ShowDialog(this);
}
/// <summary>Opens a folder in the desktop's file manager (the 📂 plugin-directory button).</summary>
private void OpenDirectory(string path)
{
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
{
StatusText.Text = "Plugin directory not found";
return;
}
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path)
{
UseShellExecute = true,
});
}
catch (Exception ex)
{
StatusText.Text = "Could not open the folder: " + ex.Message;
}
}
/// <summary>Hands the plugin file to the desktop's default editor.</summary>
private void OpenPluginSource(string path, string what)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
StatusText.Text = $"No {what} source file to open";
return;
}
Dialogs.OpenUrl(path);
StatusText.Text = "Opened " + Path.GetFileName(path) + " — use Reload plugins after editing";
}
private async Task ReloadPlugins()
{
try
{
await _service.ReloadPluginsAsync(AssetPaths.Library, AssetPaths.Transforms);
TransformCombo.ItemsSource = _service.Transforms.Select(t => t.Name).ToList();
PostFxAddCombo.ItemsSource = _service.Effects.Select(t => t.Name).ToList();
if (_service.Current is not null)
{
RebuildIteratorList(_service.Current);
RebuildPostFxList(_service.Current);
NodeEditorView.InvalidateVisual();
AnimationView.RefreshParams();
}
StatusText.Text = $"Reloaded {_service.Transforms.Count} transforms and {_service.Effects.Count} effects";
}
catch (Exception ex)
{
StatusText.Text = "Plugin reload failed: " + ex.Message;
}
}
}