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);