← all demos

outline_to_mesh

Four outlines, traced and generated, meshed and solved with one pipeline.

uv run python examples/cli.py run outline_to_mesh

Four outlines through one pipeline. Each becomes a planar straight-line graph, is simplified with Douglas-Peucker where it was traced densely (California, cloud), then triangulated by Ruppert's algorithm to a minimum-angle and maximum-area bound. On each mesh the same Poisson problem is solved, the dome of -div(grad u) = 1 with u = 0 on the boundary: tallest where the domain is widest and pinched to zero at every edge and hole. The outlines make different demands. California meshes as disconnected islands; the cloud's boundary follows its true Bezier curves; the gear bore is a hole by the even-odd rule; the star's notches are corners sharper than the bound, which Ruppert meets at the input angle. The inset zooms into California's mesh, which resolves the traced coastline and its offshore islands.
Four outlines through one pipeline. Each becomes a planar straight-line graph, is simplified with Douglas-Peucker where it was traced densely (California, cloud), then triangulated by Ruppert's algorithm to a minimum-angle and maximum-area bound. On each mesh the same Poisson problem is solved, the dome of -div(grad u) = 1 with u = 0 on the boundary: tallest where the domain is widest and pinched to zero at every edge and hole. The outlines make different demands. California meshes as disconnected islands; the cloud's boundary follows its true Bezier curves; the gear bore is a hole by the even-odd rule; the star's notches are corners sharper than the bound, which Ruppert meets at the input angle. The inset zooms into California's mesh, which resolves the traced coastline and its offshore islands.
outline        pts  triangles  min angle
California     570      4008      25
Cloud          124      1981      29
Gear            97      2004      28
Star            10      2000      28

Source

The functions that pose and solve the problem. The figures are below the fold.

"""Four outlines, traced and generated, through one pipeline: simplify the traced
ones with Douglas-Peucker, triangulate with Ruppert's algorithm, and solve a Poisson
problem on each.

`zoo_shapes` gathers the outlines, `mesh_outline` and `dome` handle one each, and `run`
returns an `OutlineStudy` of plain results. Nothing here draws: `figures.py` does that
from the `OutlineStudy`, and this file is what the gallery shows.
"""
import json
from dataclasses import dataclass
from pathlib import Path

import numpy as np

from fem.boundary import Dirichlet
from fem.conditions import Conditions
from fem.loads import Source
from fem.mesh.curves import Circle
from fem.mesh.mesh import Mesh
from fem.mesh.outline import Outline, douglas_peucker
from fem.physics.equations import Poisson
from fem.regions import everywhere


def star_outline(points: int = 5, outer_radius: float = 1.0, inner_radius: float = 0.42,
                 center: tuple[float, float] = (0.0, 0.0)) -> Outline:
    """A `points`-pointed star as a single straight-line loop.

    Radii alternate between `outer_radius` at the tips and `inner_radius` at the notches,
    so the reentrant notches are the sharp corners Ruppert's meets at the input angle
    rather than refining away.
    """
    angles = np.pi / 2 + np.linspace(0, 2 * np.pi, 2 * points, endpoint=False)
    radii = np.where(np.arange(2 * points) % 2 == 0, outer_radius, inner_radius)
    outline = np.column_stack([center[0] + radii * np.cos(angles),
                               center[1] + radii * np.sin(angles)])
    return Outline.from_polygons([outline])


