From 9d19b1c035f58d283d9306efd34308d3dfa33bed Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 3 Nov 2026 21:44:57 +0100 Subject: [PATCH] Add an example page for each object. --- CHANGELOG.md | 3 + tutorials/index.ipynb | 2 + tutorials/objects/aperture2d.ipynb | 98 +++++++++++++++++ tutorials/objects/aperture3d.ipynb | 100 ++++++++++++++++++ tutorials/objects/circle.ipynb | 115 ++++++++++++++++++++ tutorials/objects/cone.ipynb | 71 +++++++++++++ tutorials/objects/cuboid.ipynb | 73 +++++++++++++ tutorials/objects/cylinder.ipynb | 69 ++++++++++++ tutorials/objects/function_object_2d.ipynb | 95 +++++++++++++++++ tutorials/objects/function_object_3d.ipynb | 104 ++++++++++++++++++ tutorials/objects/helix.ipynb | 73 +++++++++++++ tutorials/objects/line.ipynb | 116 +++++++++++++++++++++ tutorials/objects/polygon.ipynb | 93 +++++++++++++++++ tutorials/objects/polyline.ipynb | 98 +++++++++++++++++ tutorials/objects/rectangle.ipynb | 91 ++++++++++++++++ tutorials/objects/slanted_tube.ipynb | 95 +++++++++++++++++ tutorials/objects/sphere.ipynb | 109 +++++++++++++++++++ tutorials/objects/torus.ipynb | 94 +++++++++++++++++ tutorials/objects/triangle.ipynb | 91 ++++++++++++++++ tutorials/objects/tube.ipynb | 90 ++++++++++++++++ 20 files changed, 1680 insertions(+) create mode 100644 tutorials/objects/aperture2d.ipynb create mode 100644 tutorials/objects/aperture3d.ipynb create mode 100644 tutorials/objects/circle.ipynb create mode 100644 tutorials/objects/cone.ipynb create mode 100644 tutorials/objects/cuboid.ipynb create mode 100644 tutorials/objects/cylinder.ipynb create mode 100644 tutorials/objects/function_object_2d.ipynb create mode 100644 tutorials/objects/function_object_3d.ipynb create mode 100644 tutorials/objects/helix.ipynb create mode 100644 tutorials/objects/line.ipynb create mode 100644 tutorials/objects/polygon.ipynb create mode 100644 tutorials/objects/polyline.ipynb create mode 100644 tutorials/objects/rectangle.ipynb create mode 100644 tutorials/objects/slanted_tube.ipynb create mode 100644 tutorials/objects/sphere.ipynb create mode 100644 tutorials/objects/torus.ipynb create mode 100644 tutorials/objects/triangle.ipynb create mode 100644 tutorials/objects/tube.ipynb diff --git a/CHANGELOG.md b/CHANGELOG.md index 67896b6..c7c76ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- One example page per object of `simgeometry.objects`, from `Line` to `Helix` + (`tutorials/objects/`, the Objects group of the Examples): how to build it, membership, + level set and a figure. - `Helix(start, radius, pitch, turns, wire_radius, axis=..., left_handed=...)`: a helical wire of round cross-section with hemispherical ends, e.g. the winding of an RF coil. Its level set is the distance from the centreline (Newton steps along it from the diff --git a/tutorials/index.ipynb b/tutorials/index.ipynb index 0ed604f..bd23794 100644 --- a/tutorials/index.ipynb +++ b/tutorials/index.ipynb @@ -21,6 +21,8 @@ "9. [Boundary outlines](/simgeometry/examples/boundary-outlines/) turns 2D shapes into vertex loops and panels for boundary-element solvers, and finds where particle steps cross a boundary.\n", "10. [Conductors and level sets](/simgeometry/examples/conductors-and-level-sets/) builds an Einzel lens from ring electrodes with voltages, reads Dirichlet values on a grid, and uses level sets for exact distances and edge crossings.\n", "\n", + "The Objects pages below show each shape of `simgeometry.objects` on its own, from `Line` in 1D to `Helix` in 3D: how to build it, what it contains, its level set and how it looks.\n", + "\n", "See [Building the docs](/simgeometry/development/docs/) to add an example." ] } diff --git a/tutorials/objects/aperture2d.ipynb b/tutorials/objects/aperture2d.ipynb new file mode 100644 index 0000000..e406bf5 --- /dev/null +++ b/tutorials/objects/aperture2d.ipynb @@ -0,0 +1,98 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Aperture2D\n", + "\n", + "Two plates mirrored about a beam axis: a slit, or the 2D cut through a round aperture.\n", + "\n", + "`Aperture2D(axis, position, vertices)` takes the profile of one plate as `(axial, radial)`\n", + "vertices, the coordinate along the beam and the distance from the centre line at\n", + "`position`; the second plate is its mirror image. `Aperture2D.slit` builds the common\n", + "rectangular plates." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import Aperture2D\n", + "from simgeometry.crossings import crossings\n", + "\n", + "slit = Aperture2D.slit(axial_position=0.3, half_width=0.1, thickness=0.05, length=0.4)\n", + "# a knife edge with a narrower bore: thin at the bore, thicker outside\n", + "knife = Aperture2D(\n", + " axis=0,\n", + " position=0.0,\n", + " vertices=[(0.8, 0.05), (0.84, 0.05), (0.95, 0.5), (0.7, 0.5)],\n", + ")\n", + "print(\n", + " \"on the axis:\", slit.contains((0.3, 0.0)), \" in a plate:\", slit.contains((0.3, 0.2))\n", + ")\n", + "assert not slit.contains((0.3, 0.0)) and slit.contains((0.3, -0.2))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## A beam through both apertures\n", + "\n", + "Steps along the beam axis (x) at different heights: the slit stops those with $|y| > 0.1$,\n", + "the knife edge those with $|y| > 0.05$ that got through the slit." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "y = np.linspace(-0.425, 0.425, 18)\n", + "start = np.stack((np.zeros_like(y), y))\n", + "end = np.stack((np.ones_like(y), y))\n", + "first = crossings(slit, start, end)\n", + "end_after_slit = np.where(first.hit, first.point, end)\n", + "second = crossings(knife, start, end_after_slit)\n", + "stopped = np.where(second.hit, second.point, end_after_slit)\n", + "assert first.hit.sum() == np.sum(np.abs(y) > 0.1)\n", + "assert second.hit.sum() == np.sum((np.abs(y) > 0.05) & (np.abs(y) < 0.1))\n", + "\n", + "fig, ax = plt.subplots(figsize=(6, 3.6), layout=\"constrained\")\n", + "for shape, color in ((slit, \"tab:blue\"), (knife, \"tab:green\")):\n", + " for loop in shape.outlines():\n", + " ax.fill(*loop, color=color, alpha=0.5)\n", + "for i in range(len(y)):\n", + " ax.plot([start[0, i], stopped[0, i]], [y[i], y[i]], color=\"tab:orange\", lw=1)\n", + "ax.axhline(0.0, color=\"0.5\", lw=0.8, ls=\"--\")\n", + "ax.set(xlabel=\"x (beam axis)\", ylabel=\"y\", title=\"A slit and a knife-edge aperture\")\n", + "ax.set_aspect(\"equal\")\n", + "plt.show()" + ] + } + ], + "metadata": { + "docs_order": 116, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/aperture3d.ipynb b/tutorials/objects/aperture3d.ipynb new file mode 100644 index 0000000..26cc7bc --- /dev/null +++ b/tutorials/objects/aperture3d.ipynb @@ -0,0 +1,100 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Aperture3D\n", + "\n", + "A plate with a round opening along a coordinate axis: the extraction or acceleration grid of an\n", + "ion source, a skimmer or a beam-limiting aperture.\n", + "\n", + "`Aperture3D(axis, center_coords, vertices, lower_bound, upper_bound)` takes the opening's\n", + "radius along the axis as `(axial, radius)` vertices, interpolated linearly between them, and\n", + "the box the plate fills. It is inverted by default: the solid is the material around the\n", + "opening, and nothing outside the box. It has no level set, so crossings are sampled." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "from simgeometry import Aperture3D\n", + "\n", + "# a 0.2 thick plate whose bore narrows from radius 0.4 to 0.2\n", + "grid_plate = Aperture3D(\n", + " axis=\"z\",\n", + " center_coords=(0.0, 0.0),\n", + " vertices=[(0.0, 0.4), (0.2, 0.2)],\n", + " lower_bound=(-1.0, -1.0, 0.0),\n", + " upper_bound=(1.0, 1.0, 0.2),\n", + ")\n", + "print(\"on the axis:\", grid_plate.contains((0.0, 0.0, 0.1)))\n", + "print(\"radius 0.3 at the front:\", grid_plate.contains((0.3, 0.0, 0.01)))\n", + "print(\"radius 0.3 at the back:\", grid_plate.contains((0.3, 0.0, 0.19)))\n", + "assert not grid_plate.contains((0.3, 0.0, 0.01)) and grid_plate.contains(\n", + " (0.3, 0.0, 0.19)\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Section and surface\n", + "\n", + "The section `y = 0` shows the tapered bore through the plate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "ax = grid_plate.show(y=0.0, resolution=200, display=False)\n", + "ax.set_title(\"The section y = 0 of an Aperture3D\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "plotter = grid_plate.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=96, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A square plate with a tapered round hole\")" + ] + } + ], + "metadata": { + "docs_order": 129, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/circle.ipynb b/tutorials/objects/circle.ipynb new file mode 100644 index 0000000..84b0160 --- /dev/null +++ b/tutorials/objects/circle.ipynb @@ -0,0 +1,115 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Circle\n", + "\n", + "A disc of a given radius: the cross-section of a wire, a rod or a round electrode.\n", + "\n", + "`Circle(midpoint, radius)` has an exact level set, the signed distance\n", + "`|p - midpoint| - radius`, so distances, closest points and normals are analytic." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import Circle, Grid\n", + "\n", + "rod = Circle(midpoint=(0.0, 0.0), radius=0.5)\n", + "points = np.array([[0.0, 0.4, 1.0], [0.0, 0.0, 1.0]]) # (2, n): one point per column\n", + "print(\"inside:\", rod.contains_points(points))\n", + "print(\"signed distance:\", rod.signed_distance(points))\n", + "print(\"closest point to (1, 1):\", rod.closest_point((1.0, 1.0)))\n", + "print(\"normal at (0.5, 0):\", rod.surface_normal((0.5, 0.0)))\n", + "assert np.isclose(rod.signed_distance((1.0, 1.0)), np.sqrt(2) - 0.5)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Distance field and node mask\n", + "\n", + "The level set sampled around the rod: its contours are circles, one per distance. The grid\n", + "nodes inside the rod are those `node_mask` selects." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(-1.0, 1.0, 201)\n", + "X, Y = np.meshgrid(x, x, indexing=\"ij\")\n", + "distance = rod.signed_distance(np.stack((X.ravel(), Y.ravel()))).reshape(X.shape)\n", + "\n", + "grid = Grid(2, (-1.0, -1.0), (1.0, 1.0), (21, 21))\n", + "mask = rod.node_mask(grid).ravel()\n", + "nodes = grid.node_points()\n", + "\n", + "fig, ax = plt.subplots(figsize=(5.5, 4.5), layout=\"constrained\")\n", + "image = ax.contourf(X, Y, distance, levels=np.linspace(-0.5, 1.0, 16), cmap=\"RdBu_r\")\n", + "ax.contour(X, Y, distance, levels=[0.0], colors=\"k\")\n", + "ax.scatter(*nodes[:, mask], s=8, color=\"k\", label=\"nodes inside\")\n", + "fig.colorbar(image, ax=ax, label=\"signed distance\")\n", + "ax.set(xlabel=\"x\", ylabel=\"y\", title=\"The level set of a Circle\")\n", + "ax.set_aspect(\"equal\")\n", + "ax.legend(loc=\"upper right\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## Outlines and inversion\n", + "\n", + "`outlines(max_spacing)` gives the boundary as a counter-clockwise vertex loop, for\n", + "boundary-element solvers and for plotting. An inverted circle is everything outside it: the\n", + "wall of a round chamber." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "(loop,) = rod.outlines(max_spacing=0.05)\n", + "print(\"outline vertices:\", loop.shape[1])\n", + "\n", + "chamber = Circle(midpoint=(0.0, 0.0), radius=0.5, inverted=True)\n", + "assert np.array_equal(chamber.node_mask(grid).ravel(), ~mask)\n", + "assert np.isclose(chamber.signed_distance((1.0, 1.0)), -rod.signed_distance((1.0, 1.0)))" + ] + } + ], + "metadata": { + "docs_order": 111, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/cone.ipynb b/tutorials/objects/cone.ipynb new file mode 100644 index 0000000..bb3530d --- /dev/null +++ b/tutorials/objects/cone.ipynb @@ -0,0 +1,71 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Cone\n", + "\n", + "A cone or a frustum along any axis: nozzles, tips and tapered electrodes.\n", + "\n", + "`Cone(start, end, start_radius, end_radius)` has radius `start_radius` at `start` and\n", + "`end_radius` at `end`; one of them may be zero, which puts the tip there. The level set is the\n", + "exact signed distance." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from simgeometry import Cone, GeometryCollection\n", + "\n", + "tip = Cone(start=(0.0, 0.0, 0.0), end=(0.0, 0.0, 1.0), start_radius=0.5, end_radius=0.0)\n", + "frustum = Cone(\n", + " start=(1.5, 0.0, 0.0), end=(1.5, 0.0, 1.0), start_radius=0.5, end_radius=0.25\n", + ")\n", + "print(\"volumes:\", round(tip.volume(), 4), round(frustum.volume(), 4))\n", + "print(\"distance from the tip:\", tip.signed_distance((0.0, 0.0, 1.5)))\n", + "assert np.isclose(tip.volume(), np.pi * 0.5**2 / 3)\n", + "assert np.isclose(tip.signed_distance((0.0, 0.0, 1.5)), 0.5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "cones = GeometryCollection({\"cone\": tip, \"frustum\": frustum})\n", + "plotter = cones.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=64, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A cone and a frustum side by side\")" + ] + } + ], + "metadata": { + "docs_order": 125, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/cuboid.ipynb b/tutorials/objects/cuboid.ipynb new file mode 100644 index 0000000..21260a4 --- /dev/null +++ b/tutorials/objects/cuboid.ipynb @@ -0,0 +1,73 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Cuboid\n", + "\n", + "An axis-aligned box in 3D, optionally with rounded edges and corners: blocks, plates and\n", + "chamber walls.\n", + "\n", + "`Cuboid(midpoint, side_lengths, fillet=r)` rounds the twelve edges and eight corners with\n", + "radius `r`, up to half the shortest side. The level set is the exact signed distance with or\n", + "without the fillet." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from simgeometry import Cuboid, GeometryCollection\n", + "\n", + "block = Cuboid(midpoint=(0.0, 0.0, 0.0), side_lengths=(2.0, 1.0, 0.4))\n", + "rounded = Cuboid(midpoint=(0.0, 1.5, 0.0), side_lengths=(2.0, 1.0, 0.4), fillet=0.15)\n", + "print(\"volume:\", block.volume(), \" rounded:\", round(rounded.volume(), 4))\n", + "print(\n", + " \"corner inside:\",\n", + " block.contains((0.99, 0.49, 0.19)),\n", + " rounded.contains((0.99, 1.99, 0.19)),\n", + ")\n", + "assert np.isclose(block.signed_distance((1.5, 0.0, 0.0)), 0.5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "blocks = GeometryCollection({\"sharp\": block, \"fillet 0.15\": rounded})\n", + "plotter = blocks.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=64, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A box with sharp edges and one with rounded edges\")" + ] + } + ], + "metadata": { + "docs_order": 122, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/cylinder.ipynb b/tutorials/objects/cylinder.ipynb new file mode 100644 index 0000000..af98f0f --- /dev/null +++ b/tutorials/objects/cylinder.ipynb @@ -0,0 +1,69 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Cylinder\n", + "\n", + "A solid circular cylinder along any axis: rods, posts and feedthroughs.\n", + "\n", + "`Cylinder(start, end, radius)` runs from the centre of one end face to the other; `fillet`\n", + "rounds the two circular edges. The level set is the exact signed distance, that of the\n", + "profile in the half-plane of axial position and radius." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from simgeometry import Cylinder\n", + "\n", + "rod = Cylinder(start=(0.0, 0.0, 0.0), end=(2.0, 0.0, 1.0), radius=0.3, fillet=0.1)\n", + "print(\"length:\", rod.length, \" volume:\", round(rod.volume(), 4))\n", + "print(\"on the axis:\", rod.contains((1.0, 0.0, 0.5)))\n", + "side = (1.0, 1.0, 0.5) # 1 from the middle of the axis, normal to it\n", + "print(\"distance from\", np.round(side, 3), \":\", rod.signed_distance(side))\n", + "assert np.isclose(rod.length, np.sqrt(5))\n", + "assert np.isclose(rod.signed_distance(side), 0.7)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "plotter = rod.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=64, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A tilted cylinder with rounded edges\")" + ] + } + ], + "metadata": { + "docs_order": 123, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/function_object_2d.ipynb b/tutorials/objects/function_object_2d.ipynb new file mode 100644 index 0000000..268349f --- /dev/null +++ b/tutorials/objects/function_object_2d.ipynb @@ -0,0 +1,95 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# FunctionObject2D\n", + "\n", + "A 2D shape defined by a Python function: anything that has a membership test but no primitive.\n", + "\n", + "`FunctionObject2D(func, lower_bound, upper_bound)` calls `func((x, y))` for each point and\n", + "needs a bounding box, which cannot be derived from a function. It has no level set, so\n", + "distances and crossings are found by sampling its membership. Prefer a primitive or a Boolean\n", + "combination of primitives where one fits." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import FunctionObject2D, Grid\n", + "from simgeometry.crossings import crossings\n", + "\n", + "\n", + "def flower(coords):\n", + " x, y = coords\n", + " return np.hypot(x, y) < 0.8 + 0.25 * np.cos(5 * np.arctan2(y, x))\n", + "\n", + "\n", + "shape = FunctionObject2D(flower, lower_bound=(-1.05, -1.05), upper_bound=(1.05, 1.05))\n", + "print(\"centre inside:\", shape.contains((0.0, 0.0)), \" level set:\", shape.has_level_set)\n", + "assert shape.contains((1.0, 0.0)) and not shape.contains((0.0, 1.0))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Node mask and sampled crossings\n", + "\n", + "The grid nodes inside, and the first crossing of steps from the centre outward. The crossings\n", + "are sampled along each step and refined by bisection (`exact` is false)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "grid = Grid(2, (-1.1, -1.1), (1.1, 1.1), (45, 45))\n", + "mask = shape.node_mask(grid).ravel()\n", + "nodes = grid.node_points()\n", + "\n", + "angles = np.linspace(0, 2 * np.pi, 24, endpoint=False)\n", + "start = np.zeros((2, angles.size))\n", + "end = 1.2 * np.stack((np.cos(angles), np.sin(angles)))\n", + "hits = crossings(shape, start, end)\n", + "print(\"all steps leave the shape:\", bool(hits.hit.all()), \" exact:\", hits.exact)\n", + "\n", + "fig, ax = plt.subplots(figsize=(5, 5), layout=\"constrained\")\n", + "ax.scatter(*nodes[:, mask], s=6, color=\"tab:purple\", label=\"nodes inside\")\n", + "for i in range(angles.size):\n", + " ax.plot(*np.stack((start[:, i], hits.point[:, i]), axis=1), color=\"0.6\", lw=0.8)\n", + "ax.plot(*hits.point, \"o\", color=\"tab:red\", ms=4, label=\"crossings\")\n", + "ax.set(xlabel=\"x\", ylabel=\"y\", title=\"A FunctionObject2D\")\n", + "ax.set_aspect(\"equal\")\n", + "ax.legend(loc=\"upper right\")\n", + "plt.show()" + ] + } + ], + "metadata": { + "docs_order": 117, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/function_object_3d.ipynb b/tutorials/objects/function_object_3d.ipynb new file mode 100644 index 0000000..1ff38c9 --- /dev/null +++ b/tutorials/objects/function_object_3d.ipynb @@ -0,0 +1,104 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# FunctionObject3D\n", + "\n", + "A 3D shape defined by a Python function: anything that has a membership test but no primitive.\n", + "\n", + "`FunctionObject3D(func, lower_bound, upper_bound)` calls `func((x, y, z))` for each point and\n", + "needs a bounding box. It has no level set and no volume: crossings and distances are found by\n", + "sampling its membership. Prefer a primitive where one fits (a torus is `Torus`, a ring\n", + "electrode `Tube`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "from simgeometry import FunctionObject3D, Grid\n", + "\n", + "\n", + "def rounded_cube(coords):\n", + " x, y, z = coords\n", + " return x**4 + y**4 + z**4 <= 1.0\n", + "\n", + "\n", + "shape = FunctionObject3D(\n", + " rounded_cube, lower_bound=(-1.0, -1.0, -1.0), upper_bound=(1.0, 1.0, 1.0)\n", + ")\n", + "print(\"corner of the cube inside:\", shape.contains((0.9, 0.9, 0.9)))\n", + "print(\"level set:\", shape.has_level_set)\n", + "assert shape.contains((0.8, 0.0, 0.0)) and not shape.contains((0.9, 0.9, 0.9))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Volume from a node mask\n", + "\n", + "Without a `volume()`, count the nodes inside: the superellipsoid $x^4 + y^4 + z^4 \\le 1$ has\n", + "the volume $8\\,\\Gamma(5/4)^3 / \\Gamma(7/4) \\approx 6.48$. The surface below is also drawn from\n", + "sampled membership, which leaves faint terraces." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "from math import gamma\n", + "\n", + "n = 41\n", + "grid = Grid(3, (-1.0, -1.0, -1.0), (1.0, 1.0, 1.0), (n, n, n))\n", + "estimate = shape.node_mask(grid).sum() * (2.0 / (n - 1)) ** 3\n", + "exact = 8 * gamma(5 / 4) ** 3 / gamma(7 / 4)\n", + "print(f\"node count: {estimate:.3f}, exact: {exact:.3f}\")\n", + "assert abs(estimate / exact - 1) < 0.05" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "plotter = shape.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=96, show_bounds=False\n", + ")\n", + "plasma_plots.scene(\n", + " plotter, alt=\"A cube with rounded edges, the superellipsoid of order four\"\n", + ")" + ] + } + ], + "metadata": { + "docs_order": 130, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/helix.ipynb b/tutorials/objects/helix.ipynb new file mode 100644 index 0000000..14f8427 --- /dev/null +++ b/tutorials/objects/helix.ipynb @@ -0,0 +1,73 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Helix\n", + "\n", + "A helical wire of round cross-section: an RF coil, a spring or a helical electrode.\n", + "\n", + "`Helix(start, radius, pitch, turns, wire_radius)` winds `turns` times around `axis` at\n", + "`radius`, advancing `pitch` per turn, right-handed unless `left_handed=True`. Its ends are\n", + "hemispherical caps. The level set is the exact signed distance within a wire radius of the\n", + "wire and a lower bound farther away (`level_set_exact` is false), so `signed_distance`\n", + "measures the distance to a sampled surface mesh instead." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from simgeometry import Helix\n", + "\n", + "coil = Helix(start=(0.0, 0.0, 0.0), radius=0.5, pitch=0.25, turns=4, wire_radius=0.05)\n", + "print(\"axial length:\", coil.length, \" wire length:\", round(coil.wire_length, 4))\n", + "print(\"start of the wire inside:\", coil.contains((0.5, 0.0, 0.0)))\n", + "print(\"axis inside:\", coil.contains((0.0, 0.0, 0.5)))\n", + "assert np.isclose(coil.wire_length, 4 * np.hypot(2 * np.pi * 0.5, 0.25))\n", + "# half a turn in, the centreline is at (-0.5, 0, 0.125); 0.02 outward from it:\n", + "level = coil.level_set(np.array([[-0.52], [0.0], [0.125]]))\n", + "print(f\"level set 0.02 from the centreline: {float(level[0]):.4f}\")\n", + "assert np.isclose(float(level[0]), 0.02 - 0.05)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "plotter = coil.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=96, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A helical wire of four turns\")" + ] + } + ], + "metadata": { + "docs_order": 128, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/line.ipynb b/tutorials/objects/line.ipynb new file mode 100644 index 0000000..7b79e3e --- /dev/null +++ b/tutorials/objects/line.ipynb @@ -0,0 +1,116 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Line\n", + "\n", + "An interval of the real line: a wall, an electrode or an absorber in a 1D domain.\n", + "\n", + "`Line(midpoint, side_lengths)` covers `midpoint ± side_lengths / 2`. Both are one-element\n", + "sequences, as for `Rectangle` and `Cuboid` in two and three dimensions. Points are `(1, n)`\n", + "arrays, component-major like every query in simgeometry." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import Grid, Line\n", + "\n", + "wall = Line(midpoint=(0.6,), side_lengths=(0.25,))\n", + "print(\"from\", float(wall.lower_bound[0]), \"to\", float(wall.upper_bound[0]))\n", + "print(\"x = 0.6 inside:\", wall.contains((0.6,)))\n", + "print(\"points inside:\", wall.contains_points(np.array([[0.1, 0.5, 0.7, 0.9]])))\n", + "assert wall.contains((0.6,)) and not wall.contains((0.2,))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Node mask and level set\n", + "\n", + "`node_mask(grid)` marks the grid nodes inside the line. Its level set is the exact signed\n", + "distance to the interval: negative inside, zero at both ends, growing with slope one outside." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "grid = Grid(1, (0.0,), (1.0,), (21,))\n", + "mask = wall.node_mask(grid)\n", + "nodes = grid.node_points()[0]\n", + "print(\"nodes inside:\", nodes[mask])\n", + "\n", + "x = np.linspace(0.0, 1.0, 401)\n", + "distance = wall.signed_distance(x[None, :])\n", + "assert np.isclose(wall.signed_distance((0.2,)), 0.6 - 0.125 - 0.2)\n", + "\n", + "fig, ax = plt.subplots(figsize=(6, 3), layout=\"constrained\")\n", + "ax.axvspan(wall.lower_bound[0], wall.upper_bound[0], color=\"tab:orange\", alpha=0.25)\n", + "ax.plot(x, distance, label=\"signed distance\")\n", + "ax.axhline(0.0, color=\"0.5\", lw=0.8)\n", + "ax.plot(\n", + " nodes[~mask], np.zeros((~mask).sum()), \"o\", mfc=\"none\", color=\"0.5\", label=\"nodes\"\n", + ")\n", + "ax.plot(\n", + " nodes[mask], np.zeros(mask.sum()), \"o\", color=\"tab:orange\", label=\"nodes inside\"\n", + ")\n", + "ax.set(xlabel=\"x\", ylabel=\"signed distance\", title=\"A Line and its level set\")\n", + "ax.legend(loc=\"upper center\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## Inverted lines\n", + "\n", + "`inverted=True` covers everything except the interval, for example the walls on both sides of\n", + "a gap. The node mask is the complement and the level set changes sign." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "gap = Line(midpoint=(0.6,), side_lengths=(0.25,), inverted=True)\n", + "assert np.array_equal(gap.node_mask(grid), ~mask)\n", + "assert np.isclose(gap.signed_distance((0.2,)), -wall.signed_distance((0.2,)))\n", + "print(\"walls at x = 0.2:\", gap.contains((0.2,)))" + ] + } + ], + "metadata": { + "docs_order": 101, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/polygon.ipynb b/tutorials/objects/polygon.ipynb new file mode 100644 index 0000000..6727d22 --- /dev/null +++ b/tutorials/objects/polygon.ipynb @@ -0,0 +1,93 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Polygon\n", + "\n", + "A closed polygon through any number of vertices: electrode profiles, wall cross-sections and\n", + "other shapes made of straight edges.\n", + "\n", + "`Polygon(vertices)` decides membership by the even-odd rule, so a self-intersecting polygon\n", + "has holes where its edges overlap. Its level set is the exact signed distance to the nearest\n", + "edge, at a cost of one pass per edge." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import Polygon\n", + "\n", + "# an L-shaped electrode and a five-pointed star drawn in one stroke\n", + "electrode = Polygon(\n", + " [(0.0, 0.0), (1.0, 0.0), (1.0, 0.3), (0.3, 0.3), (0.3, 1.0), (0.0, 1.0)]\n", + ")\n", + "angles = np.pi / 2 + 4 * np.pi / 5 * np.arange(5)\n", + "star = Polygon(\n", + " np.stack((2.0 + 0.5 * np.cos(angles), 0.5 + 0.5 * np.sin(angles)), axis=1)\n", + ")\n", + "\n", + "print(\"in the notch of the L:\", electrode.contains((0.6, 0.6)))\n", + "print(\"distance from (0.6, 0.6):\", electrode.signed_distance((0.6, 0.6)))\n", + "print(\"star centre inside:\", star.contains((2.0, 0.5))) # covered twice: a hole\n", + "print(\"star point inside:\", star.contains((2.0, 0.9)))\n", + "assert not star.contains((2.0, 0.5)) and star.contains((2.0, 0.9))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Membership and level set\n", + "\n", + "The level set sampled around both polygons, with the zero contour drawn in black: the pentagon\n", + "in the middle of the star is outside." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(-0.2, 2.7, 291)\n", + "y = np.linspace(-0.2, 1.2, 141)\n", + "X, Y = np.meshgrid(x, y, indexing=\"ij\")\n", + "points = np.stack((X.ravel(), Y.ravel()))\n", + "distance = np.minimum(electrode.signed_distance(points), star.signed_distance(points))\n", + "\n", + "fig, ax = plt.subplots(figsize=(7, 3.6), layout=\"constrained\")\n", + "image = ax.contourf(X, Y, distance.reshape(X.shape), levels=15, cmap=\"RdBu_r\")\n", + "ax.contour(X, Y, distance.reshape(X.shape), levels=[0.0], colors=\"k\")\n", + "fig.colorbar(image, ax=ax, label=\"signed distance\")\n", + "ax.set(xlabel=\"x\", ylabel=\"y\", title=\"Two Polygons by the even-odd rule\")\n", + "ax.set_aspect(\"equal\")\n", + "plt.show()" + ] + } + ], + "metadata": { + "docs_order": 114, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/polyline.ipynb b/tutorials/objects/polyline.ipynb new file mode 100644 index 0000000..2f24fc6 --- /dev/null +++ b/tutorials/objects/polyline.ipynb @@ -0,0 +1,98 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Polyline\n", + "\n", + "An open curve of zero thickness through a list of vertices: a thin plate, a wire or a grid\n", + "electrode.\n", + "\n", + "`Polyline(vertices)` encloses no area, so `contains` is always false and it takes no part in\n", + "Boolean shapes. Its geometry is its outline, one open curve from the first vertex to the last,\n", + "which `boundary_panels` and `crossings` use directly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import Polyline\n", + "from simgeometry.crossings import crossings\n", + "\n", + "deflector = Polyline([(0.0, 0.3), (0.5, 0.7), (1.0, 0.3)])\n", + "(curve,) = deflector.outlines()\n", + "panels = deflector.boundary_panels(max_spacing=0.1)\n", + "print(\"closed:\", deflector.closed, \" vertices:\", curve.shape[1])\n", + "print(\"panels:\", len(panels), \" total length:\", panels.length.sum())\n", + "assert not deflector.contains((0.5, 0.5))\n", + "assert np.isclose(panels.length.sum(), 2 * np.hypot(0.5, 0.4))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Particle steps\n", + "\n", + "A particle step that passes through the curve stops on it: `crossings` returns, for each step\n", + "from `start` to `end`, whether it hits, where, and the normal there. Here vertical steps rise\n", + "from below the deflector." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(-0.2, 1.2, 15)\n", + "start = np.stack((x, np.zeros_like(x)))\n", + "end = np.stack((x, np.ones_like(x)))\n", + "hits = crossings(deflector, start, end)\n", + "print(\"steps stopped:\", int(hits.hit.sum()), \"of\", len(x), \" analytic:\", hits.exact)\n", + "\n", + "fig, ax = plt.subplots(figsize=(5.5, 4), layout=\"constrained\")\n", + "for i in range(len(x)):\n", + " stop = hits.point[:, i] if hits.hit[i] else end[:, i]\n", + " ax.plot(*np.stack((start[:, i], stop), axis=1), color=\"0.7\", lw=1)\n", + "ax.plot(*curve, color=\"k\", lw=2, label=\"Polyline\")\n", + "ax.plot(*hits.point[:, hits.hit], \"o\", color=\"tab:red\", label=\"crossings\")\n", + "ax.quiver(\n", + " *hits.point[:, hits.hit],\n", + " *hits.normal[:, hits.hit],\n", + " color=\"tab:red\",\n", + " scale=14,\n", + " width=0.005,\n", + ")\n", + "ax.set(xlabel=\"x\", ylabel=\"y\", title=\"Steps stopped by a Polyline\")\n", + "ax.set_aspect(\"equal\")\n", + "ax.legend(loc=\"lower center\")\n", + "plt.show()" + ] + } + ], + "metadata": { + "docs_order": 115, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/rectangle.ipynb b/tutorials/objects/rectangle.ipynb new file mode 100644 index 0000000..ee6c143 --- /dev/null +++ b/tutorials/objects/rectangle.ipynb @@ -0,0 +1,91 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Rectangle\n", + "\n", + "An axis-aligned box in 2D, optionally with rounded corners: plates, electrodes and walls.\n", + "\n", + "`Rectangle(midpoint, side_lengths, fillet=r)` rounds the four corners with radius `r`, up to\n", + "half the shorter side, where it becomes a stadium. Rounded corners keep the surface field of an\n", + "electrode bounded. With or without the fillet, the level set is the exact signed distance." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import Rectangle\n", + "\n", + "plates = {\n", + " \"sharp\": Rectangle(midpoint=(0.0, 0.0), side_lengths=(2.0, 0.6)),\n", + " \"fillet 0.15\": Rectangle(midpoint=(0.0, 0.0), side_lengths=(2.0, 0.6), fillet=0.15),\n", + " \"stadium\": Rectangle(midpoint=(0.0, 0.0), side_lengths=(2.0, 0.6), fillet=0.3),\n", + "}\n", + "corner = (0.97, 0.27)\n", + "for name, plate in plates.items():\n", + " print(f\"{name:12s} near the corner inside: {plate.contains(corner)!s:5s}\", end=\" \")\n", + " print(f\"distance from (1.2, 0.5): {plate.signed_distance((1.2, 0.5)):.4f}\")\n", + "assert np.isclose(plates[\"sharp\"].signed_distance((1.2, 0.5)), np.hypot(0.2, 0.2))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Outlines and offset contours\n", + "\n", + "Each rectangle with its outline and the contours of its level set at 0, 0.15 and 0.3 outside:\n", + "the offset contours of an exact distance are rounded even around sharp corners." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(-1.6, 1.6, 321)\n", + "y = np.linspace(-0.9, 0.9, 181)\n", + "X, Y = np.meshgrid(x, y, indexing=\"ij\")\n", + "points = np.stack((X.ravel(), Y.ravel()))\n", + "\n", + "fig, axes = plt.subplots(3, 1, figsize=(6, 6.5), layout=\"constrained\", sharex=True)\n", + "for ax, (name, plate) in zip(axes, plates.items(), strict=True):\n", + " distance = plate.signed_distance(points).reshape(X.shape)\n", + " ax.contour(X, Y, distance, levels=[0.15, 0.3], colors=\"0.6\", linewidths=0.8)\n", + " (loop,) = plate.outlines(max_spacing=0.02)\n", + " ax.fill(*loop, color=\"tab:blue\", alpha=0.4)\n", + " ax.plot(*np.c_[loop, loop[:, :1]], color=\"tab:blue\")\n", + " ax.set(ylabel=\"y\", title=name)\n", + " ax.set_aspect(\"equal\")\n", + "axes[-1].set_xlabel(\"x\")\n", + "plt.show()" + ] + } + ], + "metadata": { + "docs_order": 112, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/slanted_tube.ipynb b/tutorials/objects/slanted_tube.ipynb new file mode 100644 index 0000000..3b21362 --- /dev/null +++ b/tutorials/objects/slanted_tube.ipynb @@ -0,0 +1,95 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# SlantedTube\n", + "\n", + "A tube or rod whose far end is cut by a slanted plane, like the mouth of an oven nozzle cut\n", + "at 45°.\n", + "\n", + "`SlantedTube(start, end, inner_radius, outer_radius, cut_normal)` has a flat face at `start`\n", + "and a cut through `end` with the outward normal `cut_normal`; `inner_radius=0` gives a solid\n", + "rod. Its level set is a bound on the distance, exact on the faces, and its crossings are\n", + "analytic." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import SlantedTube\n", + "\n", + "c = np.cos(np.pi / 4)\n", + "nozzle = SlantedTube(\n", + " (0.0, 0.0, 0.0), (0.0, 0.0, 1.0), 0.15, 0.2, cut_normal=(0.0, -c, c)\n", + ")\n", + "print(f\"cut angle: {nozzle.cut_angle:.0f} deg, volume: {nozzle.volume():.4f}\")\n", + "print(\"wall at y = +0.175, z = 1.1 (long side):\", nozzle.contains((0.0, 0.175, 1.1)))\n", + "print(\"wall at y = -0.175, z = 1.1 (short side):\", nozzle.contains((0.0, -0.175, 1.1)))\n", + "assert nozzle.contains((0.0, 0.175, 1.1)) and not nozzle.contains((0.0, -0.175, 1.1))" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## The section through the cut\n", + "\n", + "The section `x = 0` shows the wall on both sides of the bore and the 45° mouth." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "ax = nozzle.show(x=0.0, resolution=200, display=False)\n", + "ax.set_title(\"The section x = 0 of a SlantedTube\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "plotter = nozzle.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=72, opacity=0.6, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A tube whose end is cut at 45 degrees\")" + ] + } + ], + "metadata": { + "docs_order": 127, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/sphere.ipynb b/tutorials/objects/sphere.ipynb new file mode 100644 index 0000000..daa30be --- /dev/null +++ b/tutorials/objects/sphere.ipynb @@ -0,0 +1,109 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Sphere\n", + "\n", + "A ball of a given radius: a probe tip, a dust grain or a spherical electrode.\n", + "\n", + "`Sphere(midpoint, radius)` has an exact level set, `|p - midpoint| - radius`, and analytic\n", + "crossings." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from simgeometry import Grid, Sphere\n", + "\n", + "ball = Sphere(midpoint=(0.0, 0.0, 0.0), radius=1.0)\n", + "print(\"volume:\", ball.volume())\n", + "print(\"distance from (2, 0, 0):\", ball.signed_distance((2.0, 0.0, 0.0)))\n", + "assert np.isclose(ball.volume(), 4 / 3 * np.pi)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Node mask\n", + "\n", + "The nodes inside the ball, times the cell volume, approach its volume as the grid is refined." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "for n in (11, 21, 41, 81):\n", + " grid = Grid(3, (-1.0, -1.0, -1.0), (1.0, 1.0, 1.0), (n, n, n))\n", + " cell = np.prod([2.0 / (n - 1)] * 3)\n", + " estimate = ball.node_mask(grid).sum() * cell\n", + " print(\n", + " f\"{n:3d}^3 nodes: {estimate:.4f} (error {estimate / ball.volume() - 1:+.2%})\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## In 3D\n", + "\n", + "`show_3d(backend=\"pyvista\")` draws the surface with plasma-plots; `points=` adds labelled point\n", + "sets, here the nodes of a coarse grid inside the ball." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "grid = Grid(3, (-1.0, -1.0, -1.0), (1.0, 1.0, 1.0), (13, 11, 9))\n", + "inside = grid.node_points()[:, ball.node_mask(grid).ravel()]\n", + "plotter = ball.show_3d(\n", + " backend=\"pyvista\",\n", + " display=False,\n", + " resolution=64,\n", + " opacity=0.3,\n", + " show_bounds=False,\n", + " points={\"nodes inside\": inside},\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A translucent sphere with the grid nodes inside it\")" + ] + } + ], + "metadata": { + "docs_order": 121, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/torus.ipynb b/tutorials/objects/torus.ipynb new file mode 100644 index 0000000..a6afcdc --- /dev/null +++ b/tutorials/objects/torus.ipynb @@ -0,0 +1,94 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Torus\n", + "\n", + "A solid torus: the points within `minor_radius` of a circle of `major_radius`, the shape of a\n", + "tokamak or stellarator vessel, or of a ring-shaped wire.\n", + "\n", + "`Torus(midpoint, major_radius, minor_radius, axis=...)` puts the circle in the plane normal to\n", + "`axis`. The level set is the exact signed distance." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from simgeometry import Torus\n", + "\n", + "vessel = Torus(midpoint=(0.0, 0.0, 0.0), major_radius=1.0, minor_radius=0.3)\n", + "print(\"volume:\", round(vessel.volume(), 4))\n", + "print(\"hole empty:\", not vessel.contains((0.0, 0.0, 0.0)))\n", + "print(\"distance from the centre:\", vessel.signed_distance((0.0, 0.0, 0.0)))\n", + "assert np.isclose(vessel.volume(), 2 * np.pi**2 * 1.0 * 0.3**2)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Sections\n", + "\n", + "`show(y=0)` draws the section through the axis: the two discs of the tube." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "ax = vessel.show(y=0.0, resolution=200, display=False)\n", + "ax.set_title(\"The section y = 0 of a Torus\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "tilted = Torus(\n", + " midpoint=(0.0, 0.0, 0.0), major_radius=1.0, minor_radius=0.3, axis=(1.0, 0.0, 1.0)\n", + ")\n", + "plotter = tilted.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=64, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A torus with its axis tilted by 45 degrees\")" + ] + } + ], + "metadata": { + "docs_order": 126, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/triangle.ipynb b/tutorials/objects/triangle.ipynb new file mode 100644 index 0000000..31618fe --- /dev/null +++ b/tutorials/objects/triangle.ipynb @@ -0,0 +1,91 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Triangle\n", + "\n", + "A triangle from three vertices, in either orientation.\n", + "\n", + "`Triangle(vertices)` has an exact level set, the signed distance to its edges, and an exact\n", + "outline: its three corners, counter-clockwise." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from simgeometry import Grid, Triangle\n", + "\n", + "wedge = Triangle([(0.0, 0.0), (1.0, 0.0), (0.3, 0.8)])\n", + "print(\"centroid inside:\", wedge.contains((0.43, 0.27)))\n", + "print(\"distance from (0.5, -0.2):\", wedge.signed_distance((0.5, -0.2)))\n", + "assert np.isclose(wedge.signed_distance((0.5, -0.2)), 0.2)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Nodes, outline and normals\n", + "\n", + "The grid nodes inside, the outline loop and the outward normals at points on the boundary from\n", + "`surface_normal`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "grid = Grid(2, (-0.1, -0.1), (1.1, 0.9), (25, 21))\n", + "mask = wedge.node_mask(grid).ravel()\n", + "nodes = grid.node_points()\n", + "(loop,) = wedge.outlines()\n", + "panels = wedge.boundary_panels(max_spacing=0.15)\n", + "normals = wedge.surface_normal(panels.midpoint)\n", + "\n", + "fig, ax = plt.subplots(figsize=(5.5, 4.5), layout=\"constrained\")\n", + "ax.scatter(*nodes[:, ~mask], s=6, color=\"0.75\")\n", + "ax.scatter(*nodes[:, mask], s=8, color=\"tab:blue\", label=\"nodes inside\")\n", + "ax.plot(*np.c_[loop, loop[:, :1]], color=\"k\")\n", + "ax.quiver(\n", + " *panels.midpoint,\n", + " *normals,\n", + " color=\"tab:red\",\n", + " scale=12,\n", + " width=0.005,\n", + " label=\"outward normals\",\n", + ")\n", + "ax.set(xlabel=\"x\", ylabel=\"y\", title=\"A Triangle on a grid\")\n", + "ax.set_aspect(\"equal\")\n", + "ax.legend(loc=\"upper right\")\n", + "plt.show()" + ] + } + ], + "metadata": { + "docs_order": 113, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/objects/tube.ipynb b/tutorials/objects/tube.ipynb new file mode 100644 index 0000000..7bcc588 --- /dev/null +++ b/tutorials/objects/tube.ipynb @@ -0,0 +1,90 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Tube\n", + "\n", + "A hollow cylinder between two radii: a ring electrode when short, a drift tube or a chamber\n", + "wall when long.\n", + "\n", + "`Tube(start, end, inner_radius, outer_radius)` has an empty bore and an exact level set;\n", + "`fillet` rounds its four circular edges." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from simgeometry import Cuboid, PolarRepeat, Tube\n", + "\n", + "ring = Tube(\n", + " start=(0.0, 0.0, -0.2),\n", + " end=(0.0, 0.0, 0.2),\n", + " inner_radius=1.0,\n", + " outer_radius=1.5,\n", + " fillet=0.05,\n", + ")\n", + "print(\"bore empty:\", not ring.contains((0.0, 0.0, 0.0)))\n", + "print(\"in the wall:\", ring.contains((1.25, 0.0, 0.0)))\n", + "print(\"distance from the centre:\", ring.signed_distance((0.0, 0.0, 0.0)))\n", + "assert np.isclose(ring.signed_distance((0.0, 0.0, 0.0)), 1.0)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## A slotted shield\n", + "\n", + "`PolarRepeat(shape, count)` repeats a shape about an axis at the cost of one copy. Cutting\n", + "twelve slots out of a long tube gives a Faraday shield." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "import plasma_plots\n", + "import pyvista as pv\n", + "\n", + "pv.OFF_SCREEN = True # render without a window, e.g. on a docs server\n", + "\n", + "wall = Tube(\n", + " start=(0.0, 0.0, -1.0), end=(0.0, 0.0, 1.0), inner_radius=0.9, outer_radius=1.0\n", + ")\n", + "slot = Cuboid(midpoint=(0.95, 0.0, 0.0), side_lengths=(0.3, 0.12, 1.5))\n", + "shield = wall - PolarRepeat(slot, 12)\n", + "assert not shield.contains((0.95, 0.0, 0.0)) and shield.contains((0.95, 0.0, 0.9))\n", + "plotter = shield.show_3d(\n", + " backend=\"pyvista\", display=False, resolution=96, show_bounds=False\n", + ")\n", + "plasma_plots.scene(plotter, alt=\"A tube with twelve axial slots cut into its wall\")" + ] + } + ], + "metadata": { + "docs_order": 124, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}