Four outlines, traced and generated, meshed and solved with one pipeline.
uv run python examples/cli.py run outline_to_mesh
outline pts triangles min angle California 570 4008 25 Cloud 124 1981 29 Gear 97 2004 28 Star 10 2000 28
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 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})