def gear_outline(teeth: int = 12, root_radius: float = 0.7, tooth_height: float = 0.22,
                 tooth_fraction: float = 0.5, bore_radius: float = 0.28,
                 center: tuple[float, float] = (0.0, 0.0)) -> Outline:
    """A spur gear with a circular bore, as two loops (rim and hole).

    Each of `teeth` sectors carries one tooth: the radius steps from `root_radius` out to
    `root_radius + tooth_height` over the middle `tooth_fraction` of the sector and back,
    with radial flanks. The bore is a `Circle`, so an isoparametric solve reads
    a true round hole and refinement rounds it; under the even-odd rule it is a hole in
    the gear rather than a second part.
    """
    tip_radius = root_radius + tooth_height
    pitch = 2 * np.pi / teeth
    gap = 0.5 * (1 - tooth_fraction) * pitch     # root arc either side of each tooth
    outline = []
    for i in range(teeth):
        base = i * pitch
        # (root, base) -> (root, base+gap): the valley; then radial flank up, the tip
        # land, and the next flank down is the following sector's opening edge.
        for radius, angle in ((root_radius, base), (root_radius, base + gap),
                              (tip_radius, base + gap), (tip_radius, base + pitch - gap)):
            outline.append((center[0] + radius * np.cos(angle),
                            center[1] + radius * np.sin(angle)))

    return Outline([Outline.from_polygons([np.array(outline)]).loops[0],
                    Circle(list(center), bore_radius)])

# Resolved against the repo, so a demo does not depend on where it was launched from.
DEFAULT_SVG_FILE = str(Path(__file__).resolve().parents[3] / 'files' / 'california.svg')
CLOUD_SVG_FILE = str(Path(__file__).resolve().parents[3] / 'files' / 'cloud.svg')

# Douglas-Peucker: drop points that deviate less than this fraction of the curve's
# bounding-box extent. Ruppert's cost grows steeply in point count.
DEFAULT_SIMPLIFICATION_TOLERANCE = 0.005

# The Poisson "dome": a unit source pinned to zero on every boundary, so the field is a
# picture of the domain itself, tallest where it is widest.
dome_bc = Conditions(Dirichlet(everywhere(), 0), Source(1.0))
dome_equation = Poisson()


def get_curve_from_svg(svg_file):
    """The longest loop of the SVG, as the ring of points its pieces sample to."""
    longest = max(Outline.from_svg(svg_file).loops, key=len)
    return Outline([longest]).sample().vertices


def close_ring(points):
    """`points` with its first vertex repeated at the end, for plotting.

    A closed SVG path comes back as a ring whose closing edge is implied;
    `ax.plot` needs it spelled out.
    """
    return np.vstack([points, points[:1]])


def curve_extent(curve) -> float:
    """The longer side of `curve`'s bounding box, which the tolerance is a fraction of."""
    return float(max(np.max(curve, axis=0) - np.min(curve, axis=0)))


def simplify_curve(curve, tolerance=DEFAULT_SIMPLIFICATION_TOLERANCE):
    """Simplify `curve` with Douglas-Peucker, `tolerance` a fraction of its extent."""
    return douglas_peucker(curve, tolerance * curve_extent(curve))


def save_curve(curve, save_file='douglas_peucker_output.json'):
    """Write a simplified curve out as JSON, to be read back as an outline later."""
    with open(save_file, 'w') as f:
        json.dump(np.asarray(curve).tolist(), f)


def zoo_shapes(svg_tolerance=DEFAULT_SIMPLIFICATION_TOLERANCE) -> list[tuple[str, Outline]]:
    """The outlines the zoo meshes, as (name, Outline) pairs.

    California and the cloud are traced from `files/*.svg` and simplified on the way in;
    the star and gear are generated (below). Each puts a different demand on the
    mesher: disconnected islands, a curved boundary, sharp reentrant corners, and
    repeated teeth around a circular bore.
    """
    return [
        ('California', Outline.from_svg(DEFAULT_SVG_FILE).simplified(svg_tolerance)),
        ('Cloud', Outline.from_svg(CLOUD_SVG_FILE).simplified(svg_tolerance)),
        ('Gear', gear_outline()),
        ('Star', star_outline()),
    ]


def dome(mesh: Mesh) -> np.ndarray:
    """Solve -div(grad u) = 1 with u = 0 on every boundary of `mesh`."""
    return dome_equation.problem(mesh, dome_bc).solve().dofs


