diff --git a/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx b/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx
new file mode 100644
index 00000000..aed194cc
--- /dev/null
+++ b/Computator.NET.WebClient/ClientApp/components/SurfacePlot.tsx
@@ -0,0 +1,185 @@
+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 = {
+ 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 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 {
+ // 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;
+ 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: { ...initialEye } }
+ }
+ }, { 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 => {
+ 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);
+ } catch (reason) {
+ if (!disposed) setError(reason instanceof Error ? reason.message : '3D rendering is unavailable.');
+ }
+ };
+ void start();
+ return () => {
+ disposed = true;
+ resize?.disconnect();
+ if (wheelHandler) div.removeEventListener('wheel', wheelHandler);
+ 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..98c2a5f8
--- /dev/null
+++ b/Computator.NET.WebClient/ClientApp/helpers/plotly-gl3d.d.ts
@@ -0,0 +1,13 @@
+// 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;
+ 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 };
+ }
+ const Plotly: PlotlyGl3d;
+ export default Plotly;
+}
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..a6dec194
--- /dev/null
+++ b/Computator.NET.WebClient/e2e/surface-real.spec.ts
@@ -0,0 +1,58 @@
+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 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.wheel(0, -500);
+ await expect.poll(eye).not.toEqual(rotated);
+ 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/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",
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..13b81c03
--- /dev/null
+++ b/Computator.NET.WebClient/tests/chart-surface.spec.ts
@@ -0,0 +1,147 @@
+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 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);
+ 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 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.wheel(0, -400);
+ 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;
+ 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/Computator.NET.slnx b/Computator.NET.slnx
index 77c6a057..cd08e103 100644
--- a/Computator.NET.slnx
+++ b/Computator.NET.slnx
@@ -316,6 +316,7 @@
+
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/build/mono/SmokeTests/Program.cs b/build/mono/SmokeTests/Program.cs
index 6b5ccdeb..e7448b86 100644
--- a/build/mono/SmokeTests/Program.cs
+++ b/build/mono/SmokeTests/Program.cs
@@ -8,6 +8,7 @@
using System.Linq;
using System.Numerics;
using System.Reflection;
+using System.Runtime.InteropServices;
using System.Text;
using System.Windows.Forms;
using System.Xml;
@@ -103,7 +104,9 @@ 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);
+ Run("native-mono-3d-common-grid-parity-contract", Native3DCommonGridContract);
WriteResults();
var failed = Results.Count(result => result.Failure != null);
var skipped = Results.Count(result => result.SkippedReason != null);
@@ -401,21 +404,264 @@ private static void ComplexChart()
}
}
- private static void Unsupported3D()
+ private static void Native3D()
{
- using (var chart = new Chart3DControl { Width = 600, Height = 200 })
+ using (var chart = new Chart3DControl { Width = 700, Height = 480, Quality = 20 })
+ using (var window = new Form
{
- Check(chart is Control, "Mono 3D placeholder must be WinForms.");
- var function = new ExpressionsEvaluator().Evaluate("x+y", "", CalculationsMode.Fxy);
- Expect(() => chart.AddFunction(function));
- Expect(() => chart.GetImage(640, 400));
- var exportPath = Path.Combine(outputDirectory, "unsupported-3d-must-not-exist.png");
- Expect(() => chart.SaveImage(exportPath, ImageFormat.Png));
- Check(!File.Exists(exportPath), "Unsupported 3D must not report a successful blank export.");
- Capture(chart, "unsupported-3d.png");
+ ClientSize = new Size(700, 480),
+ StartPosition = FormStartPosition.Manual,
+ Location = new Point(120, 100),
+ Text = "Mono native 3D acceptance"
+ })
+ {
+ Check(chart is Control, "Mono chart must be a real WinForms control.");
+ chart.SetChartAreaValues(-2, 2, -2, 2);
+ chart.AddFunction(new ExpressionsEvaluator().Evaluate("x²+y²", "", CalculationsMode.Fxy));
+ Check(chart.FiniteSurfaceSamples == 33 * 33, "Paraboloid must be evaluated on the expected bounded grid.");
+ using (var export = chart.GetImage(700, 480))
+ {
+ Check(export.Width == 700 && export.Height == 480, "3D export must match requested dimensions.");
+ AssertImageHasVariation(export, 60);
+ export.Save(Path.Combine(outputDirectory, "mono-3d-paraboloid.png"), ImageFormat.Png);
+ }
+
+ window.Controls.Add(chart);
+ window.Show();
+ window.Activate();
+ Application.DoEvents();
+ Check(chart.Visible && chart.IsHandleCreated && chart.Focus(), "3D chart must have a visible, focusable X11 window.");
+ chart.Refresh();
+ Application.DoEvents();
+
+ // Xvfb screenshots use actual on-screen pixels, NOT DrawToBitmap
+ // or the offscreen export. This catches invisible WinForms children.
+ using (var before = CaptureVisibleChart(chart, "mono-3d-visible-before.png"))
+ {
+ AssertImageHasVariation(before, 60);
+ var startAngle = chart.YawDegrees;
+ DragOnRealX11(chart, 90, 25);
+ Application.DoEvents();
+ Check(Math.Abs(chart.YawDegrees - startAngle) > 1, "XTEST pointer drag did not rotate the live 3D camera.");
+ chart.Refresh();
+ Application.DoEvents();
+ using (var after = CaptureVisibleChart(chart, "mono-3d-visible-rotated.png"))
+ {
+ AssertImageHasVariation(after, 60);
+ Check(DifferentPixels(before, after) > 200,
+ "Visible X11 chart pixels did not change after the rotation input.");
+ }
+ }
+
+ // Export must retain an actual right-button pan, not silently reset
+ // the camera to its unpanned center.
+ using (var beforePanExport = new Bitmap(chart.GetImage(700, 480)))
+ using (var beforePanScreen = CaptureVisibleChart(chart, "mono-3d-visible-prepan.png"))
+ {
+ DragOnRealX11(chart, 80, 36, 3);
+ chart.Refresh();
+ Application.DoEvents();
+ using (var afterPanScreen = CaptureVisibleChart(chart, "mono-3d-visible-panned.png"))
+ using (var afterPanExport = new Bitmap(chart.GetImage(700, 480)))
+ {
+ Check(DifferentPixels(beforePanScreen, afterPanScreen) > 200,
+ "XTEST right-button drag did not pan the native on-screen chart.");
+ Check(DifferentPixels(beforePanExport, afterPanExport) > 200,
+ "Export discarded the native 3D chart's current pan position.");
+ afterPanExport.Save(Path.Combine(outputDirectory, "mono-3d-panned-export.png"), ImageFormat.Png);
+ }
+ }
+
+ var exportPath = Path.Combine(outputDirectory, "mono-3d-export.png");
+ chart.SaveImage(exportPath, ImageFormat.Png);
+ Check(File.Exists(exportPath) && new FileInfo(exportPath).Length > 4000,
+ "Native 3D PNG export did not write chart pixels.");
+ using (var image = Image.FromFile(exportPath))
+ AssertImageHasVariation(image, 60);
+
+ Expect(() => chart.GetImage(5000, 300));
+ Expect(() => chart.SetChartAreaValues(2, -2, -1, 1));
+ window.Close();
+ Application.DoEvents();
+ }
+ }
+
+
+ private static void Native3DCommonGridContract()
+ {
+ // Same 33x33 [-2,2] golden grid as WebApi.IntegrationTests/ChartApiTests.
+ // Record the actual coordinates passed by the native sampler, not just
+ // the public finite-sample counter or a separately rendered picture.
+ const int gridSize = 33;
+ var evaluations = new List>();
+ using (var chart = new Chart3DControl { Width = 640, Height = 440, Quality = 20 })
+ {
+ chart.SetChartAreaValues(-2, 2, -2, 2);
+ var paraboloid = new Computator.NET.DataTypes.Functions.Function(
+ new Func((x, y) =>
+ {
+ var height = x*x + y*y;
+ evaluations.Add(Tuple.Create(x, y, height));
+ return height;
+ }), Computator.NET.DataTypes.Functions.FunctionType.Real3D);
+ chart.AddFunction(paraboloid);
+ Check(chart.FiniteSurfaceSamples == gridSize * gridSize,
+ "Native common-grid paraboloid must have 1,089 finite samples.");
+ Check(evaluations.Count == gridSize * gridSize,
+ "Native common-grid paraboloid did not evaluate each vertex exactly once.");
+ for (var row = 0; row < gridSize; row++)
+ for (var column = 0; column < gridSize; column++)
+ {
+ var actual = evaluations[row * gridSize + column];
+ var expectedX = -2 + column / 8.0;
+ var expectedY = -2 + row / 8.0;
+ Near(actual.Item1, expectedX, 1e-12);
+ Near(actual.Item2, expectedY, 1e-12);
+ Near(actual.Item3, expectedX*expectedX + expectedY*expectedY, 1e-12);
+ }
+ }
+
+ using (var chart = new Chart3DControl { Width = 640, Height = 440, Quality = 20 })
+ {
+ chart.SetChartAreaValues(-2, 2, -2, 2);
+ var evaluator = new ExpressionsEvaluator();
+ var wave = evaluator.Evaluate("sin(x)*cos(y)", "", CalculationsMode.Fxy);
+ chart.AddFunction(wave);
+ Check(chart.FiniteSurfaceSamples == gridSize * gridSize,
+ "Shared sin/cos evaluator unexpectedly produced invalid sample values.");
+ Near(wave.Evaluate(-2, -2), Math.Sin(-2) * Math.Cos(-2), 1e-12);
+ Near(wave.Evaluate(0, 0), 0, 1e-12);
+ chart.ClearAll();
+ chart.AddFunction(evaluator.Evaluate("1/(x-y)", "", CalculationsMode.Fxy));
+ Check(chart.FiniteSurfaceSamples == gridSize * gridSize - gridSize,
+ "Exactly 33 singular diagonal points must be omitted, matching Web JSON null holes.");
+ }
+ }
+
+ private static void Native3DEdges()
+ {
+ using (var chart = new Chart3DControl { Width = 640, Height = 440, Quality = 20 })
+ {
+ chart.SetChartAreaValues(-2, 2, -2, 2);
+ var evaluator = new ExpressionsEvaluator();
+ var function = evaluator.Evaluate("x+y", "", CalculationsMode.Fxy);
+ chart.AddFunction(function);
+ Check(chart.FiniteSurfaceSamples == 33 * 33, "Plane samples were not retained.");
+ var custom = evaluator.Evaluate("MonoSurface(x,y)",
+ "static real MonoSurface(real x, real y) { return x*x + y*y + 1; }",
+ CalculationsMode.Fxy);
+ chart.AddFunction(custom);
+ Check(chart.FiniteSurfaceSamples == 2 * 33 * 33,
+ "Explicit unsaved custom f(x,y) function was not evaluated.");
+ chart.Quality = 0;
+ Check(chart.FiniteSurfaceSamples == 2 * 17 * 17, "Quality did not resample existing functions.");
+ chart.Quality = 20;
+ chart.SetChartAreaValues(-1, 1, -1, 1);
+ Check(chart.FiniteSurfaceSamples == 2 * 33 * 33, "Bounds update lost chart functions.");
+ chart.AddPoints(new[]
+ {
+ new Computator.NET.DataTypes.Charts.Point3D(-1, -1, 1),
+ new Computator.NET.DataTypes.Charts.Point3D(0, 0, 0),
+ new Computator.NET.DataTypes.Charts.Point3D(1, 1, 1)
+ });
+ Check(chart.Mode == Computator.NET.DataTypes.Charts.Chart3DMode.Points,
+ "Adding point series should enter the points chart mode.");
+ using (var image = chart.GetImage(640, 440))
+ {
+ AssertImageHasVariation(image, 40);
+ image.Save(Path.Combine(outputDirectory, "mono-3d-points.png"), ImageFormat.Png);
+ }
+ chart.ClearAll();
+ Check(chart.FiniteSurfaceSamples == 0, "ClearAll must release sampled surfaces.");
+ }
+
+ using (var chart = new Chart3DControl { Width = 640, Height = 440, Quality = 20 })
+ {
+ chart.SetChartAreaValues(-2, 2, -2, 2);
+ chart.AddFunction(new ExpressionsEvaluator().Evaluate("1/(x-y)", "", CalculationsMode.Fxy));
+ Check(chart.FiniteSurfaceSamples > 0 && chart.FiniteSurfaceSamples < 33 * 33,
+ "Undefined diagonal must become holes, not zero-height vertices.");
+ using (var image = chart.GetImage(640, 440))
+ AssertImageHasVariation(image, 30);
+ chart.ClearAll();
+ chart.AddFunction(new ExpressionsEvaluator().Evaluate("1/(y-y)", "", CalculationsMode.Fxy));
+ Check(chart.FiniteSurfaceSamples == 0, "Entirely undefined surface cannot invent finite values.");
+ using (var image = chart.GetImage(640, 440))
+ AssertImageHasVariation(image, 8);
+ chart.ClearAll();
+ chart.AddFunction(new ExpressionsEvaluator().Evaluate("x*y", "", CalculationsMode.Fxy));
+ Check(chart.FiniteSurfaceSamples == 33 * 33, "A singular plot must not poison subsequent redraws.");
+ Expect(() =>
+ chart.AddFunction(new ExpressionsEvaluator().Evaluate("x²", "", CalculationsMode.Real)));
}
}
+ // SendKeys.SendWait does not reliably dispatch focused key events on Mono.
+ // Drive actual X11 pointer/button events through XTEST and observe the live
+ // WinForms viewer's camera as well as screen pixels, not a synthetic handler.
+ [DllImport("libX11.so.6")]
+ private static extern IntPtr XOpenDisplay(IntPtr displayName);
+ [DllImport("libX11.so.6")]
+ private static extern int XCloseDisplay(IntPtr display);
+ [DllImport("libX11.so.6")]
+ private static extern int XSync(IntPtr display, int discard);
+ [DllImport("libXtst.so.6")]
+ private static extern int XTestFakeMotionEvent(IntPtr display, int screen, int x, int y, ulong delay);
+ [DllImport("libXtst.so.6")]
+ private static extern int XTestFakeButtonEvent(IntPtr display, uint button, int press, ulong delay);
+
+ private static void DragOnRealX11(Control control, int dx, int dy, uint button = 1)
+ {
+ var origin = control.PointToScreen(new Point(control.ClientSize.Width / 2, control.ClientSize.Height / 2));
+ var display = XOpenDisplay(IntPtr.Zero);
+ Check(display != IntPtr.Zero, "X11 display is required for native interactive chart acceptance.");
+ try
+ {
+ Check(XTestFakeMotionEvent(display, 0, origin.X, origin.Y, 0) != 0,
+ "XTEST could not move the pointer into the chart.");
+ XSync(display, 0);
+ Application.DoEvents();
+ Check(XTestFakeButtonEvent(display, button, 1, 0) != 0,
+ "XTEST could not press the requested mouse button.");
+ XSync(display, 0);
+ Application.DoEvents();
+ for (var i = 1; i <= 6; i++)
+ {
+ Check(XTestFakeMotionEvent(display, 0, origin.X + dx * i / 6,
+ origin.Y + dy * i / 6, 0) != 0, "XTEST drag was rejected.");
+ XSync(display, 0);
+ Application.DoEvents();
+ }
+ Check(XTestFakeButtonEvent(display, button, 0, 0) != 0,
+ "XTEST could not release the requested mouse button.");
+ XSync(display, 0);
+ Application.DoEvents();
+ }
+ finally { XCloseDisplay(display); }
+ }
+
+ private static Bitmap CaptureVisibleChart(Control control, string name)
+ {
+ var rectangle = control.RectangleToScreen(control.ClientRectangle);
+ Check(rectangle.Width > 100 && rectangle.Height > 100 && control.Visible,
+ "3D chart is not a usable visible native control.");
+ var screenshot = new Bitmap(rectangle.Width, rectangle.Height);
+ using (var graphics = Graphics.FromImage(screenshot))
+ graphics.CopyFromScreen(rectangle.Location, Point.Empty, rectangle.Size);
+ screenshot.Save(Path.Combine(outputDirectory, name), ImageFormat.Png);
+ return screenshot;
+ }
+
+ private static int DifferentPixels(Bitmap before, Bitmap after)
+ {
+ Check(before.Size == after.Size, "Rotation changed native chart dimensions.");
+ var changed = 0;
+ for (var x = 0; x < before.Width; x += 3)
+ for (var y = 0; y < before.Height; y += 3)
+ if (before.GetPixel(x, y).ToArgb() != after.GetPixel(x, y).ToArgb()) changed++;
+ return changed;
+ }
+
private static void Capture(Control control, string name)
{
using (var image = new Bitmap(control.Width, control.Height))
diff --git a/build/mono/validate.sh b/build/mono/validate.sh
index b85b35e0..1ff9d17f 100644
--- a/build/mono/validate.sh
+++ b/build/mono/validate.sh
@@ -54,7 +54,10 @@ mcs -nologo -nowarn:0414 -r:System.Drawing -out:"$validation_temp/LegacyFixture.
mono "$validation_temp/LegacyFixture.exe" "$validation_temp/legacy-settings.dat"
# A failed or timed-out run must not expose an old passing report/screenshot.
rm -f -- "$results/mono-results.xml" "$results/startup.png" "$results/desktop-workflow.png" \
- "$results/real-chart.png" "$results/complex-chart.png" "$results/unsupported-3d.png" "$results/function-reference.png" \
+ "$results/real-chart.png" "$results/complex-chart.png" "$results/function-reference.png" \
+ "$results/mono-3d-paraboloid.png" "$results/mono-3d-visible-before.png" \
+ "$results/mono-3d-visible-rotated.png" "$results/mono-3d-export.png" \
+ "$results/mono-3d-points.png" \
"$results/calculator-inputs.png" "$results/numerical-inputs.png"
status=0
COMPUTATOR_DATA_DIRECTORY="$validation_temp/startup" \
diff --git a/docs/MONO-DESKTOP.md b/docs/MONO-DESKTOP.md
index 1feb7672..3a9e4723 100644
--- a/docs/MONO-DESKTOP.md
+++ b/docs/MONO-DESKTOP.md
@@ -21,11 +21,11 @@ On Ubuntu 24.04, install the GUI and native runtime prerequisites:
```sh
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
- mono-devel libgdiplus libgsl-dev fonts-dejavu-core xvfb xauth
+ mono-devel libgdiplus libgsl-dev libxtst6 fonts-dejavu-core xvfb xauth
```
`libgsl-dev` supplies the unversioned library name used by Mono's P/Invoke loader.
-`libgdiplus` is used by Framework Mono's System.Drawing implementation. Xvfb and
+`libgdiplus` is used by Framework Mono's System.Drawing implementation. `libxtst6` supplies the XTEST input extension client used by the real-pointer 3D GUI acceptance. Xvfb and
xauth are needed for headless acceptance; normal interactive use requires an X11
display, or an XWayland display on a Wayland desktop.
@@ -97,7 +97,7 @@ ordinary Windows build.
| UI | Framework Mono WinForms/System.Drawing | Microsoft WinForms and WPF |
| 2D charts | Existing `libraries/net46/DataVisualizationX.dll` | Existing WinForms.DataVisualization package |
| Complex charts | Existing managed sampling, domain coloring and System.Drawing renderer | Same chart source |
-| 3D charts | Visible unsupported control; chart generation/export operations reject the request | Existing WPF implementation |
+| 3D charts | Bounded WinForms/System.Drawing software surface and point renderer with camera input, axes and PNG export | Existing WPF implementation |
| Code editor | Existing TROKA/ICSharpCode TextEditor | Existing Scintilla, AvalonEdit and TextEditor options |
| Function reference | Readable text from the existing reference, including equation alternative text | Existing HTML reference rendering |
| Native GSL | Host's `libgsl` via Mono P/Invoke | Existing validated Windows loader and package/fallback policy |
@@ -170,9 +170,33 @@ numerical method or input as correct.
### Desktop behavior
-The unsupported 3D panel remains visible when that mode is selected. It does not
-silently return an empty image or pretend that a WPF chart was rendered. Real
-two-variable calculations remain separate from the unavailable 3D visualization.
+Native Mono 3D uses the existing explicit real f(x,y) evaluator and presents a
+WinForms software-rendered surface. It does not load WPF or require a GPU or
+embedded browser. 3D charts are interactive on X11: **left-drag** rotates,
+**mouse wheel** zooms, **right-drag** pans, **arrow keys** rotate, **+/-** zoom
+and **Home** resets the camera. The plot includes labeled x/y/z numeric axes.
+Camera changes and resize render cached samples without recompilation.
+Both visible painting and PNG export preserve rotation, zoom and right-drag pan;
+when exporting at another size, the pan offsets are scaled with the output viewport.
+
+Sampling is synchronous and capped at **17–97 vertices per axis** (Quality 0–100),
+**eight functions per chart**, **20,000 points per series** and **40,000 points total**.
+The chart supports finite explicit real height surfaces, including compiled
+user-defined two-argument functions, color-coded depth-sorted triangulation,
+missing-value holes, point series, image export and normal edit/plot/print menus.
+Invalid bounds and oversized exports fail explicitly rather than allocating
+unbounded images. All-nonfinite surfaces display a diagnostic rather than a
+misleading zero-height mesh. Sampling executes trusted local TSL without process
+isolation; slow user functions can still block the UI while being sampled.
+
+Limitations: this renderer uses a painter's algorithm, not a hardware z-buffer.
+Overlapping/intersecting surfaces can have incorrect triangle ordering; performance
+is limited compared with WebGL/WPF for dense meshes. Implicit F(x,y,z)=0 surfaces,
+lighting, surface mouse picking and true hardware acceleration are not supported.
+This is **native Linux/Mono** only; the separately developed WebGL browser viewer
+in [PR #294](https://github.com/PawelTroka/Computator.NET/pull/294) is not reused
+or bundled. The complex-chart defect [#295](https://github.com/PawelTroka/Computator.NET/issues/295)
+remains a separate workstream.
Only TextEditor is offered on Mono. Its settings value remains `2`, matching the
Windows edition. Known historical/default editor values are normalized to the
@@ -244,8 +268,12 @@ The restoration was built and exercised on Ubuntu 24.04.3 x64 with .NET SDK
The suite also exercises expressions, scripts, custom functions, current C#
syntax, native GSL calls and recovery, compatibility math edge cases, JSON and
trusted NRBF migration, Unix document safety, visible editor/numerical workflows,
-2D and complex PNG rendering, and explicit 3D rejection. Its window images use
-actual X11 screen pixels; a successful build or a blank `DrawToBitmap` capture is
+2D and complex PNG rendering, plus numerical 3D surfaces, custom functions,
+missing samples, mode switching, clearing, exports and an actual visible X11
+WinForms chart before/after a real XTEST pointer drag. The 3D acceptance retains
+`mono-3d-visible-before.png`, `mono-3d-visible-rotated.png`,
+`mono-3d-paraboloid.png`, `mono-3d-export.png` and `mono-3d-points.png`.
+Its on-screen captures use actual X11 pixels; a successful offscreen render or blank `DrawToBitmap` capture is
not used as evidence of usable UI.
A clean hosted run also exposed an assembly-discovery ordering bug: filename
diff --git a/docs/followups/WEB-3D-SURFACES.md b/docs/followups/WEB-3D-SURFACES.md
new file mode 100644
index 00000000..43664be3
--- /dev/null
+++ b/docs/followups/WEB-3D-SURFACES.md
@@ -0,0 +1,80 @@
+# 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, 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.
+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
+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.