From b5ccd4d737d4d42a2b9dbbe59ab488e850194c98 Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Mon, 26 Oct 2026 01:49:31 +0100 Subject: [PATCH 01/10] feat(mono): add bounded software 3D surface sampling and depth-sorted renderer --- .../Chart3D/UI/MonoSurfaceRenderer.cs | 376 ++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 Computator.NET.Charting/Chart3D/UI/MonoSurfaceRenderer.cs 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); + } + } +} From fb17632436fb556b7771b59870574b994eb29ac6 Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Mon, 26 Oct 2026 01:51:35 +0100 Subject: [PATCH 02/10] feat(mono): replace 3D placeholder with interactive WinForms surface and export control --- .../Chart3D/UI/Chart3DControlMono.cs | 442 ++++++++++++++++-- 1 file changed, 393 insertions(+), 49 deletions(-) diff --git a/Computator.NET.Charting/Chart3D/UI/Chart3DControlMono.cs b/Computator.NET.Charting/Chart3D/UI/Chart3DControlMono.cs index 95e19a19..1121ea7d 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,391 @@ 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); + 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, 0, 0); + 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(); + } } } From 06973e43e8c95dcde01d0b4befbbbb79e885fd7f Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Mon, 26 Oct 2026 01:52:08 +0100 Subject: [PATCH 03/10] build: compile Mono software 3D renderer only in opt-in Mono target --- Computator.NET.Charting/Computator.NET.Charting.csproj | 1 + 1 file changed, 1 insertion(+) 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 + From 43c5d63f7c04a53c964015671fd64450abd184f5 Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Mon, 26 Oct 2026 01:53:56 +0100 Subject: [PATCH 04/10] test(mono): assert surface samples and real X11 window pixels before and after rotation --- build/mono/SmokeTests/Program.cs | 151 ++++++++++++++++++++++++++++--- 1 file changed, 140 insertions(+), 11 deletions(-) diff --git a/build/mono/SmokeTests/Program.cs b/build/mono/SmokeTests/Program.cs index 6b5ccdeb..645f57ba 100644 --- a/build/mono/SmokeTests/Program.cs +++ b/build/mono/SmokeTests/Program.cs @@ -103,7 +103,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 +402,149 @@ 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; + SendKeys.SendWait("{RIGHT}"); + Application.DoEvents(); + Check(Math.Abs(chart.YawDegrees - startAngle) > 1, "Focused real WinForms keyboard input did not rotate the 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."); + } + } + + 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+y", "", CalculationsMode.Real))); + } + } + + 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)) From a2a311e301a7c4e2fec1036c4a985992a86a63f0 Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Mon, 26 Oct 2026 01:55:13 +0100 Subject: [PATCH 05/10] test(mono): clean native 3D visible and exported screenshots before acceptance --- build/mono/validate.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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" \ From 220aade6c31a563e4c2b535aa55e655c13e0c558 Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Mon, 26 Oct 2026 01:55:15 +0100 Subject: [PATCH 06/10] docs(mono): document native surface controls, limits, X11 tests and web separation --- docs/MONO-DESKTOP.md | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/MONO-DESKTOP.md b/docs/MONO-DESKTOP.md index 1feb7672..b2dfec66 100644 --- a/docs/MONO-DESKTOP.md +++ b/docs/MONO-DESKTOP.md @@ -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,31 @@ 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. + +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 +266,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 keyboard rotation. 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 From 06c4b038a23c6dc2abbc7d67e90f4cb972a61fee Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Mon, 26 Oct 2026 02:00:58 +0100 Subject: [PATCH 07/10] test(mono): drive genuine X11 drag with XTEST and correct real-function fixture --- build/mono/SmokeTests/Program.cs | 51 ++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/build/mono/SmokeTests/Program.cs b/build/mono/SmokeTests/Program.cs index 645f57ba..b37667f4 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; @@ -438,9 +439,9 @@ private static void Native3D() { AssertImageHasVariation(before, 60); var startAngle = chart.YawDegrees; - SendKeys.SendWait("{RIGHT}"); + DragOnRealX11(chart, 90, 25); Application.DoEvents(); - Check(Math.Abs(chart.YawDegrees - startAngle) > 1, "Focused real WinForms keyboard input did not rotate the 3D camera."); + 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")) @@ -519,10 +520,54 @@ private static void Native3DEdges() 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+y", "", CalculationsMode.Real))); + 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) + { + 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, 1, 1, 0) != 0, + "XTEST could not press the left 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, 1, 0, 0) != 0, + "XTEST could not release the left mouse button."); + XSync(display, 0); + Application.DoEvents(); + } + finally { XCloseDisplay(display); } + } + private static Bitmap CaptureVisibleChart(Control control, string name) { var rectangle = control.RectangleToScreen(control.ClientRectangle); From dc850c96df3bfe8a61ca2b80177d5a947916e8b0 Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Mon, 26 Oct 2026 02:01:16 +0100 Subject: [PATCH 08/10] ci(mono): explicitly install libXtst for real X11 pointer acceptance --- .github/workflows/mono-desktop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 4769084a36af7983a152367bb619aeb10c7ffaa3 Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Mon, 26 Oct 2026 02:01:18 +0100 Subject: [PATCH 09/10] docs(mono): document XTEST-driven native mouse acceptance prerequisite --- docs/MONO-DESKTOP.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/MONO-DESKTOP.md b/docs/MONO-DESKTOP.md index b2dfec66..86c0bcb2 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. @@ -268,7 +268,7 @@ 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, plus numerical 3D surfaces, custom functions, missing samples, mode switching, clearing, exports and an actual visible X11 -WinForms chart before/after keyboard rotation. The 3D acceptance retains +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 From db44d1d700c6133714bed0a913d71d0b69578a28 Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Tue, 27 Oct 2026 01:47:58 +0100 Subject: [PATCH 10/10] fix(mono): preserve camera pan in PNG export and validate real X11 drag Scale viewport pan offsets in GetImage instead of resetting them. Capture native before/after right-button XTEST screenshots and enforce that the exported image retains the resulting pan. --- .../Chart3D/UI/Chart3DControlMono.cs | 6 +++- build/mono/SmokeTests/Program.cs | 29 +++++++++++++++---- docs/MONO-DESKTOP.md | 2 ++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/Computator.NET.Charting/Chart3D/UI/Chart3DControlMono.cs b/Computator.NET.Charting/Chart3D/UI/Chart3DControlMono.cs index 1121ea7d..6971721b 100644 --- a/Computator.NET.Charting/Chart3D/UI/Chart3DControlMono.cs +++ b/Computator.NET.Charting/Chart3D/UI/Chart3DControlMono.cs @@ -301,10 +301,14 @@ public Image GetImage(int width, int height) 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, 0, 0); + _equalAxes, _yaw, _tilt, _zoom, panX, panY); return image; } catch { image.Dispose(); throw; } diff --git a/build/mono/SmokeTests/Program.cs b/build/mono/SmokeTests/Program.cs index b37667f4..fff895d1 100644 --- a/build/mono/SmokeTests/Program.cs +++ b/build/mono/SmokeTests/Program.cs @@ -452,6 +452,25 @@ private static void Native3D() } } + // 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, @@ -538,7 +557,7 @@ private static void Native3DEdges() [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) + 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); @@ -549,8 +568,8 @@ private static void DragOnRealX11(Control control, int dx, int dy) "XTEST could not move the pointer into the chart."); XSync(display, 0); Application.DoEvents(); - Check(XTestFakeButtonEvent(display, 1, 1, 0) != 0, - "XTEST could not press the left mouse button."); + 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++) @@ -560,8 +579,8 @@ private static void DragOnRealX11(Control control, int dx, int dy) XSync(display, 0); Application.DoEvents(); } - Check(XTestFakeButtonEvent(display, 1, 0, 0) != 0, - "XTEST could not release the left mouse button."); + Check(XTestFakeButtonEvent(display, button, 0, 0) != 0, + "XTEST could not release the requested mouse button."); XSync(display, 0); Application.DoEvents(); } diff --git a/docs/MONO-DESKTOP.md b/docs/MONO-DESKTOP.md index 86c0bcb2..3a9e4723 100644 --- a/docs/MONO-DESKTOP.md +++ b/docs/MONO-DESKTOP.md @@ -176,6 +176,8 @@ 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**.