@dataclass
class MeshedOutline:
    """One outline through the pipeline: its name, point count, mesh, and dome."""
    name: str
    n_points: int
    mesh: Mesh
    dofs: np.ndarray

    @property
    def n_triangles(self) -> int:
        return len(self.mesh.elements)

    @property
    def worst_angle(self) -> float:
        """The smallest angle in the mesh (degrees), against the bound Ruppert's was set."""
        return self.mesh.min_angle


@dataclass
class OutlineStudy:
    """Everything `run` computed, for the figure and the table to read."""
    shapes: list[MeshedOutline]
    min_angle: float


def run(min_angle=28, max_area_fraction=0.0008, svg_tolerance=0.001) -> OutlineStudy:
    """Mesh and solve every outline in the zoo.

    `svg_tolerance` is finer than the default so the coastline keeps its detail (the
    raw trace has ~1700 points).
    """
    shapes = []
    for name, outline in zoo_shapes(svg_tolerance):
        graph = outline.sample()
        mesh = graph.mesh(min_angle=min_angle, max_area_fraction=max_area_fraction)
        shapes.append(MeshedOutline(name, len(graph.vertices), mesh, dome(mesh)))
    return OutlineStudy(shapes, min_angle)
The figures, and the demo that assembles them
"""The figure and table of the outline-to-mesh demo, drawn from an `OutlineStudy`."""
import matplotlib.pyplot as plt
import numpy as np
from demo_registry import Demo, DemoResult, Figure
from matplotlib.widgets import Button, Slider

from demos.outline_to_mesh import physics
from demos.outline_to_mesh.physics import (
    DEFAULT_SIMPLIFICATION_TOLERANCE,
    DEFAULT_SVG_FILE,
    OutlineStudy,
    close_ring,
    curve_extent,
    get_curve_from_svg,
    run,
    save_curve,
)
from fem.mesh.outline import douglas_peucker
from fem.plot.helpers import plot_mesh
from fem.plot.plotter import Plotter


def _explore_simplification(curve, save_file='douglas_peucker_output.json',
                            tolerance=DEFAULT_SIMPLIFICATION_TOLERANCE):
    """Open a slider to explore the Douglas-Peucker simplification of `curve`, starting
    from `tolerance` (a fraction of the curve's extent), and return the curve simplified
    at whatever the slider was left on."""
    d = curve_extent(curve)
    fig, ax = plt.subplots()  # a widget figure, not a Plotter: this path is interactive
    closed_curve = close_ring(curve)
    ax.plot(closed_curve[:, 0], closed_curve[:, 1], color='gray', alpha=0.5)
    plt.subplots_adjust(bottom=0.15)

    initial = close_ring(douglas_peucker(curve, tolerance * d))
    sampled_plot = plt.plot(initial[:, 0], initial[:, 1], 'b-')[0]
    # Starting at zero would leave an untouched slider handing the full outline
    # downstream, which Ruppert's does not finish triangulating.
    slider = Slider(plt.axes((0.15, 0.04, 0.6, 0.03)), 'Epsilon ', 0, d/20,
                    valinit=tolerance * d)
    button = Button(plt.axes((0.85, 0.04, 0.1, 0.04)), 'Save')

    def update(val):
        dp = close_ring(douglas_peucker(curve, slider.val))
        sampled_plot.set_xdata(dp[:, 0])
        sampled_plot.set_ydata(dp[:, 1])
        fig.canvas.draw_idle()

    def save(event):
        save_curve(douglas_peucker(curve, slider.val), save_file)
        print(f'Saved points to {save_file}')

    slider.on_changed(update)
    button.on_clicked(save)
    ax.set_aspect('equal')
    plt.show()

    return douglas_peucker(curve, slider.val)


