diff --git a/.github/workflows/mono-desktop.yml b/.github/workflows/mono-desktop.yml
index 4e92b47b..e5847b89 100644
--- a/.github/workflows/mono-desktop.yml
+++ b/.github/workflows/mono-desktop.yml
@@ -23,7 +23,7 @@ jobs:
- name: Install the actual Mono and X11 runtime
run: |
sudo apt-get update
- sudo apt-get install -y --no-install-recommends mono-devel xvfb xauth libgdiplus libgsl-dev fonts-dejavu-core
+ sudo apt-get install -y --no-install-recommends mono-devel xvfb xauth libgdiplus libgsl-dev libxtst6 fonts-dejavu-core
- name: Build the opt-in Framework graph and run desktop acceptance
run: ./build.sh --target=Mono-Validate
- name: Retain runtime evidence even on failure
diff --git a/Computator.NET.Charting/Chart3D/UI/Chart3DControlMono.cs b/Computator.NET.Charting/Chart3D/UI/Chart3DControlMono.cs
index 95e19a19..6971721b 100644
--- a/Computator.NET.Charting/Chart3D/UI/Chart3DControlMono.cs
+++ b/Computator.NET.Charting/Chart3D/UI/Chart3DControlMono.cs
@@ -3,16 +3,19 @@
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Imaging;
+using System.Linq;
using System.Windows.Forms;
-using Computator.NET.DataTypes;
+using Computator.NET.Charting.Printing;
using Computator.NET.DataTypes.Charts;
using Computator.NET.DataTypes.Functions;
-using Computator.NET.Localization;
namespace Computator.NET.Charting.Chart3D.UI
{
- // This adapter keeps the shared presenters and chart factory composable.
- // It deliberately performs no 3D computation or image export: WPF is absent.
+ ///
+ /// Native WinForms Mono f(x,y) chart. Rendering uses libgdiplus through
+ /// System.Drawing; expression compilation/evaluation remains in shared Core.
+ /// The WPF Chart3DControl is completely separate and unaffected.
+ ///
public class
#if __MonoCS__
Chart3DControl
@@ -21,6 +24,17 @@ public class
#endif
: Control, IChart3D
{
+ private readonly List _functions = new List();
+ private readonly List _surfaces = new List();
+ private readonly List> _points = new List>();
+ private double _xMin = -5, _xMax = 5, _yMin = -5, _yMax = 5;
+ private double _quality = 50;
+ private double _yaw = .76, _tilt = .55, _zoom = 1, _panX, _panY;
+ private bool _equalAxes = true;
+ private Chart3DMode _mode = Chart3DMode.Surface;
+ private MouseButtons _dragButton;
+ private Point _lastPointer;
+
public
#if __MonoCS__
Chart3DControl()
@@ -28,61 +42,395 @@ public class
Chart3DControlMono()
#endif
{
+ Name = "chart3D";
Dock = DockStyle.Fill;
BackColor = Color.White;
- ForeColor = Color.Black;
- Text = Strings.Chart3D_is_not_supported_on_Mono;
- AccessibleName = Text;
- SetStyle(ControlStyles.ResizeRedraw | ControlStyles.OptimizedDoubleBuffer, true);
+ ForeColor = Color.FromArgb(30, 41, 59);
+ TabStop = true;
+ AccessibleName = "Interactive 3D chart";
+ SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
+ ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
+ ControlStyles.Selectable, true);
+ }
+
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public int FiniteSurfaceSamples => _surfaces.Sum(surface => surface.FiniteCount);
+
+ [Category("3D view")]
+ [Description("Sampling resolution is limited to 17–97 vertices per axis.")]
+ public double Quality
+ {
+ get => _quality;
+ set
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value))
+ throw new ArgumentOutOfRangeException(nameof(value));
+ var clamped = Math.Max(0, Math.Min(100, value));
+ if (_quality == clamped) return;
+ _quality = clamped;
+ Resample();
+ Changed(nameof(Quality));
+ }
+ }
+
+ [Category("3D view")]
+ public bool EqualAxes
+ {
+ get => _equalAxes;
+ set { if (_equalAxes == value) return; _equalAxes = value; Invalidate(); Changed(nameof(EqualAxes)); }
+ }
+
+ [Category("3D view")]
+ public Chart3DMode Mode
+ {
+ get => _mode;
+ set
+ {
+ if (!Enum.IsDefined(typeof(Chart3DMode), value))
+ throw new ArgumentOutOfRangeException(nameof(value));
+ if (_mode == value) return;
+ _mode = value;
+ Invalidate();
+ Changed(nameof(Mode));
+ }
+ }
+
+ [Category("3D view")]
+ public double Zoom
+ {
+ get => _zoom;
+ set
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value))
+ throw new ArgumentOutOfRangeException(nameof(value));
+ _zoom = Math.Max(.15, Math.Min(7, value));
+ Invalidate();
+ Changed(nameof(Zoom));
+ }
+ }
+
+ [Category("3D view")]
+ public double YawDegrees
+ {
+ get => _yaw * 180 / Math.PI;
+ set
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value))
+ throw new ArgumentOutOfRangeException(nameof(value));
+ _yaw = (value % 360) * Math.PI / 180;
+ Invalidate();
+ Changed(nameof(YawDegrees));
+ }
+ }
+
+ [Category("3D view")]
+ public double TiltDegrees
+ {
+ get => _tilt * 180 / Math.PI;
+ set
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value))
+ throw new ArgumentOutOfRangeException(nameof(value));
+ _tilt = Math.Max(-80, Math.Min(80, value)) * Math.PI / 180;
+ Invalidate();
+ Changed(nameof(TiltDegrees));
+ }
+ }
+
+ [Category("3D area")]
+ public double XMin
+ {
+ get => _xMin;
+ set
+ {
+ if (_xMin == value) return;
+ _xMin = value;
+ Resample();
+ XMinChanged?.Invoke(this, EventArgs.Empty);
+ Changed(nameof(XMin));
+ }
}
+ [Category("3D area")]
+ public double XMax
+ {
+ get => _xMax;
+ set
+ {
+ if (_xMax == value) return;
+ _xMax = value;
+ Resample();
+ XMaxChanged?.Invoke(this, EventArgs.Empty);
+ Changed(nameof(XMax));
+ }
+ }
+
+ [Category("3D area")]
+ public double YMin
+ {
+ get => _yMin;
+ set
+ {
+ if (_yMin == value) return;
+ _yMin = value;
+ Resample();
+ YMinChanged?.Invoke(this, EventArgs.Empty);
+ Changed(nameof(YMin));
+ }
+ }
+
+ [Category("3D area")]
+ public double YMax
+ {
+ get => _yMax;
+ set
+ {
+ if (_yMax == value) return;
+ _yMax = value;
+ Resample();
+ YMaxChanged?.Invoke(this, EventArgs.Empty);
+ Changed(nameof(YMax));
+ }
+ }
+
+ public event EventHandler XMinChanged;
+ public event EventHandler XMaxChanged;
+ public event EventHandler YMinChanged;
+ public event EventHandler YMaxChanged;
+ public event PropertyChangedEventHandler PropertyChanged;
+
+ private void Changed(string property) =>
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(property));
+
+ public void SetChartAreaValues(double x0, double xn, double y0, double yn)
+ {
+ if (!MonoSurfaceSampler.ValidBounds(x0, xn, y0, yn))
+ throw new ArgumentException("3D axes require finite, increasing x and y bounds.");
+ var changeXMin = _xMin != x0;
+ var changeXMax = _xMax != xn;
+ var changeYMin = _yMin != y0;
+ var changeYMax = _yMax != yn;
+ _xMin = x0; _xMax = xn; _yMin = y0; _yMax = yn;
+ Resample(); // atomic; avoids intermediate invalid bounds
+ if (changeXMin) { XMinChanged?.Invoke(this, EventArgs.Empty); Changed(nameof(XMin)); }
+ if (changeXMax) { XMaxChanged?.Invoke(this, EventArgs.Empty); Changed(nameof(XMax)); }
+ if (changeYMin) { YMinChanged?.Invoke(this, EventArgs.Empty); Changed(nameof(YMin)); }
+ if (changeYMax) { YMaxChanged?.Invoke(this, EventArgs.Empty); Changed(nameof(YMax)); }
+ }
+
+ private int GridSize => 17 + (int)Math.Round(_quality * .8);
+
+ private void Resample()
+ {
+ if (IsDisposed || Disposing) return;
+ if (!MonoSurfaceSampler.ValidBounds(_xMin, _xMax, _yMin, _yMax))
+ {
+ _surfaces.Clear();
+ Invalidate();
+ return;
+ }
+
+ // Build a complete new snapshot first. A failed evaluation never
+ // leaves a half-filled mesh or invalid GDI resources behind.
+ var replacement = new List();
+ foreach (var function in _functions)
+ replacement.Add(MonoSurfaceSampler.Sample(function, _xMin, _xMax, _yMin, _yMax, GridSize));
+ _surfaces.Clear();
+ _surfaces.AddRange(replacement);
+ Invalidate();
+ }
+
+ public void AddFunction(Function function)
+ {
+ if (function == null) throw new ArgumentNullException(nameof(function));
+ if (_functions.Count >= 8)
+ throw new InvalidOperationException("A Mono chart can contain at most eight surfaces.");
+ var sampled = MonoSurfaceSampler.Sample(function, _xMin, _xMax, _yMin, _yMax, GridSize);
+ _functions.Add(function);
+ _surfaces.Add(sampled);
+ Invalidate();
+ }
+
+ public void AddPoints(IEnumerable points)
+ {
+ if (points == null) throw new ArgumentNullException(nameof(points));
+ var values = new List();
+ foreach (var point in points)
+ {
+ if (values.Count == 20000)
+ throw new ArgumentOutOfRangeException(nameof(points), "A 3D series supports at most 20,000 points.");
+ if (point != null && MonoSurfaceSampler.IsFinite(point.x) &&
+ MonoSurfaceSampler.IsFinite(point.y) && MonoSurfaceSampler.IsFinite(point.z))
+ values.Add(new Point3D(point.x, point.y, point.z));
+ }
+ if (_points.Sum(series => series.Count) + values.Count > 40000)
+ throw new ArgumentOutOfRangeException(nameof(points), "A chart supports at most 40,000 points.");
+ _points.Add(values);
+ Mode = Chart3DMode.Points;
+ Invalidate();
+ }
+
+ // Retain the list overload used by the Windows/WPF chart.
+ public void AddPoints(List points) => AddPoints((IEnumerable)points);
+
+ public void ClearAll()
+ {
+ _functions.Clear();
+ _surfaces.Clear();
+ _points.Clear();
+ Invalidate();
+ }
+
+ public void Redraw() => Resample();
+
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
- var bounds = Rectangle.Inflate(ClientRectangle, -16, -16);
- if (bounds.Width > 0 && bounds.Height > 0)
- TextRenderer.DrawText(e.Graphics, Text, Font, bounds, ForeColor,
- TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.WordBreak);
- }
-
- private static PlatformNotSupportedException Unsupported() =>
- new PlatformNotSupportedException(Strings.Chart3D_is_not_supported_on_Mono);
-
- public void Print() => throw Unsupported();
- public void PrintPreview() => throw Unsupported();
- public void ShowEditDialog() => throw Unsupported();
- public void AddFunction(Function function) => throw Unsupported();
- public void SaveImage(string path, ImageFormat imageFormat) => throw Unsupported();
- public Image GetImage(int width, int height) => throw Unsupported();
- public void ShowEditPropertiesDialog() => throw Unsupported();
- public void ShowPlotDialog() => throw Unsupported();
- public void AddPoints(IEnumerable points) => throw Unsupported();
- public void AddPoints(List points) => throw Unsupported();
-
- // Shared area, quality and clear actions reach every registered chart.
- // Accept them so selecting or clearing a supported chart keeps working.
- public double XMin { get; set; }
- public double XMax { get; set; }
- public double YMin { get; set; }
- public double YMax { get; set; }
- public double Quality { get; set; }
- public bool EqualAxes { get; set; }
- public Chart3DMode Mode { get; set; }
- public event EventHandler XMinChanged { add { } remove { } }
- public event EventHandler XMaxChanged { add { } remove { } }
- public event EventHandler YMinChanged { add { } remove { } }
- public event EventHandler YMaxChanged { add { } remove { } }
- public event PropertyChangedEventHandler PropertyChanged { add { } remove { } }
+ MonoSceneRenderer.Draw(e.Graphics, ClientRectangle, Font,
+ _surfaces, _points, _mode, _xMin, _xMax, _yMin, _yMax,
+ _equalAxes, _yaw, _tilt, _zoom, _panX, _panY);
+ }
- public void SetChartAreaValues(double x0, double xn, double y0, double yn)
+ public Image GetImage(int width, int height)
+ {
+ if (width < 1 || width > 4096) throw new ArgumentOutOfRangeException(nameof(width));
+ if (height < 1 || height > 4096 || (long)width * height > 8388608)
+ throw new ArgumentOutOfRangeException(nameof(height));
+ var image = new Bitmap(width, height, PixelFormat.Format32bppArgb);
+ try
+ {
+ image.SetResolution(96, 96);
+ // Preserve the user's camera composition when exporting. Pan is
+ // specified in live control pixels, so scale it for another output size.
+ var panX = ClientSize.Width > 0 ? _panX * width / ClientSize.Width : _panX;
+ var panY = ClientSize.Height > 0 ? _panY * height / ClientSize.Height : _panY;
+ using (var graphics = Graphics.FromImage(image))
+ MonoSceneRenderer.Draw(graphics, new Rectangle(0, 0, width, height),
+ Font, _surfaces, _points, _mode, _xMin, _xMax, _yMin, _yMax,
+ _equalAxes, _yaw, _tilt, _zoom, panX, panY);
+ return image;
+ }
+ catch { image.Dispose(); throw; }
+ }
+
+ public void SaveImage(string path, ImageFormat imageFormat)
+ {
+ if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("An image path is required.", nameof(path));
+ if (imageFormat == null) throw new ArgumentNullException(nameof(imageFormat));
+ var width = Math.Max(1, ClientSize.Width);
+ var height = Math.Max(1, ClientSize.Height);
+ using (var image = GetImage(width, height))
+ image.Save(path, imageFormat);
+ }
+
+ public void Print()
+ {
+ using (var image = GetImage(Math.Max(1, Width), Math.Max(1, Height)))
+ new ImagePrinter().Print(image);
+ }
+
+ public void PrintPreview()
+ {
+ using (var image = GetImage(Math.Max(1, Width), Math.Max(1, Height)))
+ new ImagePrinter().PrintPreview(image);
+ }
+
+ public void ShowEditDialog() => ShowEditPropertiesDialog();
+
+ public void ShowEditPropertiesDialog()
+ {
+ using (var editor = new EditChartProperties(this))
+ editor.ShowDialog(FindForm());
+ Redraw();
+ }
+
+ public void ShowPlotDialog()
+ {
+ // Do not reparent/dispose the live chart from the main desktop view.
+ var preview = new Chart3DControl
+ {
+ Quality = Quality, EqualAxes = EqualAxes, Mode = Mode,
+ YawDegrees = YawDegrees, TiltDegrees = TiltDegrees, Zoom = Zoom
+ };
+ preview.SetChartAreaValues(_xMin, _xMax, _yMin, _yMax);
+ foreach (var function in _functions) preview.AddFunction(function);
+ foreach (var series in _points) preview.AddPoints(series);
+ preview.Mode = Mode;
+ new PlotForm(preview).Show();
+ }
+
+ protected override void OnMouseDown(MouseEventArgs e)
+ {
+ base.OnMouseDown(e);
+ if (e.Button != MouseButtons.Left && e.Button != MouseButtons.Right) return;
+ Focus();
+ _dragButton = e.Button;
+ _lastPointer = e.Location;
+ Capture = true;
+ }
+
+ protected override void OnMouseMove(MouseEventArgs e)
{
- XMin = x0;
- XMax = xn;
- YMin = y0;
- YMax = yn;
+ base.OnMouseMove(e);
+ if (_dragButton == MouseButtons.None) return;
+ var dx = e.X - _lastPointer.X;
+ var dy = e.Y - _lastPointer.Y;
+ _lastPointer = e.Location;
+ if (_dragButton == MouseButtons.Left)
+ {
+ _yaw += dx * .012;
+ _tilt = Math.Max(-1.4, Math.Min(1.4, _tilt + dy * .01));
+ }
+ else
+ {
+ _panX += dx;
+ _panY += dy;
+ }
+ Invalidate(); // camera changes do not reevaluate any expression
}
- public void Redraw() => Invalidate();
- public void ClearAll() => Invalidate();
+ protected override void OnMouseUp(MouseEventArgs e)
+ {
+ base.OnMouseUp(e);
+ if (e.Button != _dragButton) return;
+ _dragButton = MouseButtons.None;
+ Capture = false;
+ }
+
+ protected override void OnMouseWheel(MouseEventArgs e)
+ {
+ base.OnMouseWheel(e);
+ Zoom *= Math.Pow(1.15, e.Delta / 120.0);
+ }
+
+ protected override bool IsInputKey(Keys keyData)
+ {
+ var key = keyData & Keys.KeyCode;
+ return key == Keys.Up || key == Keys.Down || key == Keys.Left || key == Keys.Right ||
+ base.IsInputKey(keyData);
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ switch (e.KeyCode)
+ {
+ case Keys.Left: _yaw -= .12; break;
+ case Keys.Right: _yaw += .12; break;
+ case Keys.Up: _tilt = Math.Min(1.4, _tilt + .12); break;
+ case Keys.Down: _tilt = Math.Max(-1.4, _tilt - .12); break;
+ case Keys.Add:
+ case Keys.Oemplus: Zoom *= 1.15; break;
+ case Keys.Subtract:
+ case Keys.OemMinus: Zoom /= 1.15; break;
+ case Keys.Home:
+ _yaw = .76; _tilt = .55; _panX = _panY = 0; Zoom = 1;
+ break;
+ default: return;
+ }
+ e.Handled = true;
+ Invalidate();
+ }
}
}
diff --git a/Computator.NET.Charting/Chart3D/UI/MonoSurfaceRenderer.cs b/Computator.NET.Charting/Chart3D/UI/MonoSurfaceRenderer.cs
new file mode 100644
index 00000000..56c893f4
--- /dev/null
+++ b/Computator.NET.Charting/Chart3D/UI/MonoSurfaceRenderer.cs
@@ -0,0 +1,376 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using System.Globalization;
+using Computator.NET.DataTypes;
+using Computator.NET.DataTypes.Charts;
+using Computator.NET.DataTypes.Functions;
+
+namespace Computator.NET.Charting.Chart3D.UI
+{
+ // Mono uses its existing libgdiplus/System.Drawing stack. No WPF, browser,
+ // GPU driver or extra native library is required.
+ internal sealed class MonoSurfaceGrid
+ {
+ internal readonly double[] X;
+ internal readonly double[] Y;
+ internal readonly double[] Z; // row-major; NaN means a missing vertex
+ internal readonly int FiniteCount;
+
+ internal int Size => X.Length;
+
+ internal MonoSurfaceGrid(double[] x, double[] y, double[] z, int finiteCount)
+ {
+ X = x;
+ Y = y;
+ Z = z;
+ FiniteCount = finiteCount;
+ }
+ }
+
+ internal static class MonoSurfaceSampler
+ {
+ internal const int MaximumGridSize = 97;
+
+ internal static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
+
+ internal static bool ValidBounds(double xMin, double xMax, double yMin, double yMax) =>
+ IsFinite(xMin) && IsFinite(xMax) && IsFinite(yMin) && IsFinite(yMax) &&
+ xMin < xMax && yMin < yMax && IsFinite(xMax - xMin) && IsFinite(yMax - yMin);
+
+ internal static MonoSurfaceGrid Sample(Function function, double xMin, double xMax,
+ double yMin, double yMax, int gridSize)
+ {
+ if (function == null) throw new ArgumentNullException(nameof(function));
+ if (function.FunctionType != FunctionType.Real3D)
+ throw new NotSupportedException("Mono 3D requires an explicit real f(x,y) surface; implicit surfaces are not supported.");
+ if (!ValidBounds(xMin, xMax, yMin, yMax))
+ throw new ArgumentException("3D axes require finite, increasing x and y bounds.");
+ if (gridSize < 9 || gridSize > MaximumGridSize)
+ throw new ArgumentOutOfRangeException(nameof(gridSize));
+
+ var x = new double[gridSize];
+ var y = new double[gridSize];
+ var z = new double[checked(gridSize * gridSize)];
+ for (var i = 0; i < gridSize; i++)
+ {
+ var ratio = i / (double)(gridSize - 1);
+ x[i] = i == 0 ? xMin : i == gridSize - 1 ? xMax : (1 - ratio) * xMin + ratio * xMax;
+ y[i] = i == 0 ? yMin : i == gridSize - 1 ? yMax : (1 - ratio) * yMin + ratio * yMax;
+ }
+
+ var finite = 0;
+ for (var row = 0; row < gridSize; row++)
+ for (var column = 0; column < gridSize; column++)
+ {
+ double height;
+ try { height = function.Evaluate(x[column], y[row]); }
+ catch (ArithmeticException) { height = double.NaN; }
+ catch (CalculationException error) when (error.InnerException is ArithmeticException)
+ { height = double.NaN; }
+ z[row * gridSize + column] = IsFinite(height) ? height : double.NaN;
+ if (IsFinite(height)) finite++;
+ }
+ return new MonoSurfaceGrid(x, y, z, finite);
+ }
+ }
+
+ ///
+ /// Bounded, depth-sorted, orthographic software 3D. Both live painting and
+ /// exports invoke this same renderer; sampling is independent of viewport size.
+ ///
+ internal static class MonoSceneRenderer
+ {
+ private struct Vertex
+ {
+ internal PointF Pixel;
+ internal double Depth;
+ internal bool Valid;
+ }
+
+ private sealed class Primitive
+ {
+ internal PointF[] Polygon;
+ internal PointF Center;
+ internal double Depth;
+ internal int Shade;
+ internal bool Point;
+ }
+
+ private struct Projection
+ {
+ internal double XMid, YMid, ZMid, XSpan, YSpan, ZSpan;
+ internal double SinYaw, CosYaw, SinTilt, CosTilt;
+ internal double Unit, CenterX, CenterY;
+ }
+
+ internal static void Draw(Graphics graphics, Rectangle viewport, Font font,
+ IList surfaces, IList> pointSeries, Chart3DMode mode,
+ double xMin, double xMax, double yMin, double yMax, bool equalAxes,
+ double yaw, double tilt, double zoom, double panX, double panY)
+ {
+ var state = graphics.Save();
+ try
+ {
+ graphics.SetClip(viewport);
+ graphics.Clear(Color.White);
+ graphics.SmoothingMode = SmoothingMode.AntiAlias;
+ graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit;
+
+ if (viewport.Width < 45 || viewport.Height < 45) return;
+ if (!MonoSurfaceSampler.ValidBounds(xMin, xMax, yMin, yMax))
+ {
+ DrawMessage(graphics, font, viewport, "Set finite, increasing x/y bounds.");
+ return;
+ }
+
+ var zMin = double.PositiveInfinity;
+ var zMax = double.NegativeInfinity;
+ var finite = 0;
+ foreach (var surface in surfaces)
+ foreach (var z in surface.Z)
+ if (MonoSurfaceSampler.IsFinite(z))
+ {
+ zMin = Math.Min(zMin, z);
+ zMax = Math.Max(zMax, z);
+ finite++;
+ }
+ foreach (var series in pointSeries)
+ foreach (var point in series)
+ if (MonoSurfaceSampler.IsFinite(point.x) &&
+ MonoSurfaceSampler.IsFinite(point.y) && MonoSurfaceSampler.IsFinite(point.z))
+ {
+ zMin = Math.Min(zMin, point.z);
+ zMax = Math.Max(zMax, point.z);
+ finite++;
+ }
+ if (finite == 0)
+ {
+ DrawMessage(graphics, font, viewport,
+ surfaces.Count == 0 && pointSeries.Count == 0
+ ? "Enter f(x,y) to display an interactive 3D chart."
+ : "No finite samples in the selected domain.");
+ return;
+ }
+
+ // Constant z surfaces retain a visible plane and readable z ticks.
+ if (zMin == zMax)
+ {
+ zMin -= 1;
+ zMax += 1;
+ }
+ var spanZ = zMax - zMin;
+ if (!MonoSurfaceSampler.IsFinite(spanZ))
+ {
+ DrawMessage(graphics, font, viewport, "Surface z range is too large to display.");
+ return;
+ }
+ var axisScale = Math.Max(xMax - xMin, Math.Max(yMax - yMin, spanZ));
+ var projection = new Projection
+ {
+ XMid = xMin / 2 + xMax / 2,
+ YMid = yMin / 2 + yMax / 2,
+ ZMid = zMin / 2 + zMax / 2,
+ XSpan = equalAxes ? axisScale : xMax - xMin,
+ YSpan = equalAxes ? axisScale : yMax - yMin,
+ ZSpan = equalAxes ? axisScale : spanZ,
+ SinYaw = Math.Sin(yaw), CosYaw = Math.Cos(yaw),
+ SinTilt = Math.Sin(tilt), CosTilt = Math.Cos(tilt),
+ Unit = Math.Min(viewport.Width, viewport.Height) * .28 * zoom,
+ CenterX = viewport.Left + viewport.Width * .49 + panX,
+ CenterY = viewport.Top + viewport.Height * .53 + panY
+ };
+
+ var primitives = new List();
+ foreach (var surface in surfaces)
+ {
+ var size = surface.Size;
+ var vertices = new Vertex[surface.Z.Length];
+ for (var row = 0; row < size; row++)
+ for (var column = 0; column < size; column++)
+ {
+ var index = row * size + column;
+ var z = surface.Z[index];
+ if (MonoSurfaceSampler.IsFinite(z))
+ vertices[index] = Project(surface.X[column], surface.Y[row], z, projection);
+ }
+
+ if (mode == Chart3DMode.Surface)
+ {
+ for (var row = 0; row < size - 1; row++)
+ for (var column = 0; column < size - 1; column++)
+ {
+ var a = row * size + column;
+ var b = a + 1;
+ var c = a + size;
+ var d = c + 1;
+ AddTriangle(primitives, vertices, surface.Z, a, b, c, zMin, spanZ);
+ AddTriangle(primitives, vertices, surface.Z, b, d, c, zMin, spanZ);
+ }
+ }
+ else
+ for (var i = 0; i < vertices.Length; i++)
+ if (vertices[i].Valid)
+ primitives.Add(new Primitive
+ {
+ Point = true, Center = vertices[i].Pixel, Depth = vertices[i].Depth,
+ Shade = ColorIndex(surface.Z[i], zMin, spanZ)
+ });
+ }
+
+ foreach (var series in pointSeries)
+ foreach (var point in series)
+ {
+ if (!MonoSurfaceSampler.IsFinite(point.x) ||
+ !MonoSurfaceSampler.IsFinite(point.y) || !MonoSurfaceSampler.IsFinite(point.z))
+ continue;
+ var vertex = Project(point.x, point.y, point.z, projection);
+ if (vertex.Valid)
+ primitives.Add(new Primitive
+ {
+ Point = true, Center = vertex.Pixel, Depth = vertex.Depth,
+ Shade = ColorIndex(point.z, zMin, spanZ)
+ });
+ }
+
+ // A sorted painter's algorithm avoids external OpenGL dependencies.
+ // Orthographic projection and bounded meshes keep allocations finite.
+ primitives.Sort((left, right) => left.Depth.CompareTo(right.Depth));
+ var palette = CreatePalette();
+ try
+ {
+ using (var wire = new Pen(Color.FromArgb(48, 31, 41, 55), .7f))
+ foreach (var primitive in primitives)
+ {
+ if (primitive.Point)
+ {
+ const float radius = 3.5f;
+ graphics.FillEllipse(palette[primitive.Shade],
+ primitive.Center.X - radius, primitive.Center.Y - radius,
+ 2 * radius, 2 * radius);
+ }
+ else
+ {
+ graphics.FillPolygon(palette[primitive.Shade], primitive.Polygon);
+ if (primitives.Count <= 12000)
+ graphics.DrawPolygon(wire, primitive.Polygon);
+ }
+ }
+ }
+ finally
+ {
+ foreach (var brush in palette) brush.Dispose();
+ }
+
+ DrawAxes(graphics, font, projection, xMin, xMax, yMin, yMax, zMin, zMax);
+ using (var note = new SolidBrush(Color.FromArgb(78, 91, 112)))
+ graphics.DrawString("Drag: rotate Wheel: zoom Right drag: pan Home: reset",
+ font, note, viewport.Left + 8, viewport.Bottom - font.Height - 8);
+ }
+ finally { graphics.Restore(state); }
+ }
+
+ private static void DrawMessage(Graphics graphics, Font font, Rectangle bounds, string message)
+ {
+ using (var brush = new SolidBrush(Color.FromArgb(71, 85, 105)))
+ using (var format = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center })
+ graphics.DrawString(message, font, brush, bounds, format);
+ }
+
+ private static Vertex Project(double x, double y, double z, Projection view)
+ {
+ var nx = 2 * (x - view.XMid) / view.XSpan;
+ var ny = 2 * (y - view.YMid) / view.YSpan;
+ var nz = 2 * (z - view.ZMid) / view.ZSpan;
+ var rx = view.CosYaw * nx - view.SinYaw * ny;
+ var ry = view.SinYaw * nx + view.CosYaw * ny;
+ var px = view.CenterX + view.Unit * rx;
+ var py = view.CenterY + view.Unit * (view.SinTilt * ry - view.CosTilt * nz);
+ var depth = view.CosTilt * ry + view.SinTilt * nz;
+ var valid = MonoSurfaceSampler.IsFinite(px) && MonoSurfaceSampler.IsFinite(py) &&
+ MonoSurfaceSampler.IsFinite(depth) && Math.Abs(px) < 10000000 && Math.Abs(py) < 10000000;
+ return valid
+ ? new Vertex { Pixel = new PointF((float)px, (float)py), Depth = depth, Valid = true }
+ : default(Vertex);
+ }
+
+ private static void AddTriangle(List primitives, Vertex[] vertices,
+ double[] heights, int a, int b, int c, double zMin, double zSpan)
+ {
+ if (!vertices[a].Valid || !vertices[b].Valid || !vertices[c].Valid) return;
+ primitives.Add(new Primitive
+ {
+ Polygon = new[] { vertices[a].Pixel, vertices[b].Pixel, vertices[c].Pixel },
+ Depth = (vertices[a].Depth + vertices[b].Depth + vertices[c].Depth) / 3,
+ Shade = ColorIndex(heights[a] / 3 + heights[b] / 3 + heights[c] / 3, zMin, zSpan)
+ });
+ }
+
+ private static int ColorIndex(double z, double min, double span)
+ {
+ var relative = (z - min) / span;
+ if (double.IsNaN(relative)) return 0;
+ return (int)(Math.Max(0, Math.Min(1, relative)) * 63);
+ }
+
+ private static Brush[] CreatePalette()
+ {
+ var stops = new[]
+ {
+ Color.FromArgb(30, 58, 138), Color.FromArgb(14, 116, 144),
+ Color.FromArgb(34, 197, 94), Color.FromArgb(250, 204, 21),
+ Color.FromArgb(239, 68, 68)
+ };
+ var palette = new Brush[64];
+ for (var i = 0; i < palette.Length; i++)
+ {
+ var t = i / 63.0 * (stops.Length - 1);
+ var segment = Math.Min(stops.Length - 2, (int)t);
+ var ratio = t - segment;
+ var from = stops[segment];
+ var to = stops[segment + 1];
+ palette[i] = new SolidBrush(Color.FromArgb(
+ (int)(from.R + ratio * (to.R - from.R)),
+ (int)(from.G + ratio * (to.G - from.G)),
+ (int)(from.B + ratio * (to.B - from.B))));
+ }
+ return palette;
+ }
+
+ private static void DrawAxes(Graphics graphics, Font font, Projection view,
+ double xMin, double xMax, double yMin, double yMax, double zMin, double zMax)
+ {
+ using (var axisPen = new Pen(Color.FromArgb(71, 85, 105), 1.7f))
+ using (var label = new SolidBrush(Color.FromArgb(30, 41, 59)))
+ {
+ var origin = Project(xMin, yMin, zMin, view);
+ DrawAxis(graphics, font, axisPen, label, origin,
+ t => Project(xMin + (xMax - xMin) * t, yMin, zMin, view), "X", xMin, xMax);
+ DrawAxis(graphics, font, axisPen, label, origin,
+ t => Project(xMin, yMin + (yMax - yMin) * t, zMin, view), "Y", yMin, yMax);
+ DrawAxis(graphics, font, axisPen, label, origin,
+ t => Project(xMin, yMin, zMin + (zMax - zMin) * t, view), "Z", zMin, zMax);
+ }
+ }
+
+ private static void DrawAxis(Graphics graphics, Font font, Pen pen, Brush brush,
+ Vertex origin, Func endpoint, string title, double min, double max)
+ {
+ var end = endpoint(1);
+ if (!origin.Valid || !end.Valid) return;
+ graphics.DrawLine(pen, origin.Pixel, end.Pixel);
+ for (var tick = 0; tick <= 2; tick++)
+ {
+ var t = tick / 2.0;
+ var position = endpoint(t);
+ if (!position.Valid) continue;
+ graphics.DrawLine(pen, position.Pixel.X - 2.5f, position.Pixel.Y - 2.5f,
+ position.Pixel.X + 2.5f, position.Pixel.Y + 2.5f);
+ graphics.DrawString((min + (max - min) * t).ToString("G4", CultureInfo.InvariantCulture),
+ font, brush, position.Pixel.X + 5, position.Pixel.Y - font.Height);
+ }
+ graphics.DrawString(title, font, brush, end.Pixel.X + 7, end.Pixel.Y + 5);
+ }
+ }
+}
diff --git a/Computator.NET.Charting/Computator.NET.Charting.csproj b/Computator.NET.Charting/Computator.NET.Charting.csproj
index 35689f41..8789a6ac 100644
--- a/Computator.NET.Charting/Computator.NET.Charting.csproj
+++ b/Computator.NET.Charting/Computator.NET.Charting.csproj
@@ -18,6 +18,7 @@
Designer
+
diff --git a/build/mono/SmokeTests/Program.cs b/build/mono/SmokeTests/Program.cs
index 6b5ccdeb..fff895d1 100644
--- a/build/mono/SmokeTests/Program.cs
+++ b/build/mono/SmokeTests/Program.cs
@@ -8,6 +8,7 @@
using System.Linq;
using System.Numerics;
using System.Reflection;
+using System.Runtime.InteropServices;
using System.Text;
using System.Windows.Forms;
using System.Xml;
@@ -103,7 +104,8 @@ private static int Main(string[] args)
Run("unix-document-save-and-alias-protection", UnixDocumentSafety);
Run("real-chart-samples-and-png-rendering", RealChart);
Run("complex-chart-png-and-domain-colors", ComplexChart);
- Run("unsupported-3d-is-explicit", Unsupported3D);
+ Run("native-mono-3d-surface-and-visible-x11-interaction", Native3D);
+ Run("native-mono-3d-singularities-custom-functions-and-points", Native3DEdges);
WriteResults();
var failed = Results.Count(result => result.Failure != null);
var skipped = Results.Count(result => result.SkippedReason != null);
@@ -401,21 +403,212 @@ private static void ComplexChart()
}
}
- private static void Unsupported3D()
+ private static void Native3D()
{
- using (var chart = new Chart3DControl { Width = 600, Height = 200 })
+ using (var chart = new Chart3DControl { Width = 700, Height = 480, Quality = 20 })
+ using (var window = new Form
{
- Check(chart is Control, "Mono 3D placeholder must be WinForms.");
- var function = new ExpressionsEvaluator().Evaluate("x+y", "", CalculationsMode.Fxy);
- Expect(() => chart.AddFunction(function));
- Expect(() => chart.GetImage(640, 400));
- var exportPath = Path.Combine(outputDirectory, "unsupported-3d-must-not-exist.png");
- Expect(() => chart.SaveImage(exportPath, ImageFormat.Png));
- Check(!File.Exists(exportPath), "Unsupported 3D must not report a successful blank export.");
- Capture(chart, "unsupported-3d.png");
+ ClientSize = new Size(700, 480),
+ StartPosition = FormStartPosition.Manual,
+ Location = new Point(120, 100),
+ Text = "Mono native 3D acceptance"
+ })
+ {
+ Check(chart is Control, "Mono chart must be a real WinForms control.");
+ chart.SetChartAreaValues(-2, 2, -2, 2);
+ chart.AddFunction(new ExpressionsEvaluator().Evaluate("x²+y²", "", CalculationsMode.Fxy));
+ Check(chart.FiniteSurfaceSamples == 33 * 33, "Paraboloid must be evaluated on the expected bounded grid.");
+ using (var export = chart.GetImage(700, 480))
+ {
+ Check(export.Width == 700 && export.Height == 480, "3D export must match requested dimensions.");
+ AssertImageHasVariation(export, 60);
+ export.Save(Path.Combine(outputDirectory, "mono-3d-paraboloid.png"), ImageFormat.Png);
+ }
+
+ window.Controls.Add(chart);
+ window.Show();
+ window.Activate();
+ Application.DoEvents();
+ Check(chart.Visible && chart.IsHandleCreated && chart.Focus(), "3D chart must have a visible, focusable X11 window.");
+ chart.Refresh();
+ Application.DoEvents();
+
+ // Xvfb screenshots use actual on-screen pixels, NOT DrawToBitmap
+ // or the offscreen export. This catches invisible WinForms children.
+ using (var before = CaptureVisibleChart(chart, "mono-3d-visible-before.png"))
+ {
+ AssertImageHasVariation(before, 60);
+ var startAngle = chart.YawDegrees;
+ DragOnRealX11(chart, 90, 25);
+ Application.DoEvents();
+ Check(Math.Abs(chart.YawDegrees - startAngle) > 1, "XTEST pointer drag did not rotate the live 3D camera.");
+ chart.Refresh();
+ Application.DoEvents();
+ using (var after = CaptureVisibleChart(chart, "mono-3d-visible-rotated.png"))
+ {
+ AssertImageHasVariation(after, 60);
+ Check(DifferentPixels(before, after) > 200,
+ "Visible X11 chart pixels did not change after the rotation input.");
+ }
+ }
+
+ // Export must retain an actual right-button pan, not silently reset
+ // the camera to its unpanned center.
+ using (var beforePanExport = new Bitmap(chart.GetImage(700, 480)))
+ using (var beforePanScreen = CaptureVisibleChart(chart, "mono-3d-visible-prepan.png"))
+ {
+ DragOnRealX11(chart, 80, 36, 3);
+ chart.Refresh();
+ Application.DoEvents();
+ using (var afterPanScreen = CaptureVisibleChart(chart, "mono-3d-visible-panned.png"))
+ using (var afterPanExport = new Bitmap(chart.GetImage(700, 480)))
+ {
+ Check(DifferentPixels(beforePanScreen, afterPanScreen) > 200,
+ "XTEST right-button drag did not pan the native on-screen chart.");
+ Check(DifferentPixels(beforePanExport, afterPanExport) > 200,
+ "Export discarded the native 3D chart's current pan position.");
+ afterPanExport.Save(Path.Combine(outputDirectory, "mono-3d-panned-export.png"), ImageFormat.Png);
+ }
+ }
+
+ var exportPath = Path.Combine(outputDirectory, "mono-3d-export.png");
+ chart.SaveImage(exportPath, ImageFormat.Png);
+ Check(File.Exists(exportPath) && new FileInfo(exportPath).Length > 4000,
+ "Native 3D PNG export did not write chart pixels.");
+ using (var image = Image.FromFile(exportPath))
+ AssertImageHasVariation(image, 60);
+
+ Expect(() => chart.GetImage(5000, 300));
+ Expect(() => chart.SetChartAreaValues(2, -2, -1, 1));
+ window.Close();
+ Application.DoEvents();
}
}
+ private static void Native3DEdges()
+ {
+ using (var chart = new Chart3DControl { Width = 640, Height = 440, Quality = 20 })
+ {
+ chart.SetChartAreaValues(-2, 2, -2, 2);
+ var evaluator = new ExpressionsEvaluator();
+ var function = evaluator.Evaluate("x+y", "", CalculationsMode.Fxy);
+ chart.AddFunction(function);
+ Check(chart.FiniteSurfaceSamples == 33 * 33, "Plane samples were not retained.");
+ var custom = evaluator.Evaluate("MonoSurface(x,y)",
+ "static real MonoSurface(real x, real y) { return x*x + y*y + 1; }",
+ CalculationsMode.Fxy);
+ chart.AddFunction(custom);
+ Check(chart.FiniteSurfaceSamples == 2 * 33 * 33,
+ "Explicit unsaved custom f(x,y) function was not evaluated.");
+ chart.Quality = 0;
+ Check(chart.FiniteSurfaceSamples == 2 * 17 * 17, "Quality did not resample existing functions.");
+ chart.Quality = 20;
+ chart.SetChartAreaValues(-1, 1, -1, 1);
+ Check(chart.FiniteSurfaceSamples == 2 * 33 * 33, "Bounds update lost chart functions.");
+ chart.AddPoints(new[]
+ {
+ new Computator.NET.DataTypes.Charts.Point3D(-1, -1, 1),
+ new Computator.NET.DataTypes.Charts.Point3D(0, 0, 0),
+ new Computator.NET.DataTypes.Charts.Point3D(1, 1, 1)
+ });
+ Check(chart.Mode == Computator.NET.DataTypes.Charts.Chart3DMode.Points,
+ "Adding point series should enter the points chart mode.");
+ using (var image = chart.GetImage(640, 440))
+ {
+ AssertImageHasVariation(image, 40);
+ image.Save(Path.Combine(outputDirectory, "mono-3d-points.png"), ImageFormat.Png);
+ }
+ chart.ClearAll();
+ Check(chart.FiniteSurfaceSamples == 0, "ClearAll must release sampled surfaces.");
+ }
+
+ using (var chart = new Chart3DControl { Width = 640, Height = 440, Quality = 20 })
+ {
+ chart.SetChartAreaValues(-2, 2, -2, 2);
+ chart.AddFunction(new ExpressionsEvaluator().Evaluate("1/(x-y)", "", CalculationsMode.Fxy));
+ Check(chart.FiniteSurfaceSamples > 0 && chart.FiniteSurfaceSamples < 33 * 33,
+ "Undefined diagonal must become holes, not zero-height vertices.");
+ using (var image = chart.GetImage(640, 440))
+ AssertImageHasVariation(image, 30);
+ chart.ClearAll();
+ chart.AddFunction(new ExpressionsEvaluator().Evaluate("1/(y-y)", "", CalculationsMode.Fxy));
+ Check(chart.FiniteSurfaceSamples == 0, "Entirely undefined surface cannot invent finite values.");
+ using (var image = chart.GetImage(640, 440))
+ AssertImageHasVariation(image, 8);
+ chart.ClearAll();
+ chart.AddFunction(new ExpressionsEvaluator().Evaluate("x*y", "", CalculationsMode.Fxy));
+ Check(chart.FiniteSurfaceSamples == 33 * 33, "A singular plot must not poison subsequent redraws.");
+ Expect(() =>
+ chart.AddFunction(new ExpressionsEvaluator().Evaluate("x²", "", CalculationsMode.Real)));
+ }
+ }
+
+ // SendKeys.SendWait does not reliably dispatch focused key events on Mono.
+ // Drive actual X11 pointer/button events through XTEST and observe the live
+ // WinForms viewer's camera as well as screen pixels, not a synthetic handler.
+ [DllImport("libX11.so.6")]
+ private static extern IntPtr XOpenDisplay(IntPtr displayName);
+ [DllImport("libX11.so.6")]
+ private static extern int XCloseDisplay(IntPtr display);
+ [DllImport("libX11.so.6")]
+ private static extern int XSync(IntPtr display, int discard);
+ [DllImport("libXtst.so.6")]
+ private static extern int XTestFakeMotionEvent(IntPtr display, int screen, int x, int y, ulong delay);
+ [DllImport("libXtst.so.6")]
+ private static extern int XTestFakeButtonEvent(IntPtr display, uint button, int press, ulong delay);
+
+ private static void DragOnRealX11(Control control, int dx, int dy, uint button = 1)
+ {
+ var origin = control.PointToScreen(new Point(control.ClientSize.Width / 2, control.ClientSize.Height / 2));
+ var display = XOpenDisplay(IntPtr.Zero);
+ Check(display != IntPtr.Zero, "X11 display is required for native interactive chart acceptance.");
+ try
+ {
+ Check(XTestFakeMotionEvent(display, 0, origin.X, origin.Y, 0) != 0,
+ "XTEST could not move the pointer into the chart.");
+ XSync(display, 0);
+ Application.DoEvents();
+ Check(XTestFakeButtonEvent(display, button, 1, 0) != 0,
+ "XTEST could not press the requested mouse button.");
+ XSync(display, 0);
+ Application.DoEvents();
+ for (var i = 1; i <= 6; i++)
+ {
+ Check(XTestFakeMotionEvent(display, 0, origin.X + dx * i / 6,
+ origin.Y + dy * i / 6, 0) != 0, "XTEST drag was rejected.");
+ XSync(display, 0);
+ Application.DoEvents();
+ }
+ Check(XTestFakeButtonEvent(display, button, 0, 0) != 0,
+ "XTEST could not release the requested mouse button.");
+ XSync(display, 0);
+ Application.DoEvents();
+ }
+ finally { XCloseDisplay(display); }
+ }
+
+ private static Bitmap CaptureVisibleChart(Control control, string name)
+ {
+ var rectangle = control.RectangleToScreen(control.ClientRectangle);
+ Check(rectangle.Width > 100 && rectangle.Height > 100 && control.Visible,
+ "3D chart is not a usable visible native control.");
+ var screenshot = new Bitmap(rectangle.Width, rectangle.Height);
+ using (var graphics = Graphics.FromImage(screenshot))
+ graphics.CopyFromScreen(rectangle.Location, Point.Empty, rectangle.Size);
+ screenshot.Save(Path.Combine(outputDirectory, name), ImageFormat.Png);
+ return screenshot;
+ }
+
+ private static int DifferentPixels(Bitmap before, Bitmap after)
+ {
+ Check(before.Size == after.Size, "Rotation changed native chart dimensions.");
+ var changed = 0;
+ for (var x = 0; x < before.Width; x += 3)
+ for (var y = 0; y < before.Height; y += 3)
+ if (before.GetPixel(x, y).ToArgb() != after.GetPixel(x, y).ToArgb()) changed++;
+ return changed;
+ }
+
private static void Capture(Control control, string name)
{
using (var image = new Bitmap(control.Width, control.Height))
diff --git a/build/mono/validate.sh b/build/mono/validate.sh
index b85b35e0..1ff9d17f 100644
--- a/build/mono/validate.sh
+++ b/build/mono/validate.sh
@@ -54,7 +54,10 @@ mcs -nologo -nowarn:0414 -r:System.Drawing -out:"$validation_temp/LegacyFixture.
mono "$validation_temp/LegacyFixture.exe" "$validation_temp/legacy-settings.dat"
# A failed or timed-out run must not expose an old passing report/screenshot.
rm -f -- "$results/mono-results.xml" "$results/startup.png" "$results/desktop-workflow.png" \
- "$results/real-chart.png" "$results/complex-chart.png" "$results/unsupported-3d.png" "$results/function-reference.png" \
+ "$results/real-chart.png" "$results/complex-chart.png" "$results/function-reference.png" \
+ "$results/mono-3d-paraboloid.png" "$results/mono-3d-visible-before.png" \
+ "$results/mono-3d-visible-rotated.png" "$results/mono-3d-export.png" \
+ "$results/mono-3d-points.png" \
"$results/calculator-inputs.png" "$results/numerical-inputs.png"
status=0
COMPUTATOR_DATA_DIRECTORY="$validation_temp/startup" \
diff --git a/docs/MONO-DESKTOP.md b/docs/MONO-DESKTOP.md
index 1feb7672..3a9e4723 100644
--- a/docs/MONO-DESKTOP.md
+++ b/docs/MONO-DESKTOP.md
@@ -21,11 +21,11 @@ On Ubuntu 24.04, install the GUI and native runtime prerequisites:
```sh
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
- mono-devel libgdiplus libgsl-dev fonts-dejavu-core xvfb xauth
+ mono-devel libgdiplus libgsl-dev libxtst6 fonts-dejavu-core xvfb xauth
```
`libgsl-dev` supplies the unversioned library name used by Mono's P/Invoke loader.
-`libgdiplus` is used by Framework Mono's System.Drawing implementation. Xvfb and
+`libgdiplus` is used by Framework Mono's System.Drawing implementation. `libxtst6` supplies the XTEST input extension client used by the real-pointer 3D GUI acceptance. Xvfb and
xauth are needed for headless acceptance; normal interactive use requires an X11
display, or an XWayland display on a Wayland desktop.
@@ -97,7 +97,7 @@ ordinary Windows build.
| UI | Framework Mono WinForms/System.Drawing | Microsoft WinForms and WPF |
| 2D charts | Existing `libraries/net46/DataVisualizationX.dll` | Existing WinForms.DataVisualization package |
| Complex charts | Existing managed sampling, domain coloring and System.Drawing renderer | Same chart source |
-| 3D charts | Visible unsupported control; chart generation/export operations reject the request | Existing WPF implementation |
+| 3D charts | Bounded WinForms/System.Drawing software surface and point renderer with camera input, axes and PNG export | Existing WPF implementation |
| Code editor | Existing TROKA/ICSharpCode TextEditor | Existing Scintilla, AvalonEdit and TextEditor options |
| Function reference | Readable text from the existing reference, including equation alternative text | Existing HTML reference rendering |
| Native GSL | Host's `libgsl` via Mono P/Invoke | Existing validated Windows loader and package/fallback policy |
@@ -170,9 +170,33 @@ numerical method or input as correct.
### Desktop behavior
-The unsupported 3D panel remains visible when that mode is selected. It does not
-silently return an empty image or pretend that a WPF chart was rendered. Real
-two-variable calculations remain separate from the unavailable 3D visualization.
+Native Mono 3D uses the existing explicit real f(x,y) evaluator and presents a
+WinForms software-rendered surface. It does not load WPF or require a GPU or
+embedded browser. 3D charts are interactive on X11: **left-drag** rotates,
+**mouse wheel** zooms, **right-drag** pans, **arrow keys** rotate, **+/-** zoom
+and **Home** resets the camera. The plot includes labeled x/y/z numeric axes.
+Camera changes and resize render cached samples without recompilation.
+Both visible painting and PNG export preserve rotation, zoom and right-drag pan;
+when exporting at another size, the pan offsets are scaled with the output viewport.
+
+Sampling is synchronous and capped at **17–97 vertices per axis** (Quality 0–100),
+**eight functions per chart**, **20,000 points per series** and **40,000 points total**.
+The chart supports finite explicit real height surfaces, including compiled
+user-defined two-argument functions, color-coded depth-sorted triangulation,
+missing-value holes, point series, image export and normal edit/plot/print menus.
+Invalid bounds and oversized exports fail explicitly rather than allocating
+unbounded images. All-nonfinite surfaces display a diagnostic rather than a
+misleading zero-height mesh. Sampling executes trusted local TSL without process
+isolation; slow user functions can still block the UI while being sampled.
+
+Limitations: this renderer uses a painter's algorithm, not a hardware z-buffer.
+Overlapping/intersecting surfaces can have incorrect triangle ordering; performance
+is limited compared with WebGL/WPF for dense meshes. Implicit F(x,y,z)=0 surfaces,
+lighting, surface mouse picking and true hardware acceleration are not supported.
+This is **native Linux/Mono** only; the separately developed WebGL browser viewer
+in [PR #294](https://github.com/PawelTroka/Computator.NET/pull/294) is not reused
+or bundled. The complex-chart defect [#295](https://github.com/PawelTroka/Computator.NET/issues/295)
+remains a separate workstream.
Only TextEditor is offered on Mono. Its settings value remains `2`, matching the
Windows edition. Known historical/default editor values are normalized to the
@@ -244,8 +268,12 @@ The restoration was built and exercised on Ubuntu 24.04.3 x64 with .NET SDK
The suite also exercises expressions, scripts, custom functions, current C#
syntax, native GSL calls and recovery, compatibility math edge cases, JSON and
trusted NRBF migration, Unix document safety, visible editor/numerical workflows,
-2D and complex PNG rendering, and explicit 3D rejection. Its window images use
-actual X11 screen pixels; a successful build or a blank `DrawToBitmap` capture is
+2D and complex PNG rendering, plus numerical 3D surfaces, custom functions,
+missing samples, mode switching, clearing, exports and an actual visible X11
+WinForms chart before/after a real XTEST pointer drag. The 3D acceptance retains
+`mono-3d-visible-before.png`, `mono-3d-visible-rotated.png`,
+`mono-3d-paraboloid.png`, `mono-3d-export.png` and `mono-3d-points.png`.
+Its on-screen captures use actual X11 pixels; a successful offscreen render or blank `DrawToBitmap` capture is
not used as evidence of usable UI.
A clean hosted run also exposed an assembly-discovery ordering bug: filename