Repository navigation
Conversation
Lay the groundwork for saving draw and the diagrams as svg and pdf files. The OpenGL2 backend rasterizes 3D geometry in GL2PS exports, so resolve visibility in software instead: split and order the opaque fills with a BSP painter, clip the strokes against every nearer triangle, and write the result through Matplotlib, embedding the vendored font in svg text as the results plots already do. Keep the module unwired for now, so the diagrams and draw can adopt it in their own commits. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Let the vector export embed the vendored font without importing the plotting module, which imports the rendering module. The rendering module will import the vector export once the diagrams adopt it, so leaving the function in place would close an import cycle. Keep it beside the font it embeds, in a module that imports none of the three. Name the font family through the fonts module in the moved SVG fixture, so the fixture follows the font if it ever changes. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Give papers and slides diagrams that stay sharp at any size and keep their labels as selectable text. Record each diagram as it is drawn and export it through the core vector module, rather than rebuilding the scene from VTK's actors, since recording keeps what each helper means explicit. Read the camera, the window size, and the labels after every render, and export from the last reading, so the dragged and edited labels survive even when closing the window destroys it. Take each label's box from VTK and center its text in it, which places it as VTK does without modeling VTK's padding. Paint coincident strokes so the first one recorded shows, since VTK's depth test keeps the first of two equally deep fragments. Embed every font face an svg's text uses, so math labels carry their STIX faces along with Liberation Mono. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fix diagrams that painted every panel edge over every vortex line, which buried vorticity arrows that sit in front of the panels. Painting strokes in the reverse of the order they were recorded was based on a wrong reading of VTK: a line VTK shows on top wins on depth, and of two equally deep lines VTK keeps the later one. Let every stroke and every occluder outline hide the strokes behind it across its own width, so the nearer of two crossing strokes shows wherever they cross, and a stroke behind an arrow tip can no longer paint over the tip's outline. Widen each hiding ribbon by half the hidden stroke's width, so a clipped stroke's round end stops short of the stroke in front. Exempt strokes that share an end, so a polyline's segments don't clip each other where they meet, and record each collocation point's cross as four half arms from the point for the same reason. Look the triangles up through a grid on screen, since the ribbons add two triangles per stroke and a diagram can hold tens of thousands of strokes. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Let a diagram saved as an svg or pdf file be sized for a paper, the way plot_results_versus_time's figures can be. The page takes the requested size in inches, and the view framed in the window scales to fill it, while the labels and lines keep absolute sizes in points, so a figure printed at a column's width gets the font size and line weight the publisher asks for. Open the window at the page's aspect ratio, so the framing chosen on screen is the framing saved. Leave the window and WebP output untouched, since they are screenshots of what VTK draws. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Format dispatch, overlapping-occluder visibility, and unconditional scene-recording issues remain unresolved.
Review effort: Balanced
Findings: 2
Open (4)
What changed in this PR
Adds SVG/PDF diagram export with selectable embedded-font text, configurable figure styling, and software-resolved visibility, advancing issue #266.
Changes:
- Records rendered diagram geometry into vector scenes with BSP and hidden-line processing.
- Extends all diagram APIs with vector formats and figure styling parameters.
- Moves SVG font embedding into
_fonts.pyand adds focused tests.
| File | Description |
|---|---|
CLAUDE.md |
Documents vector export architecture. |
pterasoftware/_core.py |
Forwards new diagram options. |
pterasoftware/_fonts.py |
Embeds all used SVG font faces. |
pterasoftware/_output_plotting.py |
Uses shared font embedding. |
pterasoftware/_output_rendering.py |
Records scenes and dispatches exports. |
pterasoftware/_vector_export.py |
Implements vector projection and visibility. |
pterasoftware/geometry/airfoil.py |
Adds vector airfoil diagrams. |
pterasoftware/geometry/airplane.py |
Adds vector airplane diagrams. |
pterasoftware/geometry/wing.py |
Adds vector wing diagrams. |
pterasoftware/geometry/wing_cross_section.py |
Adds vector cross-section diagrams. |
pterasoftware/problems.py |
Adds vector problem diagrams. |
pterasoftware/steady_horseshoe_vortex_lattice_method.py |
Exports horseshoe-solver diagrams. |
pterasoftware/steady_ring_vortex_lattice_method.py |
Exports ring-solver diagrams. |
pterasoftware/unsteady_ring_vortex_lattice_method.py |
Exports unsteady-solver diagrams. |
tests/unit/fixtures/fonts_fixtures.py |
Adds SVG font fixtures. |
tests/unit/fixtures/output_plotting_fixtures.py |
Removes relocated SVG fixture. |
tests/unit/fixtures/vector_export_fixtures.py |
Adds vector geometry fixtures. |
tests/unit/test_fonts.py |
Tests multi-face font embedding. |
tests/unit/test_output_plotting.py |
Removes relocated embedding tests. |
tests/unit/test_output_rendering.py |
Tests recording and export state. |
tests/unit/test_vector_export.py |
Tests vector algorithms and output. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #328 +/- ##
==========================================
- Coverage 96.41% 95.26% -1.15%
==========================================
Files 48 49 +1
Lines 10642 12040 +1398
==========================================
+ Hits 10260 11470 +1210
- Misses 382 570 +188 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Write each run of consecutive opaque fills or strokes that share a color and width as one compound path instead of one path per piece. Matplotlib gives every collection item its own SVG element with a full inline style, which made the files for large diagrams many times bigger than they need to be. Wind every fill piece counterclockwise so that the nonzero fill rule never cancels overlapping pieces, keep translucent strokes separate so their overlaps still compound, and disable Matplotlib's path simplification so that no segment is dropped. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Neighboring closed polylines, such as the outlines of adjacent panels and ring vortices, share their edges, so many of a diagram's strokes were exact repeats that inflated the vector exports and added needless work to hidden-line clipping. Skip a segment only when its color and width also match, so a coincident stroke in a different style still paints over the earlier one. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Matplotlib writes every path coordinate to six decimal places, which makes up most of a large diagram's svg. Round them to hundredths of a point, far finer than any display or print can show, to shrink the files without changing what they show. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Some programs that import an svg ignore its embedded fonts and draw its text in other fonts, which misplaces the parts of any math. Let a diagram's svg draw its labels as glyph outlines instead, which look the same everywhere at the cost of selectable and searchable text. Keep selectable text as the default, and leave pdfs alone, since their embedded TrueType fonts are read reliably. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Order convex occluders exactly against the layer's fills and against each other, instead of painting them whole by the mean depth of their points. Fix the misordering of occluders that interpenetrate, overlap cyclically, or overlap in depth, and stop fills and occluders in one layer from painting over each other regardless of depth. Convert each occluder at draw time into fill triangles and outline strokes owned by those triangles, so the existing BSP and hidden-line removal handle them, and the outlines are hidden by whatever is nearer. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Order the visible pieces of strokes by depth instead of clipping each stroke against the strokes in front of it. Stop the clipping from opening gaps where strokes cross, and stop the round ends of clipped strokes from showing over the strokes in front of them. Pull a stroke's line back by its radius where a fill's boundary cuts it, so its round end stops at the boundary rather than reaching over the fill. Split pieces caught in cycles of depth constraints, and drop the weakest constraint in any cycle that survives. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Clipping each stroke against the fills along its centerline hides or shows its whole width at once, so a stroke near a fill's edge or partly behind it is drawn wrong. Instead, cut each stroke only where it passes through a fill, and paint each piece before the fills it is behind on average over the region where they overlap on screen, so those fills cover the part of its width they hide, and after the rest. Sort the fills and the pieces together, keeping the BSP's order between overlapping fills and the depth order between overlapping pieces, and narrow every pairwise overlap test with a grid on screen. Draw them in one collection, which leaves each layer three passes, and remove the stroke clipping that no longer has a caller. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Dropping the weakest remaining constraint one at a time while the sort is blocked drops constraints that aren't in any cycle, and rescans every constraint for each drop, which made exporting a small unsteady solver diagram take tens of seconds. Instead, split the constraints into strongly connected components and, for each, drop the set of constraints with the smallest total weight whose removal breaks every cycle, found exactly as an integer program whose cycles are added as they are found. Raise an error rather than fall back to a greedy choice if the solver fails, so a failed solve is never silently replaced by a worse order. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Comparing each piece's bounding box with every later piece's grows quadratically, and finding where each overlapping pair comes closest one call at a time spends nearly all its time on numpy overhead for scalars, which made exporting a diagram with thousands of strokes take seconds. Instead, find the candidate pairs on the grid that the other overlap tests use, and find where every pair comes closest at once, with arrays. The arithmetic differs from the old version's in the last bits, which can flip which of two equally close points a pair is compared at, but every test scene still renders identically. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The test runs for every pair of a stroke piece and a fill that overlap on screen, and clipped the fill against each of the many edges of the piece's footprint, one numpy scalar operation at a time, which made exporting even a small unsteady solver diagram take minutes. Instead, clip the footprint against the fill's few edges, which gives the same region, and do the geometry in plain floats, which avoids numpy's overhead on arrays of a few corners. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Every split round compared every pair of overlapping fills and every stroke piece with every fill it overlaps, although the fills never change and most pieces aren't split, so much of the export time went to repeating work. Instead, find the fills' constraints with each other once, and keep each piece's constraints with the fills until it is split. Keep the constraints in the same order as before, so the exact cycle breaking sees the same input. Also test whether two fills overlap in plain floats, since numpy's overhead on arrays of a few corners far outweighed the arithmetic. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Breaking the cycles gathered each strongly connected component's items and constraints by scanning all of them once per component, so its cost grew with the number of components times the number of constraints, which took seconds on a small unsteady solver diagram. Instead, group them by component in one pass over each, keeping their order within each component so the exact solve sees the same input. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A closed convex polyhedron's faces that face the camera cover its whole footprint on screen, in front of the rest, so its other faces are always hidden, yet they were split, ordered, compared with every stroke they overlap, and written to the file. Instead, turn only the faces that face the camera into fills, which cuts the time to export a small unsteady solver diagram by about a fifth and its svg by about a quarter, and require the polyhedron to be closed. The faces dropped had sat beneath the joins between the faces kept, in the same color, so faint seams now show at those joins until abutting fills of one color are painted together. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Exporting a mid-size unsteady solver diagram took nearly a minute, much of it in Python loops over tiny arrays: the overlap-region depth test, called once per pair of a stroke piece and a fill, and the grid lookup that finds which boxes overlap. Compile both with Numba instead, the depth test as one call over every pair in a round, and the grid as sorted cell keys rather than a dict of lists. Let the depth test use fastmath, since a last-bit difference there only moves a stroke's place in the paint order among nearly equal choices. Also skip the integer program when the cycles first found in a component share no droppable constraint, since dropping each one's cheapest is then already exact. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The docs build mocks numpy, so computing the stroke caps' angles from np.pi when the export module loads raised a TypeError, which broke importing pterasoftware for autodoc and failed the build on its warnings. Take pi from math instead, which gives identical angles. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Paint abutting fills of the same color as one compound path wherever no chain of constraints through another item runs between them, so no anti-aliased seam shows along their shared edges. This removes most of the seams that culling the occluders' back faces introduced. Drop fills with almost no area on screen, such as faces seen edge on. They can't hide anything, so they carry no constraints, and their outlines painted stray lines of their color wherever merging moved them in the order. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Paint a strip of each fill's color along every edge it shares with a fill painted in a later run, so the later fill's anti-aliased edge blends with the earlier fill rather than with whatever is behind both. This removes nearly all of the seams that merging leaves between fills of different runs, without changing what is visible. Cover both sides of each shared edge, so the strip has no anti-aliased edge of its own there, and keep it inset from the later fill's other edges, where it would otherwise show past them. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Vector output has unresolved depth-ordering mismatches and an invalid zero-sized viewport edge case.
3 open findings
4 resolved since last review
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Two strokes lying along each other on screen were ordered by one depth comparison where they come closest, so one was painted over the other along their whole overlap, though each is nearer along part of it. Cut both strokes where their depth order swaps within the overlap, except strokes that share an end, and compare each pair of pieces by its depth difference averaged over the stretch where they overlap, since the cut pieces meet at equal depths where they come closest. Also build the cycle constraints for the integer program as a sparse matrix, and let each search for a cycle skip the items an earlier search in the same round finished, which leaves the cycles found unchanged but avoids repeating the work the extra pieces would multiply. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The dots were painted in a pass of their own over everything else, so a dot hidden in the window behind a panel, an arrow tip, or a nearer line showed on top in the exported file. Order each dot among the fills and strokes as a stroke of zero length as wide as the dot, whose footprint is the circle it paints, and draw it as a filled circle, since Matplotlib's own renderer draws nothing for a zero-length stroke. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Fixed-interval sampling can miss narrow stroke depth swaps, and the public vector-export path lacks integration coverage.
2 open findings
3 resolved since last review
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
| for sample_id in range(num_samples): | ||
| fraction = _SWAP_SAMPLE_FRACTIONS[sample_id] | ||
| x = starts_display[first_id, 0] + fraction * ( |
| # If saving an svg or pdf file, read the state its export needs at the end of every | ||
| # render, keeping only the latest. | ||
| is_vector = save and path.suffix.lower() in (".svg", ".pdf") |
|
Closing this without merging. After working through it, the feature doesn't justify what it would add to the codebase. Producing true vector output means resolving visibility in software, since VTK's own vector export rasterizes 3D geometry. Getting that close to what the window shows took a BSP painter for the fills, a depth ordering of every stroke and dot among them, cuts where strokes pass through fills or swap depth with each other, and cycle breaking for orderings that can't be satisfied. That brought the change to roughly 6,000 lines, most of it intricate geometry that would need ongoing maintenance. Even then, it remains a tradeoff between accuracy, robustness, and speed rather than a solved problem. Each accuracy fix uncovered new edge cases, and the more exact methods made exports of large unsteady solver diagrams slow enough to be impractical. That's a hard balance to keep stable given this project's focus is the solvers, not the rendering, so the raster output will stay the supported diagram format for now. |



Description
This PR lets every
diagrammethod save its diagram as an svg or pdf file, chosen by the path's suffix, alongside the existing WebP output. The scene is recorded in a new private module,_vector_export.py, as the rendering helpers draw it, then exported through a parallel camera that matches the window's final view. Visibility is resolved in software: opaque fills are split and ordered by a BSP painter, and strokes are cut wherever they pass through a fill or swap depth order with another stroke they overlap, and painted among the fills in depth order, each before the fills that hide it and after everything else it overlaps, with the dots painted among them the same way, so the file shows what the window showed. Labels are read from VTK after every render, so dragged and edited labels survive, and are written as selectable text with their fonts embedded. The diagram methods also gainfigure_size_in,font_size,text_color, andline_width, which size a vector diagram for a paper the wayplot_results_versus_timesizes its figures.The export also keeps its files small: runs of fills and strokes that share a style are written as single compound paths, segments shared by neighboring polylines are drawn once, and an svg's path coordinates are rounded to hundredths of a point. Some programs that import an svg ignore its embedded fonts and draw its text in other fonts, which misplaces the parts of math labels, so the diagram methods also gain
selectable_text. Setting it toFalsedraws an svg's labels as glyph outlines, which look the same in every program but can't be selected or searched.All new parameters default to the current behavior, so existing calls are unaffected, and the WebP output is unchanged.
output.drawis deliberately out of scope here and will adopt the same module in a follow-up PR, so the module's translucent polygon and screen polygon support, which onlydrawneeds, is currently unused.Motivation
Diagrams are mainly useful as figures in papers, slides, and documentation, where a WebP screenshot blurs when scaled and its labels can't be selected or searched. VTK's own vector export (GL2PS) rasterizes 3D geometry with the OpenGL2 backend, so it can't produce true vector output of these scenes. Recording the scene and resolving visibility in software gives sharp output at any size whose labels stay text. The figure parameters exist because a vector diagram is otherwise sized by the window, so its fonts and lines have no predictable physical size on a printed page.
Relevant Issues
Part of #266.
Changes
_vector_export.pywithVectorCamera(a parallel camera that projects diagram axes to display coordinates, plusfit_to_page),VectorText,VectorLayer, andVectorScene. A layer records opaque quadrilaterals, polylines, convex occluders (the arrow tips), dots, and translucent polygons, andVectorScene.savewrites the scene through Matplotlib with TrueType fonts in pdf files and embedded fonts in svg files.get_paint_order), adapted from csg.js, which uses explicit stacks and samples candidate splitters, so it orders crossing fills exactly and can't hit the recursion limit.VectorLayer._get_fills_and_strokesturns each occluder's faces that face the camera into fill triangles, and its silhouette and outlined faces into strokes owned by those triangles, so occluders are ordered exactly against the other fills and against each other, however they interpenetrate. Occluders must now be closed, since their faces that face away are dropped._order_items):_cut_at_piercescuts each stroke where its centerline passes through a fill it doesn't outline._get_overlap_gapscompares each stroke piece with each fill it overlaps on screen, averaged over the region where they overlap, so the piece is painted before the fills it is behind, which cover the part of its width they hide, and after the rest. Pieces of different strokes are ordered by their depths where they come closest (_get_stroke_constraints), and overlapping fills keep the BSP's order._SPLIT_ROUNDSrounds, and_break_cyclesbreaks any cycle that survives, within each strongly connected component, by dropping the lightest constraint of each cycle in turn (_get_greedy_drops)._cut_at_depth_swapsalso cuts two strokes that overlap on screen wherever they swap which of them is nearer, except strokes that share an end, so strokes lying along each other on screen are each painted over the other only where they are nearer. Pairs of pieces are compared by their depth difference averaged over the stretch where they overlap (_get_average_gaps), rather than where they come closest._merge_abutting_fillspaints abutting fills of one color together, as one compound path, wherever that keeps the order possible, and_get_underlapsreaches each fill under the abutting fills painted after it, with a strip of its color along their shared edge. Fills with almost no area on screen, such as faces seen edge on, are dropped._get_overlapping_boxes), the overlap-region test and the grid lookup are compiled with Numba, the fills' constraints are found once and each piece's are cached until it is split, and each search for a cycle skips the items an earlier search finished.VectorLayer.drawwrites each run of consecutive opaque fills of the same color, and of consecutive opaque strokes of the same color and width, as one compound path in aPathCollection, rather than one svg element per piece. Each fill piece is wound counterclockwise so the nonzero fill rule never cancels overlapping pieces, translucent strokes stay separate, and Matplotlib's path simplification is turned off for the strokes so no segment is dropped.VectorLayer.add_polylinesskips a segment that repeats one it already added in the same color and width, such as an edge shared by neighboring panels or ring vortices.VectorScene.saverounds the coordinates in an svg's path data to_SVG_DECIMAL_PLACES(2) decimal places with_round_svg_path_data, since Matplotlib always writes six._output_plotting.pyto_fonts.pyasembed_fonts_in_svg, which avoids an import cycle. It now embeds every face the text uses, including the STIX faces Matplotlib writes math in, each subset to its glyphs.plot_results_versus_timeuses the moved function.layerparameter throughadd_axes_and_points,_add_arrow_tips,add_airfoil,add_airfoil_lines,add_airfoils,add_panels,add_vortices, andadd_steady_problemin_output_rendering.py. Each helper records what it draws as it adds the PyVista actors, and each collocation point's cross is recorded as four half arms so its strokes don't clip each other.show_diagramto read the camera, window size, and labels after every render (_get_diagram_export_state) and to export from the last reading when the path ends with".svg"or".pdf", in any case. Each label is drawn in its VTK background box, with math in STIX and other text in Liberation Mono.VectorScene.savealso checks the suffix without regard to case.pathonAirfoil.diagram,WingCrossSection.diagram,Wing.diagram,Airplane.diagram,SteadyProblem.diagram, the unsteady problems'diagram, and the three solvers'diagrammethods to accept".svg"and".pdf".figure_size_in,font_size,text_color, andline_widthto the same methods, validated by_output_rendering.validate_diagram_figure_parameters:figure_size_inopens the window at the page's aspect ratio, at least 1 pixel on each side, and scales the view to fill the page, while text and line widths stay in points.font_sizesets every label's size, including math labels.text_colorcolors the labels.line_widthsets the width of the panel edges, airfoil lines, and vortices, and scales the other lines with it.selectable_text(defaultTrue) to the same methods, passed throughshow_diagramtoVectorScene.save. When it isFalse, an svg is written withsvg.fonttypeset to"path"and without embedded fonts, so every label, math included, is drawn as glyph outlines. It has no effect on pdf or WebP output."fontTools"toautodoc_mock_importsindocs/website/conf.py, so the website build doesn't need it installed.tests/unit/test_vector_export.pyand its fixtures, covering projection, page fitting, paint order (including a test that samples inside every fragment's footprint), the painting of strokes behind, in front of, and through fills and occluders, the depth order of crossing and coincident strokes, the cut where two strokes swap depth order, the painting of dots in depth order, the culling of occluders' faces that face away, line width scaling, the drawing of shared polyline edges once, and svg and pdf writing, including an svg whose text is drawn as outlines. Addedtests/unit/test_fonts.pyfor the moved font embedding, and extendedtests/unit/test_output_rendering.pyfor layer recording, export state reading, page fitting, label sizing and coloring, and parameter validation._vector_export.pyto the architecture list inCLAUDE.md, and updated the_fonts.pyentry.Dependency Updates
None.
Change Magnitude
Moderate: Medium-sized change that adds or modifies a feature without large-scale impact.
Checklist (check each item when completed or not applicable)
mainand is up to date with the upstreammainbranch.--in-place --black). See the style guide for type hints and docstrings for more details.pterasoftwarepackage use type hints. See the style guide for type hints and docstrings for more details.testspackage.testspackage.ascii-only,pre-commit-hooks, andzizmorGitHub actions.lintjob of theCIGitHub action.testjobs of theCIGitHub action.Assisted-by: Claude Opus 5.5