diff --git a/.github/workflows/linux-ci.yml b/.github/workflows/linux-ci.yml index 6220ed13..5316378f 100644 --- a/.github/workflows/linux-ci.yml +++ b/.github/workflows/linux-ci.yml @@ -58,6 +58,15 @@ jobs: --logger "trx;LogFileName=CADability.ImportTests.trx" --results-directory TestResults + - name: Run the kernel as WebAssembly + # CADability.dll in the browser runtime of .NET, under Node.js (preinstalled on the runner): fonts from bytes, + # solids, text, pictures, painting through an IPaintTo3D of its own, old project files, import and export. + # The exit code is the number of failed checks. + if: success() || failure() + run: | + dotnet build tests/CADability.WasmSmoke/CADability.WasmSmoke.csproj -c Release + node tests/CADability.WasmSmoke/bin/Release/net10.0/wwwroot/main.mjs + - name: Upload test results if: success() || failure() uses: actions/upload-artifact@v4 diff --git a/tests/CADability.WasmSmoke/CADability.WasmSmoke.csproj b/tests/CADability.WasmSmoke/CADability.WasmSmoke.csproj new file mode 100644 index 00000000..773a153f --- /dev/null +++ b/tests/CADability.WasmSmoke/CADability.WasmSmoke.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + Exe + disable + enable + false + + $(NoWarn);CA1416 + + + + + + + + + + + + + + + + diff --git a/tests/CADability.WasmSmoke/Program.cs b/tests/CADability.WasmSmoke/Program.cs new file mode 100644 index 00000000..62df734c --- /dev/null +++ b/tests/CADability.WasmSmoke/Program.cs @@ -0,0 +1,177 @@ +using CADability; +using CADability.GeoObject; +using System.Reflection; +using Path = System.IO.Path; + +namespace CADability.WasmSmoke +{ + /// + /// Runs CADability.dll as WebAssembly (issue #412): the same checks a browser front end depends on, in the browser runtime + /// of .NET under Node.js, without a frame and without GDI+. Every check prints PASS or FAIL; the exit code is the number + /// of failed checks. + /// + internal static class Program + { + private static int failed; + + private static int Main() + { + Console.WriteLine($"CADability WebAssembly smoke test on {System.Runtime.InteropServices.RuntimeInformation.OSDescription} ({System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture})"); + + Check("the font comes from bytes", () => + { + // there are no system fonts in the browser: the front end supplies the font files + ManagedFontProvider fonts = new ManagedFontProvider(Array.Empty(), false); + fonts.AddFontData(Resource("LiberationSans-Regular.ttf")); + FontProvider.Current = fonts; + }); + + Project project = null; + Check("a project is created", () => + { + project = Project.CreateSimpleProject(); + Expect(FrameImpl.MainFrame == null, "there is no frame"); + }); + Model model = project.GetActiveModel(); + + Check("a solid with a hole is made by a boolean operation", () => + { + Solid box = Make3D.MakeBox(GeoPoint.Origin, 100 * GeoVector.XAxis, 60 * GeoVector.YAxis, 40 * GeoVector.ZAxis); + Solid cylinder = Make3D.MakeCylinder(new GeoPoint(50, 30, -10), 15 * GeoVector.XAxis, 60 * GeoVector.ZAxis); + Solid[] result = Make3D.Difference(box, cylinder); + Expect(result.Length == 1, $"one solid, got {result.Length}"); + double expected = 100 * 60 * 40 - Math.PI * 15 * 15 * 40; + double volume = result[0].Volume(1e-3); + Expect(Math.Abs(volume - expected) < expected * 1e-3, $"volume {volume}, expected {expected}"); + model.Add(result[0]); + }); + + Text text = null; + Check("a text is measured with the font of the kernel", () => + { + text = Text.Construct(); + text.Font = "Liberation Sans"; + text.TextString = "CADability 412"; + text.Location = new GeoPoint(0, 80, 0); + text.LineDirection = GeoVector.XAxis; + text.GlyphDirection = GeoVector.YAxis; + text.TextSize = 10; + BoundingCube extent = text.GetBoundingCube(); + Expect(!extent.IsEmpty && extent.Xmax - extent.Xmin > 50 && extent.Ymax - extent.Ymin > 5, $"the extent of the glyphs, got {extent}"); + Expect(text.GetOutline().Count > 0, "the glyph outlines"); + model.Add(text); + }); + + Picture picture = null; + Check("a picture keeps its image file", () => + { + picture = Picture.Construct(); + picture.Set(Png3x2, new GeoPoint(0, -50, 0), new GeoVector(30, 0, 0), new GeoVector(0, 20, 0)); + Expect((picture.PixelWidth, picture.PixelHeight) == (3, 2), $"3×2 pixels, got {picture.PixelWidth}×{picture.PixelHeight}"); + model.Add(picture); + }); + + Check("the model is painted by a renderer of its own", () => + { + RecordingPaintTo3D renderer = new RecordingPaintTo3D(); + foreach (IGeoObject go in model.AllObjects) go.PrePaintTo3D(renderer); + foreach (IGeoObject go in model.AllObjects) go.PaintTo3D(renderer); + Console.WriteLine($" {renderer.Triangles} triangles, {renderer.Polylines} polylines, {renderer.TextCalls} text calls, {renderer.Bitmaps} bitmaps"); + Expect(renderer.Triangles > 100, "the faces of the solid and the glyphs are triangulated"); + Expect(renderer.Polylines > 10, "the edges are painted"); + Expect(renderer.Extent.Xmin < 1 && renderer.Extent.Xmax > 99 && renderer.Extent.Ymin < -49 && renderer.Extent.Ymax > 85, $"the painted extent, got {renderer.Extent}"); + }); + + Check("the project is saved and read as JSON", () => + { + MemoryStream written = new MemoryStream(); + project.WriteToJson(written); + Model read = Project.ReadFromJson(new MemoryStream(written.ToArray())).GetActiveModel(); + Expect(read.AllObjects.OfType().Count() == 1, "the solid"); + Expect(read.AllObjects.OfType().Single().TextString == "CADability 412", "the text"); + Expect(read.AllObjects.OfType().Single().ImageData.SequenceEqual(Png3x2), "the image file of the picture"); + }); + + Check("a project file of CADability 1.x with pictures opens", () => + { + Project old = Project.ReadFromJson(new MemoryStream(Resource("pictures-1x.cdb.json"))); + Picture[] pictures = old.GetActiveModel().AllObjects.OfType().ToArray(); + Expect(pictures.Length > 0 && pictures.All(p => p.PixelWidth > 0), $"pictures with their image files, got {pictures.Length}"); + }); + + Check("a project file of CADability 1.x with page settings opens", () => + { + Project old = Project.ReadFromJson(new MemoryStream(Resource("pagesettings-1x-net48.cdb.json"))); + Expect(old.DefaultPageSetup.PaperName == "A3" && old.DefaultPageSetup.Landscape, "A3 landscape"); + }); + + Check("a project file with solids opens", () => + { + Project volumes = Project.ReadFromJson(new MemoryStream(Resource("Volumes.cdb.json"))); + Expect(volumes.GetActiveModel().AllObjects.OfType().Any(), "solids"); + }); + + Check("a STEP file is imported", () => + { + string file = Path.Combine(Path.GetTempPath(), "issue101.stp"); + File.WriteAllBytes(file, Resource("issue101.stp")); + Project imported = Project.ReadFromFile(file, "stp"); + Expect(imported.GetActiveModel().AllObjects.Count > 0, "objects"); + }); + + Check("the project is exported to DXF and STL", () => + { + string dxf = Path.Combine(Path.GetTempPath(), "smoke.dxf"); + Expect(project.Export(dxf, "dxf") && new FileInfo(dxf).Length > 0, "the DXF file"); + Project imported = Project.ReadFromFile(dxf, "dxf"); + Expect(imported.GetActiveModel().AllObjects.OfType().Any(), "the text in the DXF file"); + string stl = Path.Combine(Path.GetTempPath(), "smoke.stl"); + project.ExportToSTL(stl); + Expect(new FileInfo(stl).Length > 0, "the STL file"); + }); + + Check("the finalizers run", () => + { + // the GDI version of the FontCache had a finalizer that threw without GDI and ended the process + for (int i = 0; i < 10; ++i) Text.Construct().GetBoundingCube(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + }); + + Console.WriteLine(failed == 0 ? "All checks passed." : $"{failed} check(s) failed."); + return failed; + } + + // a PNG of 3×2 pixels + private static readonly byte[] Png3x2 = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAIAAAASFvFNAAAAG0lEQVR4nAXBAQEAAACCIDe5qbHOCgCHIeSyPCtwBd1HI03nAAAAAElFTkSuQmCC"); + + private static void Check(string name, Action check) + { + try + { + check(); + Console.WriteLine("PASS " + name); + } + catch (Exception e) + { + ++failed; + Console.WriteLine("FAIL " + name); + Console.WriteLine(" " + e.ToString().Replace("\n", "\n ")); + } + } + private static void Expect(bool condition, string what) + { + if (!condition) throw new Exception("expected " + what); + } + private static byte[] Resource(string name) + { + using (Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(name)) + using (MemoryStream copy = new MemoryStream()) + { + stream.CopyTo(copy); + return copy.ToArray(); + } + } + } +} diff --git a/tests/CADability.WasmSmoke/RecordingPaintTo3D.cs b/tests/CADability.WasmSmoke/RecordingPaintTo3D.cs new file mode 100644 index 00000000..456376c8 --- /dev/null +++ b/tests/CADability.WasmSmoke/RecordingPaintTo3D.cs @@ -0,0 +1,158 @@ +using CADability; +using CADability.Attribute; +using CADability.GeoObject; +using System.Drawing; + +namespace CADability.WasmSmoke +{ + /// + /// A renderer of its own, as a browser front end would write one: it implements and records + /// what the kernel paints. The interface still names GDI+ types (, ); a + /// renderer outside Windows implements those members without ever creating a GDI+ object. + /// + internal sealed class RecordingPaintTo3D : IPaintTo3D + { + // the kernel paints glyphs and other objects into display lists once and then paints the lists: a list keeps the + // calls and replays them with the current transformation + private sealed class DisplayList : IPaintTo3DList, IDisposable + { + public readonly List Calls = new List(); + public string Name { get; set; } + public List containedSubLists { set { } } + public void Dispose() { } + } + private readonly Stack openLists = new Stack(); + + /// + /// Keeps the call in the open display list, if there is one, and returns true; false if it is to be recorded now. + /// + private bool IntoList(Action call) + { + if (openLists.Count == 0) return false; + openLists.Peek().Calls.Add(call); + return true; + } + + public int Polylines, PolylinePoints, Triangles, TextCalls, Bitmaps, NurbsCalls, Arcs, PointCalls; + public BoundingCube Extent = BoundingCube.EmptyBoundingCube; + private readonly Stack modOps = new Stack(); + private ModOp current = ModOp.Identity; + + private void Record(GeoPoint p) => Extent.MinMax(current * p); + + public bool PaintSurfaces => true; + public bool PaintEdges => true; + public bool PaintSurfaceEdges { get; set; } = true; + public bool UseLineWidth { get; set; } + public double Precision { get; set; } = 0.01; + public double PixelToWorld => 0.1; + public bool SelectMode { get; set; } + public Color SelectColor { get; set; } + public bool DelayText { get; set; } + public bool DelayAll { get; set; } + public bool TriangulateText { get; set; } = true; + public bool DontRecalcTriangulation { get; set; } + public PaintCapabilities Capabilities => PaintCapabilities.Standard; + public IDisposable FacesBehindEdgesOffset => new DisplayList(); + public bool IsBitmap => false; + + public void MakeCurrent() { } + public void SetColor(Color color, int lockColor = 0) { } + public void AvoidColor(Color color) { } + public void SetLineWidth(LineWidth lineWidth) { } + public void SetLinePattern(LinePattern pattern) { } + public void Polyline(GeoPoint[] points) + { + if (IntoList(() => Polyline(points))) return; + ++Polylines; + PolylinePoints += points.Length; + foreach (GeoPoint p in points) Record(p); + } + public void FilledPolyline(GeoPoint[] points) => Polyline(points); + public void Points(GeoPoint[] points, float size, PointSymbol pointSymbol) + { + if (IntoList(() => Points(points, size, pointSymbol))) return; + ++PointCalls; + foreach (GeoPoint p in points) Record(p); + } + public void Triangle(GeoPoint[] vertex, GeoVector[] normals, int[] indextriples) + { + if (IntoList(() => Triangle(vertex, normals, indextriples))) return; + Triangles += indextriples.Length / 3; + foreach (GeoPoint p in vertex) Record(p); + } + public void PrepareText(string fontName, string textString, FontStyle fontStyle) { } + public void PreparePointSymbol(PointSymbol pointSymbol) { } + public void PrepareIcon(Bitmap icon) { } + public void PrepareBitmap(Bitmap bitmap, int xoffset, int yoffset) { } + public void PrepareBitmap(Bitmap bitmap) { } + public void RectangularBitmap(Bitmap bitmap, GeoPoint location, GeoVector directionWidth, GeoVector directionHeight) + { + if (!IntoList(() => RectangularBitmap(bitmap, location, directionWidth, directionHeight))) ++Bitmaps; + } + public void Text(GeoVector lineDirection, GeoVector glyphDirection, GeoPoint location, string fontName, string textString, FontStyle fontStyle, Text.AlignMode alignment, Text.LineAlignMode lineAlignment) + { + if (IntoList(() => Text(lineDirection, glyphDirection, location, fontName, textString, fontStyle, alignment, lineAlignment))) return; + ++TextCalls; + Record(location); + } + public void List(IPaintTo3DList paintThisList) + { + if (IntoList(() => List(paintThisList))) return; + if (paintThisList is DisplayList list) foreach (Action call in list.Calls) call(); + } + public void SelectedList(IPaintTo3DList paintThisList, int wobbleRadius) { } + public void Nurbs(GeoPoint[] poles, double[] weights, double[] knots, int degree) + { + if (IntoList(() => Nurbs(poles, weights, knots, degree))) return; + ++NurbsCalls; + foreach (GeoPoint p in poles) Record(p); + } + public void Line2D(int sx, int sy, int ex, int ey) { } + public void Line2D(PointF p1, PointF p2) { } + public void FillRect2D(PointF p1, PointF p2) { } + public void Point2D(int x, int y) { } + public void DisplayIcon(GeoPoint p, Bitmap icon) { if (!IntoList(() => DisplayIcon(p, icon))) ++Bitmaps; } + public void DisplayBitmap(GeoPoint p, Bitmap bitmap) { if (!IntoList(() => DisplayBitmap(p, bitmap))) ++Bitmaps; } + public void SetProjection(Projection projection, BoundingCube boundingCube) { } + public void Clear(Color background) { } + public void Resize(int width, int height) { } + public void OpenList(string name = null) => openLists.Push(new DisplayList { Name = name }); + public IPaintTo3DList CloseList() => openLists.Pop(); + public IPaintTo3DList MakeList(List sublists) + { + DisplayList res = new DisplayList(); + foreach (IPaintTo3DList sublist in sublists) res.Calls.Add(() => List(sublist)); + return res; + } + public void OpenPath() { } + public void ClosePath(Color color) { } + public void CloseFigure() { } + public void Arc(GeoPoint center, GeoVector majorAxis, GeoVector minorAxis, double startParameter, double sweepParameter) + { + if (IntoList(() => Arc(center, majorAxis, minorAxis, startParameter, sweepParameter))) return; + ++Arcs; + Record(center); + } + public void FreeUnusedLists() { } + public void UseZBuffer(bool use) { } + public void Blending(bool on) { } + public void FinishPaint() { } + public void PaintFaces(PaintTo3D.PaintMode paintMode) { } + public void Dispose() { } + public void PushState() { } + public void PopState() { } + public void PushMultModOp(ModOp insertion) + { + if (IntoList(() => PushMultModOp(insertion))) return; + modOps.Push(current); + current = current * insertion; + } + public void PopModOp() + { + if (IntoList(PopModOp)) return; + current = modOps.Pop(); + } + public void SetClip(Rectangle clipRectangle) { } + } +} diff --git a/tests/CADability.WasmSmoke/main.mjs b/tests/CADability.WasmSmoke/main.mjs new file mode 100644 index 00000000..a56f8013 --- /dev/null +++ b/tests/CADability.WasmSmoke/main.mjs @@ -0,0 +1,5 @@ +// Starts the smoke test under Node.js: node bin//net10.0/wwwroot/main.mjs (the build copies this file there) +import { dotnet } from './_framework/dotnet.js' + +const exitCode = await dotnet.run(); +process.exit(exitCode);