From a61ec284f6dd2c3aeddcf9eef5d7caeede10f778 Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Sun, 25 Oct 2026 21:41:25 +0100 Subject: [PATCH 01/16] Add bounded f(x,y) surface sampling to JSON chart API with numerical regressions --- .../ChartApiTests.cs | 85 ++++++++++++++++++- .../Controllers/ExecutionV2Controller.cs | 16 +++- .../Services/ExecutionPolicy.cs | 8 +- .../Services/SurfaceSampler.cs | 78 +++++++++++++++++ 4 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 Computator.NET.WebApi/Services/SurfaceSampler.cs diff --git a/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs b/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs index a471e039..e6da23ac 100644 --- a/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs +++ b/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs @@ -5,6 +5,12 @@ using System.Linq; using System.Net.Http; using System.Threading.Tasks; +using System.Net; +using System.Net.Http.Json; +using System.Threading; +using Computator.NET.DataTypes.Functions; +using Computator.NET.WebApi.Services; +using Newtonsoft.Json.Linq; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using NUnit.Framework; @@ -40,11 +46,84 @@ public async Task ReturnsHttpOkForComplexChart() using (var response = await _client.GetAsync("/api/chart/2z")) response.EnsureSuccessStatusCode(); } + private async Task Surface(object request) + { + using var response = await _client.PostAsJsonAsync("/api/v2/chart", request); + var text = await response.Content.ReadAsStringAsync(); + NUnit.Framework.Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), text); + NUnit.Framework.Assert.That(response.Content.Headers.ContentType?.MediaType, Is.EqualTo("application/json")); + return JObject.Parse(text); + } + + [TestCase("x*x + y*y", 8.0, 0.0)] + [TestCase("x + y", -4.0, 0.0)] + [TestCase("sin(x)*cos(y)", Math.Sin(-2)*Math.Cos(-2), 0.0)] + public async Task RealFxyApiSamplesKnownSurfaces(string expression, double corner, double center) + { + var grid = await Surface(new { expression, gridSize = 9, xMin = -2, xMax = 2, + yMin = -2, yMax = 2, width = 300, height = 300 }); + NUnit.Framework.Assert.That((string)grid["kind"], Is.EqualTo("surface")); + NUnit.Framework.Assert.That((int)grid["finiteSamples"], Is.EqualTo(81)); + NUnit.Framework.Assert.That((int)grid["missingSamples"], Is.Zero); + NUnit.Framework.Assert.That(grid["x"].Count(), Is.EqualTo(9)); + NUnit.Framework.Assert.That(grid["y"].Count(), Is.EqualTo(9)); + NUnit.Framework.Assert.That((double)grid["x"][0], Is.EqualTo(-2)); + NUnit.Framework.Assert.That((double)grid["x"][8], Is.EqualTo(2)); + NUnit.Framework.Assert.That((double)grid["y"][0], Is.EqualTo(-2)); + NUnit.Framework.Assert.That((double)grid["y"][8], Is.EqualTo(2)); + NUnit.Framework.Assert.That((double)grid["z"][0][0], Is.EqualTo(corner).Within(1e-9)); + NUnit.Framework.Assert.That((double)grid["z"][4][4], Is.EqualTo(center).Within(1e-9)); + } + + [Test] + public async Task UnsavedTwoArgumentCustomFunctionsStayRequestLocal() + { + for (var offset = 1; offset <= 2; offset++) + { + var grid = await Surface(new { expression = "SurfaceProbe(x,y)", gridSize = 9, + customFunctions = "static real SurfaceProbe(real x, real y) { return x*x+y*y+" + offset + "; }" }); + NUnit.Framework.Assert.That((double)grid["z"][4][4], Is.EqualTo(offset)); + } + using var missing = await _client.PostAsJsonAsync("/api/v2/chart", new { expression = "SurfaceProbe(x,y)", gridSize = 9 }); + NUnit.Framework.Assert.That(missing.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task SingularitiesBecomeNullHolesRatherThanInvalidJsonOrFlattenedZeros() + { + var grid = await Surface(new { expression = "1/(x-y)", gridSize = 9, xMin = -2, xMax = 2, yMin = -2, yMax = 2 }); + NUnit.Framework.Assert.That((int)grid["missingSamples"], Is.GreaterThan(0)); + NUnit.Framework.Assert.That(grid["z"][0][0].Type, Is.EqualTo(JTokenType.Null)); + NUnit.Framework.Assert.That((double)grid["z"][0][8], Is.EqualTo(0.25).Within(1e-9)); + } + + [Test] + public async Task AllSingularSurfaceIsRejectedWithoutCorruptingSubsequentRequests() + { + using var response = await _client.PostAsJsonAsync("/api/v2/chart", new { expression = "1/(x-x)", gridSize = 9 }); + NUnit.Framework.Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity)); + await Surface(new { expression = "x+y", gridSize = 9 }); + } + + [TestCase(0)] + [TestCase(8)] + [TestCase(130)] + [TestCase(100000)] + public async Task SampleCountIsBoundedBeforeExpressionCompilation(int count) + { + using var response = await _client.PostAsJsonAsync("/api/v2/chart", new { expression = "not-valid(", gridSize = count }); + NUnit.Framework.Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + NUnit.Framework.Assert.That(JObject.Parse(await response.Content.ReadAsStringAsync())["errors"]?["gridSize"], Is.Not.Null); + } + [Test] - [Ignore("Chart3D does not support server-side rendering yet")] - public async Task ReturnsHttpOkForChart3D() + public void SurfaceSamplerHonorsCancellationBeforeEvaluating() { - using (var response = await _client.GetAsync("/api/chart/2y")) response.EnsureSuccessStatusCode(); + var function = new Function(new Func((x, y) => x+y), FunctionType.Real3D); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + NUnit.Framework.Assert.Throws(() => + SurfaceSampler.Sample(function, -2, 2, -2, 2, 129, cancelled.Token)); } [TestCase("png/300/300/-2.5/2.5/-0.5/0.5/x")] diff --git a/Computator.NET.WebApi/Controllers/ExecutionV2Controller.cs b/Computator.NET.WebApi/Controllers/ExecutionV2Controller.cs index af9808b6..1b0c0d07 100644 --- a/Computator.NET.WebApi/Controllers/ExecutionV2Controller.cs +++ b/Computator.NET.WebApi/Controllers/ExecutionV2Controller.cs @@ -9,6 +9,7 @@ using Computator.NET.Core.NumericalCalculations; using Computator.NET.DataTypes; using Computator.NET.DataTypes.Charts; +using Computator.NET.DataTypes.Functions; using Computator.NET.WebApi.Services; using Microsoft.AspNetCore.Mvc; @@ -74,7 +75,19 @@ public IActionResult Chart([FromBody] ExecutionRequest request) try { var mode = _mode.DetermineMode(request.Expression, request.CustomFunctions); - if (mode == CalculationsMode.Fxy) return UnprocessableEntity(new ProblemDetails { Title = "Server-side 3D rendering is not implemented." }); + if (mode == CalculationsMode.Fxy) + { + // Preserve the PNG contract for existing real/complex charts. + // Surface samples are numerical data, not server-side bitmap rendering. + var surfaceFunction = new ExpressionsEvaluator().Evaluate(request.Expression, request.CustomFunctions, mode); + if (surfaceFunction.FunctionType != FunctionType.Real3D) + return UnprocessableEntity(new ProblemDetails { Title = "Implicit 3D equations are not supported as height surfaces." }); + var surface = SurfaceSampler.Sample(surfaceFunction, request.XMin, request.XMax, + request.YMin, request.YMax, request.GridSize, HttpContext?.RequestAborted ?? CancellationToken.None); + if (surface.FiniteSamples < 3) + return UnprocessableEntity(new ProblemDetails { Title = "The selected domain contains too few finite surface samples." }); + return Ok(surface); + } var function = new ExpressionsEvaluator().Evaluate(request.Expression, request.CustomFunctions, mode); chart = _charts.Create(mode); chart.AddFunction(function); chart.XMin = request.XMin; chart.XMax = request.XMax; @@ -107,6 +120,7 @@ public sealed class ExecutionRequest public double Epsilon { get; set; } = 1e-6; public int Width { get; set; } = 600; public int Height { get; set; } = 400; + public int GridSize { get; set; } = 65; public double XMin { get; set; } = -5; public double XMax { get; set; } = 5; public double YMin { get; set; } = -5; diff --git a/Computator.NET.WebApi/Services/ExecutionPolicy.cs b/Computator.NET.WebApi/Services/ExecutionPolicy.cs index abcda0af..d4e0bfd4 100644 --- a/Computator.NET.WebApi/Services/ExecutionPolicy.cs +++ b/Computator.NET.WebApi/Services/ExecutionPolicy.cs @@ -15,6 +15,7 @@ public static class ExecutionPolicy public const int MaximumOutputCharacters = 65536; public const int MaximumDimension = 4096; public const long MaximumPixels = 4194304; + public const int MaximumSurfaceGridSize = 129; public const uint MaximumIterations = 1000000; public const uint MaximumDerivativeOrder = 10; @@ -31,7 +32,12 @@ public static Dictionary Validate(ExecutionRequest request, bo Source(errors, "expression", request.Expression, false); Source(errors, "customFunctions", request.CustomFunctions, true); if (!ValidNumbers(request)) errors["parameters"] = new[] { "Numeric parameters must be finite and within the documented iteration/order/error limits." }; - if (chart) Chart(errors, request.Width, request.Height, request.XMin, request.XMax, request.YMin, request.YMax); + if (chart) + { + Chart(errors, request.Width, request.Height, request.XMin, request.XMax, request.YMin, request.YMax); + if (request.GridSize < 9 || request.GridSize > MaximumSurfaceGridSize) + errors["gridSize"] = new[] { $"Surface sampling requires 9–{MaximumSurfaceGridSize} points per axis." }; + } else { if (!new[] { "calculate", "script", "integral", "derivative", "function-root" }.Contains(request.Operation)) diff --git a/Computator.NET.WebApi/Services/SurfaceSampler.cs b/Computator.NET.WebApi/Services/SurfaceSampler.cs new file mode 100644 index 00000000..319ed5e8 --- /dev/null +++ b/Computator.NET.WebApi/Services/SurfaceSampler.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading; +using Computator.NET.DataTypes; +using Computator.NET.DataTypes.Functions; + +namespace Computator.NET.WebApi.Services +{ + /// + /// Bounded numeric data for an interactive browser surface. This class never + /// initializes a Windows chart or ships compiled expressions to the client. + /// Z is row-major: Z[yIndex][xIndex]. Missing/non-real points are null. + /// + public static class SurfaceSampler + { + public static SurfaceGrid Sample(Function function, double xMin, double xMax, + double yMin, double yMax, int gridSize, CancellationToken cancellation) + { + if (function == null || function.FunctionType != FunctionType.Real3D) + throw new ArgumentException("Only explicit real f(x,y) functions can be sampled.", nameof(function)); + if (gridSize < 9 || gridSize > ExecutionPolicy.MaximumSurfaceGridSize) + throw new ArgumentOutOfRangeException(nameof(gridSize)); + if (!double.IsFinite(xMin) || !double.IsFinite(xMax) || !double.IsFinite(yMin) || + !double.IsFinite(yMax) || xMin >= xMax || yMin >= yMax) + throw new ArgumentException("Surface bounds must be finite increasing intervals."); + + var x = Axis(xMin, xMax, gridSize); + var y = Axis(yMin, yMax, gridSize); + var z = new double?[gridSize][]; + var finite = 0; + for (var row = 0; row < gridSize; row++) + { + cancellation.ThrowIfCancellationRequested(); + z[row] = new double?[gridSize]; + for (var column = 0; column < gridSize; column++) + { + cancellation.ThrowIfCancellationRequested(); + double value; + try { value = function.Evaluate(x[column], y[row]); } + // Arithmetic singularities leave holes in the mesh; other + // runtime errors (including invalid user code) remain errors. + catch (CalculationException error) when (error.InnerException is ArithmeticException) + { continue; } + catch (ArithmeticException) { continue; } + if (!double.IsFinite(value)) continue; + z[row][column] = value; + finite++; + } + } + return new SurfaceGrid(x, y, z, finite); + } + + private static double[] Axis(double minimum, double maximum, int count) + { + var values = new double[count]; + for (var index = 0; index < count; index++) + { + // Weighted interpolation avoids overflowing (maximum - minimum). + var ratio = index / (double)(count - 1); + values[index] = index == 0 ? minimum : index == count - 1 ? maximum + : (1 - ratio) * minimum + ratio * maximum; + } + return values; + } + } + + public sealed class SurfaceGrid + { + public string Kind => "surface"; + public double[] X { get; } + public double[] Y { get; } + public double?[][] Z { get; } + public int FiniteSamples { get; } + public int MissingSamples => X.Length * Y.Length - FiniteSamples; + + public SurfaceGrid(double[] x, double[] y, double?[][] z, int finiteSamples) + { X = x; Y = y; Z = z; FiniteSamples = finiteSamples; } + } +} From 06677736dc033965c7bcf39baab0dee6b82ce945 Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Sun, 25 Oct 2026 21:44:50 +0100 Subject: [PATCH 02/16] Render interactive WebGL f(x,y) surfaces in browser with bounded JSON validation and PNG export --- .../ChartApiTests.cs | 2 +- .../ClientApp/components/Chart.tsx | 41 ++++-- .../ClientApp/components/SurfacePlot.tsx | 122 ++++++++++++++++++ .../ClientApp/css/site.css | 5 + .../ClientApp/helpers/plotly-gl3d.d.ts | 12 ++ Computator.NET.WebClient/package-lock.json | 8 +- Computator.NET.WebClient/package.json | 3 +- 7 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx create mode 100644 Computator.NET.WebClient/ClientApp/helpers/plotly-gl3d.d.ts diff --git a/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs b/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs index e6da23ac..16ef952a 100644 --- a/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs +++ b/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs @@ -57,7 +57,7 @@ private async Task Surface(object request) [TestCase("x*x + y*y", 8.0, 0.0)] [TestCase("x + y", -4.0, 0.0)] - [TestCase("sin(x)*cos(y)", Math.Sin(-2)*Math.Cos(-2), 0.0)] + [TestCase("sin(x)*cos(y)", -0.37840124765396416, 0.0)] public async Task RealFxyApiSamplesKnownSurfaces(string expression, double corner, double center) { var grid = await Surface(new { expression, gridSize = 9, xMin = -2, xMax = 2, diff --git a/Computator.NET.WebClient/ClientApp/components/Chart.tsx b/Computator.NET.WebClient/ClientApp/components/Chart.tsx index 92d6c9dc..02874f6f 100644 --- a/Computator.NET.WebClient/ClientApp/components/Chart.tsx +++ b/Computator.NET.WebClient/ClientApp/components/Chart.tsx @@ -4,17 +4,21 @@ import { useCustomFunctions } from '../helpers/CustomFunctionsContext'; import { useChartDraft } from '../helpers/ToolDraftContext'; import { Expression } from './Expression'; import { NumericInput } from './NumericInput'; +import { SurfacePlot, isSurfaceGrid, type SurfaceGrid } from './SurfacePlot'; -type Plot = { expression: string; customFunctions: string; xMin: number; xMax: number; yMin: number; yMax: number }; -type RenderedPlot = { url: string; expression: string }; +type Plot = { expression: string; customFunctions: string; xMin: number; xMax: number; yMin: number; yMax: number; gridSize: number }; +type RenderedPlot = { kind: 'image'; url: string; expression: string } | { kind: 'surface'; grid: SurfaceGrid; expression: string }; export function Chart() { const { expression, setExpression, xMin, setXMin, xMax, setXMax, yMin, setYMin, yMax, setYMax } = useChartDraft(); const [rendered, setRendered] = useState(); + const [gridSize, setGridSize] = useState(65); const [error, setError] = useState(''), [pending, setPending] = useState(false), [status, setStatus] = useState(''); const container = useRef(null); const active = useRef(null), imageUrl = useRef(null); const requested = useRef(null); + // A surface resizes client-side without recompiling the user's function. + const shownSurface = useRef(null); const functions = useCustomFunctions(); const renderPlot = useCallback(async (plot: Plot) => { @@ -40,13 +44,26 @@ export function Chart() { } throw new Error(detail); } - if (!response.headers.get('content-type')?.toLowerCase().startsWith('image/png')) throw new Error('Invalid chart response.'); + const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; + if (contentType.includes('application/json')) { + const json: unknown = await response.json(); + if (!isSurfaceGrid(json)) throw new Error('Invalid 3D surface response.'); + if (active.current !== controller || controller.signal.aborted) return; + if (imageUrl.current) URL.revokeObjectURL(imageUrl.current); + imageUrl.current = null; + shownSurface.current = plot; + setRendered({ kind: 'surface', grid: json, expression: plot.expression }); + setStatus('3D surface samples ready.'); + return; + } + if (!contentType.startsWith('image/png')) throw new Error('Invalid chart response.'); const image = await response.blob(); if (active.current !== controller || controller.signal.aborted) return; const url = URL.createObjectURL(image); if (imageUrl.current) URL.revokeObjectURL(imageUrl.current); imageUrl.current = url; - setRendered({ url, expression: plot.expression }); + shownSurface.current = null; + setRendered({ kind: 'image', url, expression: plot.expression }); setStatus('Chart ready.'); } catch (reason) { if (active.current === controller && !controller.signal.aborted) { @@ -65,7 +82,7 @@ export function Chart() { if (![xMin, xMax, yMin, yMax].every(Number.isFinite) || xMin >= xMax || yMin >= yMax) { setError('Each minimum must be smaller than its maximum, and all bounds must be finite.'); return; } - const plot = { expression, customFunctions: functions.code, xMin, xMax, yMin, yMax }; + const plot = { expression, customFunctions: functions.code, xMin, xMax, yMin, yMax, gridSize }; requested.current = plot; void renderPlot(plot); } @@ -80,7 +97,7 @@ export function Chart() { clearTimeout(timeout); timeout = setTimeout(() => { // Resize only the explicitly requested plot, never unsubmitted editor text. - if (requested.current) void renderPlot(requested.current); + if (requested.current && requested.current !== shownSurface.current) void renderPlot(requested.current); }, 200); }; window.addEventListener('resize', resize); @@ -95,15 +112,23 @@ export function Chart() { {error &&

{error}

}

{status}

- {rendered ?
{rendered.expression} + {rendered?.kind === 'image' ?
{rendered.expression}
Rendered expression: {rendered.expression}
+ : rendered?.kind === 'surface' ? :

Enter an expression and choose Draw chart.

}
{pending && } - {rendered && Download PNG} + {rendered?.kind === 'image' && Download PNG}
+ +

Chart area values:

  • diff --git a/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx b/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx new file mode 100644 index 00000000..f4f05744 --- /dev/null +++ b/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef, useState } from 'react'; + +// The sample grid is produced by the same server-side expression evaluator used +// for numerical calculations. Plotly never parses or evaluates user definitions. +export type SurfaceGrid = { + kind: 'surface'; + x: number[]; + y: number[]; + z: (number | null)[][]; + finiteSamples: number; + missingSamples: number; +}; + +export function isSurfaceGrid(value: unknown): value is SurfaceGrid { + if (!value || typeof value !== 'object') return false; + const grid = value as Record; + if (grid.kind !== 'surface' || !Array.isArray(grid.x) || !Array.isArray(grid.y) || + !Array.isArray(grid.z) || grid.x.length < 9 || grid.x.length > 129 || + grid.y.length !== grid.x.length || grid.z.length !== grid.y.length || + typeof grid.finiteSamples !== 'number' || typeof grid.missingSamples !== 'number') return false; + if (!grid.x.every((x: unknown) => typeof x === 'number' && Number.isFinite(x)) || + !grid.y.every((y: unknown) => typeof y === 'number' && Number.isFinite(y))) return false; + let finite = 0; + for (const row of grid.z) { + if (!Array.isArray(row) || row.length !== grid.x.length) return false; + for (const height of row) { + if (height === null) continue; + if (typeof height !== 'number' || !Number.isFinite(height)) return false; + finite++; + } + } + return finite >= 3 && finite === grid.finiteSamples && + grid.missingSamples === grid.x.length * grid.y.length - finite; +} + +export function SurfacePlot({ grid, expression }: { grid: SurfaceGrid; expression: string }) { + const host = useRef(null); + const plotly = useRef<(typeof import('plotly.js-gl3d-dist-min'))['default'] | null>(null); + const [ready, setReady] = useState(false), [exporting, setExporting] = useState(false); + const [error, setError] = useState(''); + useEffect(() => { + const div = host.current; + if (!div) return; + let disposed = false; + let resize: ResizeObserver | null = null; + const start = async () => { + try { + // Load the offline, pinned WebGL surface bundle only for f(x,y). + const { default: Plotly } = await import('plotly.js-gl3d-dist-min'); + if (disposed) return; + plotly.current = Plotly; + const axis = (name: string) => ({ + title: { text: name, font: { size: 15, color: '#eff0f3' } }, + tickfont: { color: '#eff0f3', size: 12 }, gridcolor: '#5b6570', + zerolinecolor: '#c2cad5', backgroundcolor: '#252930', showbackground: true + }); + await Plotly.newPlot(div, [{ + type: 'surface', x: grid.x, y: grid.y, z: grid.z, + connectgaps: false, showscale: true, + colorscale: 'Viridis', + hovertemplate: 'x: %{x:.4g}
    y: %{y:.4g}
    f(x,y): %{z:.4g}' + }], { + autosize: true, margin: { l: 0, r: 0, b: 0, t: 0 }, + paper_bgcolor: '#23262c', font: { color: '#eff0f3' }, + scene: { + xaxis: axis('x'), yaxis: axis('y'), zaxis: axis('f(x,y)'), + aspectmode: 'auto', dragmode: 'turntable', + camera: { eye: { x: 1.55, y: 1.55, z: 1.2 } } + } + }, { responsive: true, scrollZoom: true, displaylogo: false, + modeBarButtonsToRemove: ['sendDataToCloud'] }); + if (disposed) { Plotly.purge(div); return; } + // A real Plotly camera update is observable for browser interaction tests. + const graph = div as HTMLDivElement & { on?: (name: string, listener: (value: Record) => void) => void }; + graph.on?.('plotly_relayout', event => { + if (event['scene.camera']) div.dataset.camera = JSON.stringify(event['scene.camera']); + }); + resize = new ResizeObserver(() => { void Plotly.Plots.resize(div); }); + resize.observe(div); + setReady(true); + } catch (reason) { + if (!disposed) setError(reason instanceof Error ? reason.message : '3D rendering is unavailable.'); + } + }; + void start(); + return () => { + disposed = true; + resize?.disconnect(); + if (plotly.current) plotly.current.purge(div); + plotly.current = null; + }; + }, [grid, expression]); + + async function download() { + const div = host.current; + if (!div || !plotly.current || !ready) return; + setExporting(true); setError(''); + try { + // Plotly composites WebGL + axis annotations into a real image. + const png = await plotly.current.toImage(div, { format: 'png', width: 1200, height: 800, scale: 1 }); + const link = document.createElement('a'); + link.href = png; link.download = 'computator-chart.png'; + document.body.appendChild(link); link.click(); link.remove(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'PNG export failed.'); + } finally { setExporting(false); } + } + + return
    +
    +
    Rendered expression: {expression}
    +

    + {ready ? 'Interactive surface ready. Drag to rotate; use the mouse wheel or touch to zoom.' + : error ? '3D rendering failed.' : 'Preparing interactive 3D view…'} +

    + {error &&

    {error}

    } + +

    Non-finite samples are omitted: {grid.missingSamples} of {grid.x.length * grid.y.length}.

    +
    ; +} diff --git a/Computator.NET.WebClient/ClientApp/css/site.css b/Computator.NET.WebClient/ClientApp/css/site.css index b4084c9f..9399a477 100644 --- a/Computator.NET.WebClient/ClientApp/css/site.css +++ b/Computator.NET.WebClient/ClientApp/css/site.css @@ -101,3 +101,8 @@ h2, h3 { color: var(--workspace-text); } .computator-reference pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 1em; } .computator-reference code { font-size: 1em; } .computator-reference .reference-source { font-size: .88em; } + +/* A responsive, keyboard-labelled surface canvas; the GPU scene is rendered by + the browser so resizing and camera movement never recompile user code. */ +.chart-surface { width: 100%; height: clamp(380px, 65vh, 760px); min-width: 0; border: 1px solid var(--workspace-line); border-radius: 6px; overflow: hidden; background: var(--workspace-surface); } +.chart-surface:focus-visible { outline: 3px solid var(--workspace-accent); } diff --git a/Computator.NET.WebClient/ClientApp/helpers/plotly-gl3d.d.ts b/Computator.NET.WebClient/ClientApp/helpers/plotly-gl3d.d.ts new file mode 100644 index 00000000..af77e0ba --- /dev/null +++ b/Computator.NET.WebClient/ClientApp/helpers/plotly-gl3d.d.ts @@ -0,0 +1,12 @@ +// The pinned Plotly gl3d partial bundle publishes a UMD distribution without +// complete standalone TypeScript declarations. Limit this shim to APIs we use. +declare module 'plotly.js-gl3d-dist-min' { + interface PlotlyGl3d { + newPlot(node: HTMLElement, traces: unknown[], layout: unknown, config?: unknown): Promise; + purge(node: HTMLElement): void; + toImage(node: HTMLElement, options: { format: 'png'; width: number; height: number; scale: number }): Promise; + Plots: { resize(node: HTMLElement): void | Promise }; + } + const Plotly: PlotlyGl3d; + export default Plotly; +} diff --git a/Computator.NET.WebClient/package-lock.json b/Computator.NET.WebClient/package-lock.json index 60a6a5ce..5938e383 100644 --- a/Computator.NET.WebClient/package-lock.json +++ b/Computator.NET.WebClient/package-lock.json @@ -13,7 +13,8 @@ "react": "19.3.1", "react-ace": "15.0.0", "react-dom": "19.3.1", - "react-router-dom": "7.18.4" + "react-router-dom": "7.18.4", + "plotly.js-gl3d-dist-min": "4.1.2" }, "devDependencies": { "@mathjax/src": "4.1.3", @@ -1954,6 +1955,11 @@ "integrity": "sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==", "dev": true, "license": "MIT" + }, + "node_modules/plotly.js-gl3d-dist-min": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/plotly.js-gl3d-dist-min/-/plotly.js-gl3d-dist-min-4.1.2.tgz", + "license": "MIT" } } } diff --git a/Computator.NET.WebClient/package.json b/Computator.NET.WebClient/package.json index 36c85297..cc260f2d 100644 --- a/Computator.NET.WebClient/package.json +++ b/Computator.NET.WebClient/package.json @@ -21,7 +21,8 @@ "react": "19.3.1", "react-ace": "15.0.0", "react-dom": "19.3.1", - "react-router-dom": "7.18.4" + "react-router-dom": "7.18.4", + "plotly.js-gl3d-dist-min": "4.1.2" }, "devDependencies": { "@playwright/test": "1.64.1", From 05f3d6f096390e555efb94a90ee40b96e6ad23e1 Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Sun, 25 Oct 2026 21:47:46 +0100 Subject: [PATCH 03/16] Cover surface API and real browser interactions; document cross-platform boundaries --- .../ChartApiTests.cs | 2 +- .../ClientApp/components/SurfacePlot.tsx | 1 + .../e2e/surface-real.spec.ts | 53 ++++++++ .../tests/chart-surface.spec.ts | 118 ++++++++++++++++++ build/Assert-SuiteResults.ps1 | 5 - docs/followups/WEB-3D-SURFACES.md | 75 +++++++++++ 6 files changed, 248 insertions(+), 6 deletions(-) create mode 100644 Computator.NET.WebClient/e2e/surface-real.spec.ts create mode 100644 Computator.NET.WebClient/tests/chart-surface.spec.ts create mode 100644 docs/followups/WEB-3D-SURFACES.md diff --git a/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs b/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs index 16ef952a..0aee3b45 100644 --- a/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs +++ b/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs @@ -57,7 +57,7 @@ private async Task Surface(object request) [TestCase("x*x + y*y", 8.0, 0.0)] [TestCase("x + y", -4.0, 0.0)] - [TestCase("sin(x)*cos(y)", -0.37840124765396416, 0.0)] + [TestCase("sin(x)*cos(y)", 0.37840124765396416, 0.0)] public async Task RealFxyApiSamplesKnownSurfaces(string expression, double corner, double center) { var grid = await Surface(new { expression, gridSize = 9, xMin = -2, xMax = 2, diff --git a/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx b/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx index f4f05744..20f9ba25 100644 --- a/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx +++ b/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx @@ -42,6 +42,7 @@ export function SurfacePlot({ grid, expression }: { grid: SurfaceGrid; expressio const div = host.current; if (!div) return; let disposed = false; + setReady(false); setError(''); let resize: ResizeObserver | null = null; const start = async () => { try { diff --git a/Computator.NET.WebClient/e2e/surface-real.spec.ts b/Computator.NET.WebClient/e2e/surface-real.spec.ts new file mode 100644 index 00000000..70973d87 --- /dev/null +++ b/Computator.NET.WebClient/e2e/surface-real.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; + +// Runs only against the actual Windows mathematical API and published web host. +// No route mocks or predetermined server responses are used. +test('f(x,y) real API samples and browser camera/export agree with a paraboloid', async ({ page, request }) => { + const numerical = await request.post('/api/v2/chart', { + data: { expression: 'x*x + y*y', gridSize: 9, xMin: -2, xMax: 2, yMin: -2, yMax: 2 } + }); + expect(numerical.status(), await numerical.text()).toBe(200); + const grid = await numerical.json() as { + kind: string; x: number[]; y: number[]; z: (number | null)[][]; finiteSamples: number; missingSamples: number + }; + expect(grid.kind).toBe('surface'); + expect(grid.x).toHaveLength(9); expect(grid.y).toHaveLength(9); + expect(grid.finiteSamples).toBe(81); expect(grid.missingSamples).toBe(0); + expect(grid.z[0][0]).toBeCloseTo(8, 8); + expect(grid.z[4][4]).toBeCloseTo(0, 8); + expect(grid.z[8][8]).toBeCloseTo(8, 8); + + await page.goto('/'); + await page.locator('#expressionTextArea textarea').fill('x*x+y*y'); + await page.getByLabel('3D surface quality').selectOption('33'); + const pending = page.waitForResponse(response => + new URL(response.url()).pathname === '/api/v2/chart' && response.request().method() === 'POST', + { timeout: 35000 }); + await page.getByRole('button', { name: 'Draw chart!' }).click(); + const response = await pending; + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toContain('application/json'); + const shown = await response.json() as { z: number[][]; finiteSamples: number }; + expect(shown.finiteSamples).toBe(33 * 33); + expect(shown.z[16][16]).toBeCloseTo(0, 8); + const surface = page.getByTestId('surface-chart'); + await expect(surface).toBeVisible(); + await expect(page.getByText('Interactive surface ready.', { exact: false })).toBeVisible({ timeout: 25000 }); + const rect = await surface.boundingBox(); + expect(rect).not.toBeNull(); + const x = rect!.x + rect!.width * 0.5, y = rect!.y + rect!.height * 0.5; + await page.mouse.move(x, y); await page.mouse.down(); + await page.mouse.move(x + 90, y + 45, { steps: 10 }); await page.mouse.up(); + await expect.poll(() => surface.getAttribute('data-camera'), { timeout: 10000 }).not.toBeNull(); + const beforeZoom = await surface.getAttribute('data-camera'); + await page.mouse.move(x, y); await page.mouse.wheel(0, -500); + await expect.poll(() => surface.getAttribute('data-camera'), { timeout: 10000 }).not.toBe(beforeZoom); + const downloading = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Download PNG' }).click(); + const download = await downloading; + const png = await readFile((await download.path())!); + expect(download.suggestedFilename()).toBe('computator-chart.png'); + expect(png.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a'); + expect(png.length).toBeGreaterThan(2048); +}); diff --git a/Computator.NET.WebClient/tests/chart-surface.spec.ts b/Computator.NET.WebClient/tests/chart-surface.spec.ts new file mode 100644 index 00000000..87f2df77 --- /dev/null +++ b/Computator.NET.WebClient/tests/chart-surface.spec.ts @@ -0,0 +1,118 @@ +import { test, expect, type Page } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; + +const coordinates = Array.from({ length: 9 }, (_, i) => i - 4); +const grid = { + kind: 'surface' as const, x: coordinates, y: coordinates, + z: coordinates.map(y => coordinates.map(x => x * x + y * y)), + finiteSamples: 81, missingSamples: 0 +}; +const headers = { 'access-control-allow-origin': '*' }; +async function edit(page: Page, code: string) { + const input = page.locator('#expressionTextArea textarea'); + await input.focus(); await input.press('ControlOrMeta+A'); await page.keyboard.insertText(code); +} +test.beforeEach(async ({ page }) => { + await page.route('**/api/autocomplete/**', route => route.fulfill({ json: [], headers })); +}); + +test('surface uses real WebGL rotation, zoom, labels and exported PNG without a second API call on resize', async ({ page }) => { + const requests: Record[] = []; + await page.route('**/api/v2/chart', route => { + requests.push(route.request().postDataJSON()); + return route.fulfill({ json: grid, headers }); + }); + await page.goto('/'); await edit(page, 'x*x+y*y'); + const before = page.waitForRequest('**/api/v2/chart'); + await page.getByRole('button', { name: 'Draw chart!' }).click(); await before; + const surface = page.getByTestId('surface-chart'); + await expect(surface).toBeVisible(); + await expect(page.getByText('Interactive surface ready.', { exact: false })).toBeVisible({ timeout: 20000 }); + await expect.poll(() => surface.locator('canvas').count()).toBeGreaterThan(0); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ gridSize: 65, xMin: -5, xMax: 5, customFunctions: '' }); + const titles = await surface.evaluate(element => { + const layout = (element as HTMLDivElement & { layout?: { + scene?: { xaxis?: { title?: { text?: string } }; yaxis?: { title?: { text?: string } }; zaxis?: { title?: { text?: string } } } + } }).layout; + return [layout?.scene?.xaxis?.title?.text, layout?.scene?.yaxis?.title?.text, layout?.scene?.zaxis?.title?.text]; + }); + expect(titles).toEqual(['x', 'y', 'f(x,y)']); + const position = await surface.boundingBox(); + expect(position).not.toBeNull(); + const x = position!.x + position!.width * 0.48, y = position!.y + position!.height * 0.46; + await page.mouse.move(x, y); await page.mouse.down(); + await page.mouse.move(x + 100, y + 55, { steps: 12 }); await page.mouse.up(); + await expect.poll(() => surface.getAttribute('data-camera'), { timeout: 10000 }).not.toBeNull(); + const rotated = await surface.getAttribute('data-camera'); + await page.mouse.move(x, y); + await page.mouse.wheel(0, -400); + await expect.poll(() => surface.getAttribute('data-camera'), { timeout: 10000 }).not.toBe(rotated); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Download PNG' }).click(); + const file = await download; + expect(file.suggestedFilename()).toBe('computator-chart.png'); + const bytes = await readFile((await file.path())!); + expect(bytes.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a'); + expect(bytes.length).toBeGreaterThan(2048); + + await page.setViewportSize({ width: 1080, height: 760 }); + await expect(surface).toBeVisible(); + await page.waitForTimeout(350); + expect(requests).toHaveLength(1); + await page.getByLabel('3D surface quality').selectOption('97'); + await page.getByRole('button', { name: 'Draw chart!' }).click(); + await expect.poll(() => requests.length).toBe(2); + expect(requests[1]).toMatchObject({ gridSize: 97 }); +}); + +test('a malformed surface is rejected instead of rendering unbounded or non-finite data', async ({ page }) => { + await page.route('**/api/v2/chart', route => route.fulfill({ json: { + ...grid, z: grid.z.map((row, index) => index === 0 ? [Number.MAX_VALUE, ...row] : row) + }, headers })); + await page.goto('/'); await edit(page, 'x+y'); + await page.getByRole('button', { name: 'Draw chart!' }).click(); + await expect(page.getByRole('alert')).toHaveText('Invalid 3D surface response.'); + await expect(page.getByTestId('surface-chart')).toHaveCount(0); +}); + +test('the submitted surface includes unsaved two-argument definitions from the current tab', async ({ page }) => { + const requests: Record[] = []; + await page.route('**/api/v2/chart', route => { + requests.push(route.request().postDataJSON()); + return route.fulfill({ json: grid, headers }); + }); + await page.goto('/custom-functions'); + await page.locator('#customFunctionsTextArea textarea').fill( + 'static real SurfaceProbe(real x, real y) { return x*x+y*y+3; }'); + await page.getByRole('link', { name: 'Chart', exact: true }).click(); + await edit(page, 'SurfaceProbe(x,y)'); + await page.getByRole('button', { name: 'Draw chart!' }).click(); + await expect.poll(() => requests.length).toBe(1); + expect(requests[0].customFunctions).toContain('SurfaceProbe'); + await expect(page.getByTestId('surface-chart')).toBeVisible(); +}); + +test('cancelled 3D requests do not display a stale surface', async ({ page }) => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let count = 0; + await page.route('**/api/v2/chart', async route => { + count++; + if (count === 1) await gate; + await route.fulfill({ json: grid, headers }).catch(() => {}); + }); + try { + await page.goto('/'); await edit(page, 'x+y'); + const request = page.waitForRequest('**/api/v2/chart'); + await page.getByRole('button', { name: 'Draw chart!' }).click(); await request; + await page.getByRole('button', { name: 'Cancel chart' }).click(); + release(); + await expect(page.getByRole('status').first()).toHaveText('Chart rendering cancelled.'); + await expect(page.getByTestId('surface-chart')).toHaveCount(0); + await edit(page, 'x*x+y*y'); + await page.getByRole('button', { name: 'Draw chart!' }).click(); + await expect(page.getByTestId('surface-chart')).toBeVisible(); + expect(count).toBe(2); + } finally { release(); } +}); diff --git a/build/Assert-SuiteResults.ps1 b/build/Assert-SuiteResults.ps1 index 6b984c8d..c94bfcfe 100644 --- a/build/Assert-SuiteResults.ps1 +++ b/build/Assert-SuiteResults.ps1 @@ -5,9 +5,4 @@ param( ) $ErrorActionPreference = 'Stop' $skips = @() -if ($Suite -eq 'Computator.NET.WebApi.IntegrationTests') { - $skips = @( - 'Computator.NET.WebApi.IntegrationTests.ChartApiTests.ReturnsHttpOkForChart3D' - ) -} & (Join-Path $PSScriptRoot 'Assert-TestResults.ps1') -Path (Join-Path $PSScriptRoot "../TestResults/$Suite.trx") -MinimumTests $MinimumTests -AllowedSkippedTests $skips diff --git a/docs/followups/WEB-3D-SURFACES.md b/docs/followups/WEB-3D-SURFACES.md new file mode 100644 index 00000000..a76a1d5d --- /dev/null +++ b/docs/followups/WEB-3D-SURFACES.md @@ -0,0 +1,75 @@ +# Interactive f(x,y) surfaces in the browser + +Scope: issue [#292](https://github.com/PawelTroka/Computator.NET/issues/292). +This change adds a browser visualization path for **explicit real** height fields +z = f(x,y); it does **not** implement a native Linux/macOS 3D desktop control +or make the Windows-dependent API portable. + +## Rendering and evaluation contract + +The existing POST `/api/v2/chart` retains PNG for 2D real and complex +expressions. For expressions classified as `CalculationsMode.Fxy`, the same +endpoint uses the existing trusted-local Roslyn `ExpressionsEvaluator` and +request-local, unsaved custom functions. It returns JSON with: + +- `kind: "surface"` +- `x[]`, `y[]` and row-major `z[yIndex][xIndex]` +- `finiteSamples` and `missingSamples`, with null height values for + non-finite/arithmetically singular coordinates + +The response is a **bounded numerical surface**, not a prerendered picture. +`gridSize` defaults to 65 and must be 9–129, at most 16,641 evaluations. +Width/height, source-size, numeric bounds, 30-second worker and execution +queue limits remain enforced. Request cancellation terminates the owned worker; +the sampler also checks a cancellation token between evaluations. Samples are +request-local and are never cached or forwarded to an external host. Completely +non-finite surfaces are rejected with HTTP 422 rather than displayed as zeroes. + +For Fxy, the browser lazily loads the pinned, bundled `plotly.js-gl3d-dist-min` +package for GPU WebGL rendering. It supports interactive turntable rotation, +mouse-wheel/touch zoom, shading and occlusion, numeric x/y/f(x,y) axes, holes +at undefined points, tooltips, responsive resize without reevaluation, and PNG +export of the composed view. In environments without functional WebGL, the +application reports a rendering error instead of claiming to draw a surface. +The client validates array shape/counts before passing data to Plotly. + +Alternatives considered: server-side WinForms/WPF screenshots would still be +noninteractive and Windows-only; a custom Three.js scene would require separate +axis/picking/export implementations and a larger maintenance surface. Plotly's +partial gl3d build ships inside the offline frontend, not via a CDN. + +## Tests and reproducibility + +- `dotnet test Computator.NET.WebApi.IntegrationTests/Computator.NET.WebApi.IntegrationTests.csproj --filter FullyQualifiedName~ChartApiTests` (Windows .NET 10): + real HTTP worker contract and exact paraboloid, plane, oscillatory surface + samples; unsaved custom functions, singular gaps, bound limits and cancellation. +- `cd Computator.NET.WebClient && npm ci && npm run build && npx playwright test tests/chart-surface.spec.ts`: + real Chromium/Firefox WebGL rotation, wheel zoom, axis labels, PNG export, + cancellation, validation and request-local unsaved editor definitions using + a deterministic sample response. +- `./build.ps1 -Target Web-E2E` (Windows): published API and web app, + real numerical 3D samples, UI camera movement and image export through the + same-origin proxy (no route interception). The CI workflow records evidence. + +The previously ignored legacy `GET /api/chart/2y` PNG test is replaced by +passing JSON surface tests; the legacy image route is **not** claimed to support +interactive 3D or server-side Fxy rasterization. The result guard no longer +permits the skipped 3D test. + +## Still missing for complete platform parity + +- **Linux Mono desktop:** its chart GUI still rejects 3D requests; implement + and exercise an actual supported viewer, or explicitly offer a local browser + workflow with matching authentication and native prerequisites. +- **macOS:** standalone native/Mono desktop experience and both Intel and + Apple Silicon GUI tests remain unproven. +- **Standalone non-Windows web hosting:** the full Web API depends on Windows + UI/chart assemblies and native catalogs. Browser access from Linux/macOS + to a Windows-hosted service does not constitute a portable local package. +- **Other chart workflows:** implicit 3D equations, scripted point series, + multiple accumulated 2D functions, desktop zoom/pan and complex chart + selectors remain separate follow-ups. Large/slow arbitrary user functions + are subject to the existing worker execution limits. + +No Windows desktop rendering path, WinForms/WPF charting code, or native +packaging target is replaced by this work. From 6590d27fb11ed206a8c42b80a91c299fe3c96bf6 Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Sun, 25 Oct 2026 21:51:17 +0100 Subject: [PATCH 04/16] Register 3D workstream documentation in guarded solution inventory --- Computator.NET.slnx | 1 + 1 file changed, 1 insertion(+) diff --git a/Computator.NET.slnx b/Computator.NET.slnx index 77c6a057..cd08e103 100644 --- a/Computator.NET.slnx +++ b/Computator.NET.slnx @@ -316,6 +316,7 @@ + From c5b1429499463dc6ae07d7202fbf696089fe8f10 Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Sun, 25 Oct 2026 22:00:26 +0100 Subject: [PATCH 05/16] Make 3D camera zoom consistent across browsers and exercise Fxy singular rejection --- .../ChartApiTests.cs | 2 +- .../ClientApp/components/SurfacePlot.tsx | 62 ++++++++++++++++++- .../ClientApp/helpers/plotly-gl3d.d.ts | 1 + .../e2e/surface-real.spec.ts | 15 +++-- .../tests/chart-surface.spec.ts | 25 ++++++-- docs/followups/WEB-3D-SURFACES.md | 4 +- 6 files changed, 93 insertions(+), 16 deletions(-) diff --git a/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs b/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs index 0aee3b45..905f47cc 100644 --- a/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs +++ b/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs @@ -100,7 +100,7 @@ public async Task SingularitiesBecomeNullHolesRatherThanInvalidJsonOrFlattenedZe [Test] public async Task AllSingularSurfaceIsRejectedWithoutCorruptingSubsequentRequests() { - using var response = await _client.PostAsJsonAsync("/api/v2/chart", new { expression = "1/(x-x)", gridSize = 9 }); + using var response = await _client.PostAsJsonAsync("/api/v2/chart", new { expression = "1/(y-y)", gridSize = 9 }); NUnit.Framework.Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity)); await Surface(new { expression = "x+y", gridSize = 9 }); } diff --git a/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx b/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx index 20f9ba25..3028246e 100644 --- a/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx +++ b/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx @@ -1,5 +1,8 @@ import { useEffect, useRef, useState } from 'react'; +type Eye = { x: number; y: number; z: number }; +const initialEye: Eye = { x: 1.55, y: 1.55, z: 1.2 }; + // The sample grid is produced by the same server-side expression evaluator used // for numerical calculations. Plotly never parses or evaluates user definitions. export type SurfaceGrid = { @@ -35,14 +38,42 @@ export function isSurfaceGrid(value: unknown): value is SurfaceGrid { export function SurfacePlot({ grid, expression }: { grid: SurfaceGrid; expression: string }) { const host = useRef(null); + const cameraEye = useRef({ ...initialEye }); const plotly = useRef<(typeof import('plotly.js-gl3d-dist-min'))['default'] | null>(null); const [ready, setReady] = useState(false), [exporting, setExporting] = useState(false); const [error, setError] = useState(''); + + // Explicit camera controls remain operable with keyboard/touch and on + // devices whose browser/WebGL implementation does not forward drag or wheel + // gestures reliably. The actual Plotly camera is updated, not an overlay. + function setEye(next: Eye) { + const div = host.current; + if (!div || !plotly.current) return; + cameraEye.current = next; + void plotly.current.relayout(div, { 'scene.camera': { eye: next } }) + .then(() => { div.dataset.camera = JSON.stringify({ eye: next }); }) + .catch(reason => setError(reason instanceof Error ? reason.message : 'Camera update failed.')); + } + function rotate(angle: number) { + const { x, y, z } = cameraEye.current; + setEye({ x: x * Math.cos(angle) - y * Math.sin(angle), + y: x * Math.sin(angle) + y * Math.cos(angle), z }); + } + function zoom(scale: number) { + const { x, y, z } = cameraEye.current; + const radius = Math.hypot(x, y, z); + const factor = Math.max(0.25, Math.min(15, radius * scale)) / radius; + setEye({ x: x * factor, y: y * factor, z: z * factor }); + } + useEffect(() => { const div = host.current; if (!div) return; let disposed = false; + cameraEye.current = { ...initialEye }; + delete div.dataset.camera; setReady(false); setError(''); + let wheelHandler: ((event: WheelEvent) => void) | undefined; let resize: ResizeObserver | null = null; const start = async () => { try { @@ -66,16 +97,28 @@ export function SurfacePlot({ grid, expression }: { grid: SurfaceGrid; expressio scene: { xaxis: axis('x'), yaxis: axis('y'), zaxis: axis('f(x,y)'), aspectmode: 'auto', dragmode: 'turntable', - camera: { eye: { x: 1.55, y: 1.55, z: 1.2 } } + camera: { eye: { ...initialEye } } } - }, { responsive: true, scrollZoom: true, displaylogo: false, + }, { responsive: true, scrollZoom: false, displaylogo: false, modeBarButtonsToRemove: ['sendDataToCloud'] }); if (disposed) { Plotly.purge(div); return; } // A real Plotly camera update is observable for browser interaction tests. const graph = div as HTMLDivElement & { on?: (name: string, listener: (value: Record) => void) => void }; graph.on?.('plotly_relayout', event => { - if (event['scene.camera']) div.dataset.camera = JSON.stringify(event['scene.camera']); + const camera = event['scene.camera'] as { eye?: Eye } | undefined; + if (camera?.eye && [camera.eye.x, camera.eye.y, camera.eye.z].every(Number.isFinite)) { + cameraEye.current = { ...camera.eye }; + div.dataset.camera = JSON.stringify(camera); + } }); + // Native non-passive listener avoids the browser scrolling the whole + // page; Plotly's gl3d built-in scroll handling differs across engines. + wheelHandler = event => { + if (!event.deltaY) return; + event.preventDefault(); + zoom(event.deltaY < 0 ? 0.85 : 1.15); + }; + div.addEventListener('wheel', wheelHandler, { passive: false }); resize = new ResizeObserver(() => { void Plotly.Plots.resize(div); }); resize.observe(div); setReady(true); @@ -87,6 +130,7 @@ export function SurfacePlot({ grid, expression }: { grid: SurfaceGrid; expressio return () => { disposed = true; resize?.disconnect(); + if (wheelHandler) div.removeEventListener('wheel', wheelHandler); if (plotly.current) plotly.current.purge(div); plotly.current = null; }; @@ -116,6 +160,18 @@ export function SurfacePlot({ grid, expression }: { grid: SurfaceGrid; expressio : error ? '3D rendering failed.' : 'Preparing interactive 3D view…'}

    {error &&

    {error}

    } +
    + + + + + +

    Non-finite samples are omitted: {grid.missingSamples} of {grid.x.length * grid.y.length}.

    diff --git a/Computator.NET.WebClient/ClientApp/helpers/plotly-gl3d.d.ts b/Computator.NET.WebClient/ClientApp/helpers/plotly-gl3d.d.ts index af77e0ba..98c2a5f8 100644 --- a/Computator.NET.WebClient/ClientApp/helpers/plotly-gl3d.d.ts +++ b/Computator.NET.WebClient/ClientApp/helpers/plotly-gl3d.d.ts @@ -4,6 +4,7 @@ declare module 'plotly.js-gl3d-dist-min' { interface PlotlyGl3d { newPlot(node: HTMLElement, traces: unknown[], layout: unknown, config?: unknown): Promise; purge(node: HTMLElement): void; + relayout(node: HTMLElement, update: Record): Promise; toImage(node: HTMLElement, options: { format: 'png'; width: number; height: number; scale: number }): Promise; Plots: { resize(node: HTMLElement): void | Promise }; } diff --git a/Computator.NET.WebClient/e2e/surface-real.spec.ts b/Computator.NET.WebClient/e2e/surface-real.spec.ts index 70973d87..a6dec194 100644 --- a/Computator.NET.WebClient/e2e/surface-real.spec.ts +++ b/Computator.NET.WebClient/e2e/surface-real.spec.ts @@ -34,15 +34,20 @@ test('f(x,y) real API samples and browser camera/export agree with a paraboloid' const surface = page.getByTestId('surface-chart'); await expect(surface).toBeVisible(); await expect(page.getByText('Interactive surface ready.', { exact: false })).toBeVisible({ timeout: 25000 }); + const eye = () => surface.evaluate(element => { + const graph = element as HTMLDivElement & { layout?: { scene?: { camera?: { eye?: { x: number; y: number; z: number } } } } }; + return graph.layout?.scene?.camera?.eye ?? null; + }); + const initial = await eye(); + expect(initial).not.toBeNull(); + await page.getByRole('button', { name: 'Rotate left' }).click(); + await expect.poll(eye).not.toEqual(initial); + const rotated = await eye(); const rect = await surface.boundingBox(); expect(rect).not.toBeNull(); const x = rect!.x + rect!.width * 0.5, y = rect!.y + rect!.height * 0.5; - await page.mouse.move(x, y); await page.mouse.down(); - await page.mouse.move(x + 90, y + 45, { steps: 10 }); await page.mouse.up(); - await expect.poll(() => surface.getAttribute('data-camera'), { timeout: 10000 }).not.toBeNull(); - const beforeZoom = await surface.getAttribute('data-camera'); await page.mouse.move(x, y); await page.mouse.wheel(0, -500); - await expect.poll(() => surface.getAttribute('data-camera'), { timeout: 10000 }).not.toBe(beforeZoom); + await expect.poll(eye).not.toEqual(rotated); const downloading = page.waitForEvent('download'); await page.getByRole('button', { name: 'Download PNG' }).click(); const download = await downloading; diff --git a/Computator.NET.WebClient/tests/chart-surface.spec.ts b/Computator.NET.WebClient/tests/chart-surface.spec.ts index 87f2df77..613b80b2 100644 --- a/Computator.NET.WebClient/tests/chart-surface.spec.ts +++ b/Computator.NET.WebClient/tests/chart-surface.spec.ts @@ -38,16 +38,31 @@ test('surface uses real WebGL rotation, zoom, labels and exported PNG without a return [layout?.scene?.xaxis?.title?.text, layout?.scene?.yaxis?.title?.text, layout?.scene?.zaxis?.title?.text]; }); expect(titles).toEqual(['x', 'y', 'f(x,y)']); + const eye = () => surface.evaluate(element => { + const graph = element as HTMLDivElement & { layout?: { scene?: { camera?: { eye?: { x: number; y: number; z: number } } } } }; + return graph.layout?.scene?.camera?.eye ?? null; + }); + const startingEye = await eye(); + expect(startingEye).not.toBeNull(); + await page.getByRole('button', { name: 'Rotate left' }).click(); + await expect.poll(eye).not.toEqual(startingEye); + const rotated = await eye(); + await page.getByRole('button', { name: 'Zoom in' }).click(); + await expect.poll(eye).not.toEqual(rotated); + const zoomed = await eye(); const position = await surface.boundingBox(); expect(position).not.toBeNull(); const x = position!.x + position!.width * 0.48, y = position!.y + position!.height * 0.46; - await page.mouse.move(x, y); await page.mouse.down(); - await page.mouse.move(x + 100, y + 55, { steps: 12 }); await page.mouse.up(); - await expect.poll(() => surface.getAttribute('data-camera'), { timeout: 10000 }).not.toBeNull(); - const rotated = await surface.getAttribute('data-camera'); await page.mouse.move(x, y); await page.mouse.wheel(0, -400); - await expect.poll(() => surface.getAttribute('data-camera'), { timeout: 10000 }).not.toBe(rotated); + await expect.poll(eye).not.toEqual(zoomed); + if (test.info().project.name === 'chromium') { + // Native Plotly drag interaction, in addition to accessible camera controls. + const preDrag = await eye(); + await page.mouse.move(x, y); await page.mouse.down(); + await page.mouse.move(x + 100, y + 55, { steps: 12 }); await page.mouse.up(); + await expect.poll(eye).not.toEqual(preDrag); + } const download = page.waitForEvent('download'); await page.getByRole('button', { name: 'Download PNG' }).click(); const file = await download; diff --git a/docs/followups/WEB-3D-SURFACES.md b/docs/followups/WEB-3D-SURFACES.md index a76a1d5d..81106864 100644 --- a/docs/followups/WEB-3D-SURFACES.md +++ b/docs/followups/WEB-3D-SURFACES.md @@ -26,8 +26,8 @@ request-local and are never cached or forwarded to an external host. Completely non-finite surfaces are rejected with HTTP 422 rather than displayed as zeroes. For Fxy, the browser lazily loads the pinned, bundled `plotly.js-gl3d-dist-min` -package for GPU WebGL rendering. It supports interactive turntable rotation, -mouse-wheel/touch zoom, shading and occlusion, numeric x/y/f(x,y) axes, holes +package for GPU WebGL rendering. It supports interactive turntable rotation, keyboard-accessible camera controls, +consistent wheel zoom and touch zoom, shading and occlusion, numeric x/y/f(x,y) axes, holes at undefined points, tooltips, responsive resize without reevaluation, and PNG export of the composed view. In environments without functional WebGL, the application reports a rendering error instead of claiming to draw a surface. From 9fc1b7949fab5dab4cd3ce04cfef2bf944511b01 Mon Sep 17 00:00:00 2001 From: "Pawel Troka, PhD Eng" Date: Sun, 25 Oct 2026 22:07:54 +0100 Subject: [PATCH 06/16] Detect unsupported WebGL rather than reporting a noninteractive surface as ready --- .../ClientApp/components/SurfacePlot.tsx | 6 ++++++ .../tests/chart-surface.spec.ts | 14 ++++++++++++++ docs/followups/WEB-3D-SURFACES.md | 5 +++++ 3 files changed, 25 insertions(+) diff --git a/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx b/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx index 3028246e..aed194cc 100644 --- a/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx +++ b/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx @@ -77,6 +77,12 @@ export function SurfacePlot({ grid, expression }: { grid: SurfaceGrid; expressio let resize: ResizeObserver | null = null; const start = async () => { try { + // Plotly can resolve newPlot while showing a noninteractive "WebGL is + // not supported" placeholder. Do not mark that state as successful or + // offer a PNG export that will fail with a missing graphics context. + const probe = document.createElement('canvas'); + const context = probe.getContext('webgl') || probe.getContext('webgl2'); + if (!context) throw new Error('WebGL is unavailable. Enable graphics acceleration or use a WebGL-capable browser for interactive 3D surfaces.'); // Load the offline, pinned WebGL surface bundle only for f(x,y). const { default: Plotly } = await import('plotly.js-gl3d-dist-min'); if (disposed) return; diff --git a/Computator.NET.WebClient/tests/chart-surface.spec.ts b/Computator.NET.WebClient/tests/chart-surface.spec.ts index 613b80b2..13b81c03 100644 --- a/Computator.NET.WebClient/tests/chart-surface.spec.ts +++ b/Computator.NET.WebClient/tests/chart-surface.spec.ts @@ -23,10 +23,24 @@ test('surface uses real WebGL rotation, zoom, labels and exported PNG without a return route.fulfill({ json: grid, headers }); }); await page.goto('/'); await edit(page, 'x*x+y*y'); + const hasWebGL = await page.evaluate(() => { + try { + const probe = document.createElement('canvas'); + return Boolean(probe.getContext('webgl') || probe.getContext('webgl2')); + } catch { return false; } + }); const before = page.waitForRequest('**/api/v2/chart'); await page.getByRole('button', { name: 'Draw chart!' }).click(); await before; const surface = page.getByTestId('surface-chart'); await expect(surface).toBeVisible(); + if (!hasWebGL) { + // Software-only headless Firefox (not a production Firefox installation) + // must fail visibly, never claim a working 3D scene or PNG export. + await expect(page.getByRole('alert')).toContainText('WebGL is unavailable.'); + await expect(page.getByRole('button', { name: 'Download PNG' })).toBeDisabled(); + expect(requests).toHaveLength(1); + return; + } await expect(page.getByText('Interactive surface ready.', { exact: false })).toBeVisible({ timeout: 20000 }); await expect.poll(() => surface.locator('canvas').count()).toBeGreaterThan(0); expect(requests).toHaveLength(1); diff --git a/docs/followups/WEB-3D-SURFACES.md b/docs/followups/WEB-3D-SURFACES.md index 81106864..43664be3 100644 --- a/docs/followups/WEB-3D-SURFACES.md +++ b/docs/followups/WEB-3D-SURFACES.md @@ -32,6 +32,11 @@ at undefined points, tooltips, responsive resize without reevaluation, and PNG export of the composed view. In environments without functional WebGL, the application reports a rendering error instead of claiming to draw a surface. The client validates array shape/counts before passing data to Plotly. +A functional WebGL context is a prerequisite: if disabled or unavailable +(e.g. a software-only headless Firefox runner), the app displays a clear +rendering error and disables image export instead of showing a fake 3D +success state. Windows Chromium and Firefox real-API E2E verify interactive +camera changes and PNG export when WebGL is available. Alternatives considered: server-side WinForms/WPF screenshots would still be noninteractive and Windows-only; a custom Three.js scene would require separate 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 07/16] 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 08/16] 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 09/16] 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 10/16] 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 11/16] 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 12/16] 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 13/16] 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 14/16] 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 15/16] 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 16/16] 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**.