def _mesh_zoom_inset(ax, mesh, box, loc=(0.57, 0.57, 0.42, 0.42)):
    """Overlay a zoomed inset on `ax` revealing the bare triangulation over `box`.

    `box` is `(x0, x1, y0, y1)` in data coordinates. The inset shows the actual mesh
    under a field drawn fine enough to read smooth. Drawn on white so the lines stay
    legible where the field is dark.
    """
    inset = ax.inset_axes(loc)
    plot_mesh(inset, mesh, color='0.2', linewidth=0.35)
    inset.set_xlim(box[0], box[1])
    inset.set_ylim(box[2], box[3])
    inset.set_aspect('equal')
    inset.set_xticks([])
    inset.set_yticks([])
    for spine in inset.spines.values():
        spine.set_edgecolor('0.35')
    ax.indicate_inset_zoom(inset, edgecolor='0.35', linewidth=0.8, alpha=0.9)


def _zoo_figure(s: OutlineStudy) -> Figure:
    plotter = Plotter(2, 2, axis_labels=False, figsize=(10.5, 10.0),
                      title="One pipeline, any outline: Douglas-Peucker, Ruppert's, solve")
    for k, shape in enumerate(s.shapes):
        idx = divmod(k, 2)
        # A colour scale per cell (the domains differ in size by orders of magnitude) and
        # no colorbar: the shape matters, not the amplitude.
        clim = (0.0, float(shape.dofs.max()))
        plotter.plot(shape.mesh, shape.dofs, mode='colored', idx=idx, colorbar=False,
                     clim=clim, empty=True,
                     title=f'{shape.name}: {shape.n_triangles} triangles')
        plot_mesh(plotter.get_ax(idx), shape.mesh, color='0.9', linewidth=0.1)
        if shape.name == 'California':
            # Reveal the real mesh under the smooth field, zoomed onto the San Francisco
            # Bay, where the traced coastline is most intricate.
            v = np.asarray(shape.mesh.vertices)
            lo, hi = v.min(axis=0), v.max(axis=0)
            span = hi - lo
            box = (lo[0] + 0.11 * span[0], lo[0] + 0.23 * span[0],
                   lo[1] + 0.48 * span[1], lo[1] + 0.62 * span[1])
            _mesh_zoom_inset(plotter.get_ax(idx), shape.mesh, box)
    return Figure(
        plotter,
        'Four outlines through one pipeline. Each becomes a planar straight-line '
        'graph, is simplified with Douglas-Peucker where it was traced densely '
        "(California, cloud), then triangulated by Ruppert's algorithm to a "
        'minimum-angle and maximum-area bound. On each mesh the same Poisson '
        'problem is solved, the dome of -div(grad u) = 1 with u = 0 on the '
        'boundary: tallest where the domain is widest and pinched to zero at every '
        'edge and hole. The outlines make different demands. California meshes as '
        "disconnected islands; the cloud's boundary follows its true Bezier "
        "curves; the gear bore is a hole by the even-odd rule; the star's notches "
        'are corners sharper than the bound, which Ruppert meets at the input '
        "angle. The inset zooms into California's mesh, which resolves the traced "
        'coastline and its offshore islands.')


def _summary(s: OutlineStudy) -> str:
    rows = ['outline        pts  triangles  min angle']
    for shape in s.shapes:
        rows.append(f'{shape.name:<14}{shape.n_points:>4}{shape.n_triangles:>10}'
                    f'{shape.worst_angle:>8.0f}')
    return '\n'.join(rows)


def demo(interactive=False, **kwargs) -> DemoResult:
    """Four outlines, traced and generated, meshed and solved with one pipeline."""
    # --interactive first opens a slider to explore the Douglas-Peucker simplification on
    # the California outline.
    if interactive:
        _explore_simplification(get_curve_from_svg(DEFAULT_SVG_FILE))
    s = run(**kwargs)
    return DemoResult([_zoo_figure(s)], text=_summary(s))


# Builds its own outlines, so it takes no domain.
DEMO = Demo('outline_to_mesh', demo, section='Meshing & solving PDEs',
            show_source=physics,
            smoke_kwargs={'svg_tolerance': 0.005, 'max_area_fraction': 0.04})