diff --git a/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs b/Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs index a471e039..905f47cc 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)", 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, + 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/(y-y)", 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; } + } +} 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..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/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.