shape-plotters 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/CHANGELOG.md +17 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/PKG-INFO +5 -1
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/README.md +4 -0
- shape_plotters-0.2.0/shape_plotters/core/blit.py +103 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/engine.py +126 -4
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/widgets.py +7 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters.egg-info/PKG-INFO +5 -1
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters.egg-info/SOURCES.txt +4 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters.egg-info/scm_file_list.json +4 -0
- shape_plotters-0.2.0/tests/dragging.py +82 -0
- shape_plotters-0.2.0/tests/test_blit.py +107 -0
- shape_plotters-0.2.0/tests/test_engine_drag.py +291 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_perf.py +46 -4
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_widgets.py +31 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/.github/workflows/wheels.yml +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/.gitignore +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/LICENSE +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/conftest.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/pyproject.toml +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/setup.cfg +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/__init__.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/__main__.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/cli.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/__init__.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/energy.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/fragments.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/neck.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/nodes.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/quadrature.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/result.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/renders/__init__.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/renders/beta.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/renders/fos.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters.egg-info/dependency_links.txt +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters.egg-info/entry_points.txt +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters.egg-info/requires.txt +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters.egg-info/top_level.txt +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/__init__.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_beta_render.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_cli.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_energy.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_energy_local.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_engine.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_fos_render.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_fragments.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_neck.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_nodes.py +0 -0
- {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_quadrature.py +0 -0
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to shape-plotters. Versions follow semantic versioning.
|
|
4
4
|
|
|
5
|
+
## 0.2.0
|
|
6
|
+
|
|
7
|
+
Interactive performance. Measured in a real window on the development PC
|
|
8
|
+
(matplotlib 3.11): a drag step drops from about 110 ms to 33-46 ms, a click
|
|
9
|
+
on a slider from about 300 ms to about 105 ms.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **Slider drags are blitted.** While a slider is dragged, only the curves,
|
|
14
|
+
the stats text and that slider are redrawn; ticks, legends and the other
|
|
15
|
+
widgets come from a cached background. The axes hold still during the drag
|
|
16
|
+
and refit on release; if the shape outgrows the view, the view jumps out
|
|
17
|
+
once to a looser fit. During a drag the curves draw on top of legends.
|
|
18
|
+
Everything outside a drag, including saved PNGs, is drawn exactly as before.
|
|
19
|
+
- **A click costs one redraw instead of three.** Every mouse press used to
|
|
20
|
+
trigger two extra full redraws, one per Z/N text box.
|
|
21
|
+
|
|
5
22
|
## 0.1.0
|
|
6
23
|
|
|
7
24
|
First release.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shape-plotters
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Interactive matplotlib plotters for axially symmetric nuclear shapes (beta, FoS)
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/AleksanderAugustyn/shape-plotters
|
|
@@ -53,6 +53,10 @@ boxes (R₀ = 1.16 A^(1/3) fm), Reset, and Save (300 dpi PNG named after the
|
|
|
53
53
|
parameters, written to the current directory). Red dotted marks on the FoS
|
|
54
54
|
sliders are practical limits.
|
|
55
55
|
|
|
56
|
+
While a slider is dragged the axes hold still, so the shape visibly changes
|
|
57
|
+
instead of the axes rescaling around it; they refit when the mouse is
|
|
58
|
+
released. If the shape outgrows the view mid-drag, the view jumps out once.
|
|
59
|
+
|
|
56
60
|
## Requirements
|
|
57
61
|
|
|
58
62
|
- Linux x86-64. The parameterization libraries ship manylinux wheels only;
|
|
@@ -33,6 +33,10 @@ boxes (R₀ = 1.16 A^(1/3) fm), Reset, and Save (300 dpi PNG named after the
|
|
|
33
33
|
parameters, written to the current directory). Red dotted marks on the FoS
|
|
34
34
|
sliders are practical limits.
|
|
35
35
|
|
|
36
|
+
While a slider is dragged the axes hold still, so the shape visibly changes
|
|
37
|
+
instead of the axes rescaling around it; they refit when the mouse is
|
|
38
|
+
released. If the shape outgrows the view mid-drag, the view jumps out once.
|
|
39
|
+
|
|
36
40
|
## Requirements
|
|
37
41
|
|
|
38
42
|
- Linux x86-64. The parameterization libraries ship manylinux wheels only;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Background cache for blitted slider drags.
|
|
2
|
+
|
|
3
|
+
During a drag only a few artists change: the data lines, the stats text and
|
|
4
|
+
the dragged slider. DragBlitter keeps a copy of everything else (the static
|
|
5
|
+
layer) and composes each frame from that copy plus the dynamic artists,
|
|
6
|
+
instead of redrawing the whole figure.
|
|
7
|
+
|
|
8
|
+
It knows pixels, not shapes. What counts as a change to the static layer
|
|
9
|
+
(view limits, titles, legends) is the engine's policy; the engine reports it
|
|
10
|
+
through invalidate().
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
from matplotlib.artist import Artist
|
|
17
|
+
from matplotlib.axes import Axes
|
|
18
|
+
from matplotlib.figure import Figure
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DragBlitter:
|
|
22
|
+
"""Owns the static background and the ``animated`` flags for one drag.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
fig : Figure
|
|
27
|
+
The figure to compose frames on. Its canvas must support blitting.
|
|
28
|
+
dynamic : Sequence[Artist]
|
|
29
|
+
Artists that change on every frame of a drag.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, fig: Figure, dynamic: Sequence[Artist]) -> None:
|
|
33
|
+
self._fig = fig
|
|
34
|
+
self._dynamic = tuple(dynamic)
|
|
35
|
+
self._slider_ax: Axes | None = None
|
|
36
|
+
self._background = None
|
|
37
|
+
fig.canvas.mpl_connect("draw_event", self._on_draw)
|
|
38
|
+
# A window resize only schedules its redraw; a motion event handled
|
|
39
|
+
# before it must not blit the old-size background.
|
|
40
|
+
fig.canvas.mpl_connect("resize_event", lambda _event: self.invalidate())
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def active(self) -> bool:
|
|
44
|
+
"""True between begin() and end()."""
|
|
45
|
+
return self._slider_ax is not None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def ready(self) -> bool:
|
|
49
|
+
"""True when a background matching the current static layer is held."""
|
|
50
|
+
return self._background is not None
|
|
51
|
+
|
|
52
|
+
def begin(self, slider_ax: Axes) -> None:
|
|
53
|
+
"""Enter drag mode; full draws now leave the dynamic layer to us.
|
|
54
|
+
|
|
55
|
+
The whole slider axes is animated, not its handle and value text
|
|
56
|
+
individually: no private Slider attributes, and the value text cannot
|
|
57
|
+
overprint the copy baked into the background.
|
|
58
|
+
"""
|
|
59
|
+
self._slider_ax = slider_ax
|
|
60
|
+
self._background = None
|
|
61
|
+
for artist in (*self._dynamic, slider_ax):
|
|
62
|
+
artist.set_animated(True)
|
|
63
|
+
|
|
64
|
+
def end(self) -> None:
|
|
65
|
+
"""Leave drag mode; the next full draw is an ordinary one."""
|
|
66
|
+
if self._slider_ax is None:
|
|
67
|
+
return
|
|
68
|
+
for artist in (*self._dynamic, self._slider_ax):
|
|
69
|
+
artist.set_animated(False)
|
|
70
|
+
self._slider_ax = None
|
|
71
|
+
self._background = None
|
|
72
|
+
|
|
73
|
+
def invalidate(self) -> None:
|
|
74
|
+
"""The static layer changed; the next full draw recaptures it."""
|
|
75
|
+
self._background = None
|
|
76
|
+
|
|
77
|
+
def blit(self) -> None:
|
|
78
|
+
"""Compose one frame: the saved background plus the dynamic layer."""
|
|
79
|
+
canvas = self._fig.canvas
|
|
80
|
+
canvas.restore_region(self._background)
|
|
81
|
+
self._draw_dynamic()
|
|
82
|
+
canvas.blit(self._fig.bbox)
|
|
83
|
+
|
|
84
|
+
def _on_draw(self, event) -> None:
|
|
85
|
+
# Runs at the end of every full draw. In drag mode that draw skipped
|
|
86
|
+
# the animated artists, so the canvas holds exactly the static layer.
|
|
87
|
+
if self._slider_ax is None:
|
|
88
|
+
return
|
|
89
|
+
if self._fig.canvas.is_saving():
|
|
90
|
+
# An export (savefig), not a screen draw. It already drew the
|
|
91
|
+
# animated artists, except whole animated axes: add the slider.
|
|
92
|
+
# It may also have re-rendered the canvas at another size or on
|
|
93
|
+
# another canvas class, so the held background is no longer valid.
|
|
94
|
+
self._slider_ax.draw(event.renderer)
|
|
95
|
+
self._background = None
|
|
96
|
+
return
|
|
97
|
+
self._background = self._fig.canvas.copy_from_bbox(self._fig.bbox)
|
|
98
|
+
self._draw_dynamic()
|
|
99
|
+
|
|
100
|
+
def _draw_dynamic(self) -> None:
|
|
101
|
+
for artist in self._dynamic:
|
|
102
|
+
self._fig.draw_artist(artist)
|
|
103
|
+
self._fig.draw_artist(self._slider_ax)
|
|
@@ -8,14 +8,24 @@ Artists are created once at build; update() only mutates data, text, and
|
|
|
8
8
|
visibility. v0.1 cleared and replotted every axes per slider event — legend
|
|
9
9
|
and text layout made that the frame-time bottleneck (see
|
|
10
10
|
docs/superpowers/specs/2026-07-03-engine-v0.2-performance-design.md).
|
|
11
|
+
|
|
12
|
+
A full-figure redraw is still ~100 ms, almost all of it ticks, legends and
|
|
13
|
+
widget axes that do not change. While a slider is dragged, update() therefore
|
|
14
|
+
blits: the static layer comes from a cached background (blit.py) and only the
|
|
15
|
+
dynamic artists and the dragged slider are drawn. Everything outside a drag
|
|
16
|
+
takes the ordinary full-draw path, so a settled figure is pixel-identical to
|
|
17
|
+
one that was never dragged (see
|
|
18
|
+
docs/superpowers/specs/2026-10-03-engine-v0.3-blitting-design.md).
|
|
11
19
|
"""
|
|
12
20
|
from __future__ import annotations
|
|
13
21
|
|
|
14
22
|
import numpy as np
|
|
15
23
|
import matplotlib.pyplot as plt
|
|
24
|
+
from matplotlib.axes import Axes
|
|
16
25
|
from matplotlib.widgets import Button, CheckButtons
|
|
17
26
|
|
|
18
27
|
from shape_plotters.core import energy, fragments, quadrature
|
|
28
|
+
from shape_plotters.core.blit import DragBlitter
|
|
19
29
|
from shape_plotters.core.result import ShapeResult
|
|
20
30
|
from shape_plotters.core.widgets import IntTextBox, SliderRow
|
|
21
31
|
|
|
@@ -33,6 +43,10 @@ UNITS_LABEL = "fm units"
|
|
|
33
43
|
# R0 = 1.16 fm equals WMMM's geometric R0; see FORD.md / v0.2 handoff).
|
|
34
44
|
SCISSION_BAND_FM = (1.2, 1.5)
|
|
35
45
|
|
|
46
|
+
# Headroom of a mid-drag refit, as an autoscale margin: when a dragged shape
|
|
47
|
+
# outgrows the held view, the view jumps out this far so the next frames fit.
|
|
48
|
+
DRAG_MARGIN = 0.25
|
|
49
|
+
|
|
36
50
|
|
|
37
51
|
class ShapePlotterApp:
|
|
38
52
|
def __init__(self, render) -> None:
|
|
@@ -47,6 +61,12 @@ class ShapePlotterApp:
|
|
|
47
61
|
self._build_figure()
|
|
48
62
|
self._build_artists()
|
|
49
63
|
self._build_widgets()
|
|
64
|
+
# Blitted slider drags; None where the canvas cannot blit.
|
|
65
|
+
self._blitter = (DragBlitter(self.fig, self._dynamic)
|
|
66
|
+
if self.fig.canvas.supports_blit else None)
|
|
67
|
+
self._drag_pending = None # slider whose press was drawn the ordinary way
|
|
68
|
+
self._dragged = None # slider the blitter was begun on
|
|
69
|
+
self.fig.canvas.mpl_connect("button_release_event", self._on_release)
|
|
50
70
|
self.update()
|
|
51
71
|
|
|
52
72
|
# ---------- construction ----------
|
|
@@ -66,6 +86,8 @@ class ShapePlotterApp:
|
|
|
66
86
|
self.ax_extra = self.fig.add_subplot(gs[2]) if self.render.has_extra_panel else None
|
|
67
87
|
self.ax_stats = self.fig.add_subplot(gs[ncols])
|
|
68
88
|
self.ax_stats.axis("off")
|
|
89
|
+
self._data_axes = tuple(
|
|
90
|
+
ax for ax in (self.ax_radius, self.ax_shape, self.ax_extra) if ax is not None)
|
|
69
91
|
|
|
70
92
|
def _build_artists(self) -> None:
|
|
71
93
|
# fm_units defaults to True; the unit toggle re-texts labels in place.
|
|
@@ -132,6 +154,16 @@ class ShapePlotterApp:
|
|
|
132
154
|
0.0, 1.0, "", va="top", family="monospace", fontsize=12,
|
|
133
155
|
transform=self.ax_stats.transAxes)
|
|
134
156
|
|
|
157
|
+
# Everything update() mutates per frame. The rest of the figure is the
|
|
158
|
+
# static layer that a blitted drag frame reuses.
|
|
159
|
+
dynamic = [self.r_line, self.dr_line, self.shape_upper, self.shape_lower,
|
|
160
|
+
self.neck_line, self.zcm_point, self.rtheta_upper,
|
|
161
|
+
self.rtheta_lower, self.rtheta_zcm, self.stats_text]
|
|
162
|
+
if self.ax_extra is not None:
|
|
163
|
+
dynamic += [self.extra_rho, self.extra_drho, self.zcm_extra]
|
|
164
|
+
self._dynamic = tuple(dynamic)
|
|
165
|
+
self._rest_margins = {ax: ax.margins() for ax in self._data_axes}
|
|
166
|
+
|
|
135
167
|
def _build_widgets(self) -> None:
|
|
136
168
|
self.rows: dict[str, SliderRow] = {}
|
|
137
169
|
y = 0.06
|
|
@@ -229,6 +261,9 @@ class ShapePlotterApp:
|
|
|
229
261
|
self.rows[spec.key].slider.set_val(spec.vinit)
|
|
230
262
|
|
|
231
263
|
def _save(self, _event=None) -> None:
|
|
264
|
+
# A drag whose release never arrived must not leak its held view into
|
|
265
|
+
# the file: leave drag mode first.
|
|
266
|
+
self._on_release(None)
|
|
232
267
|
params = {k: row.slider.val for k, row in self.rows.items()}
|
|
233
268
|
fname = self.render.filename(self.z_box.value, self.n_box.value, params)
|
|
234
269
|
self.fig.savefig(fname, dpi=300, bbox_inches="tight")
|
|
@@ -281,6 +316,8 @@ class ShapePlotterApp:
|
|
|
281
316
|
return R0_FM * float(self.z_box.value + self.n_box.value) ** (1.0 / 3.0)
|
|
282
317
|
|
|
283
318
|
def update(self, _val=None) -> None:
|
|
319
|
+
dragging = self._sync_drag_mode()
|
|
320
|
+
static_before = (self.ax_shape.get_title(), self.shape_legend.get_visible())
|
|
284
321
|
params = {k: row.slider.val for k, row in self.rows.items()}
|
|
285
322
|
result = self.render.compute(params, self.toggle_state)
|
|
286
323
|
self.last_result = result
|
|
@@ -382,12 +419,97 @@ class ShapePlotterApp:
|
|
|
382
419
|
self._energy_lines = [] # any shape/Z/N/unit change invalidates energies
|
|
383
420
|
self._refresh_stats()
|
|
384
421
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
422
|
+
static_changed = static_before != (
|
|
423
|
+
self.ax_shape.get_title(), self.shape_legend.get_visible())
|
|
424
|
+
self._present(dragging, static_changed)
|
|
425
|
+
|
|
426
|
+
# ---------- presenting a frame ----------
|
|
427
|
+
|
|
428
|
+
def _sync_drag_mode(self) -> bool:
|
|
429
|
+
"""Match drag mode to the sliders; True while a drag is being blitted.
|
|
430
|
+
|
|
431
|
+
A click is not a drag: the first update of a press is drawn the
|
|
432
|
+
ordinary way and drag mode starts with the next one. A plain click
|
|
433
|
+
then costs one full draw, not a drag-mode draw plus the release refit.
|
|
434
|
+
"""
|
|
435
|
+
if self._blitter is None:
|
|
436
|
+
return False
|
|
437
|
+
slider = next((row.slider for row in self.rows.values()
|
|
438
|
+
if row.slider.drag_active), None)
|
|
439
|
+
if slider is None:
|
|
440
|
+
self._drag_pending = None
|
|
441
|
+
if self._blitter.active: # the release event never arrived
|
|
442
|
+
self._end_drag()
|
|
443
|
+
return False
|
|
444
|
+
if self._blitter.active:
|
|
445
|
+
if slider is self._dragged:
|
|
446
|
+
return True
|
|
447
|
+
# A stale drag after a lost release, and another slider pressed:
|
|
448
|
+
# blitting on would keep the new slider frozen in the background.
|
|
449
|
+
self._end_drag()
|
|
450
|
+
if self._drag_pending is not slider:
|
|
451
|
+
self._drag_pending = slider
|
|
452
|
+
return False
|
|
453
|
+
self._dragged = slider
|
|
454
|
+
self._blitter.begin(slider.ax)
|
|
455
|
+
return True
|
|
456
|
+
|
|
457
|
+
def _present(self, dragging: bool, static_changed: bool) -> None:
|
|
458
|
+
"""Put the mutated artists on screen: a full draw, or a blitted drag frame.
|
|
459
|
+
|
|
460
|
+
static_changed says whether this update touched the static layer (the
|
|
461
|
+
cross-section title or the overlay legend); such a frame cannot blit.
|
|
462
|
+
"""
|
|
463
|
+
if not dragging:
|
|
464
|
+
# visible_only: the hidden neck line keeps stale data by design.
|
|
465
|
+
for ax in self._data_axes:
|
|
388
466
|
ax.relim(visible_only=True)
|
|
389
467
|
ax.autoscale_view()
|
|
390
|
-
|
|
468
|
+
self.fig.canvas.draw_idle()
|
|
469
|
+
return
|
|
470
|
+
# Dragging: the view holds still, because rescaling changes the tick
|
|
471
|
+
# labels and with them the cached background.
|
|
472
|
+
for ax in self._data_axes:
|
|
473
|
+
ax.relim(visible_only=True)
|
|
474
|
+
if not all(self._view_holds(ax) for ax in self._data_axes):
|
|
475
|
+
for ax in self._data_axes:
|
|
476
|
+
ax.set_xmargin(DRAG_MARGIN)
|
|
477
|
+
ax.set_ymargin(DRAG_MARGIN)
|
|
478
|
+
ax.autoscale_view()
|
|
479
|
+
static_changed = True
|
|
480
|
+
if static_changed or not self._blitter.ready:
|
|
481
|
+
# Until that draw has run, later motion events land here again and
|
|
482
|
+
# re-request it (coalesced) instead of blitting a stale background.
|
|
483
|
+
self._blitter.invalidate()
|
|
484
|
+
self.fig.canvas.draw_idle()
|
|
485
|
+
else:
|
|
486
|
+
self._blitter.blit()
|
|
487
|
+
|
|
488
|
+
@staticmethod
|
|
489
|
+
def _view_holds(ax: Axes) -> bool:
|
|
490
|
+
"""True when the data limits lie inside the view on every autoscaled axis."""
|
|
491
|
+
data, view = ax.dataLim, ax.viewLim
|
|
492
|
+
x0, x1 = sorted(view.intervalx)
|
|
493
|
+
y0, y1 = sorted(view.intervaly)
|
|
494
|
+
holds_x = not ax.get_autoscalex_on() or (x0 <= data.x0 and data.x1 <= x1)
|
|
495
|
+
holds_y = not ax.get_autoscaley_on() or (y0 <= data.y0 and data.y1 <= y1)
|
|
496
|
+
return holds_x and holds_y
|
|
497
|
+
|
|
498
|
+
def _end_drag(self) -> None:
|
|
499
|
+
"""Leave drag mode and restore the tight view the non-drag path draws."""
|
|
500
|
+
self._blitter.end()
|
|
501
|
+
for ax in self._data_axes:
|
|
502
|
+
xmargin, ymargin = self._rest_margins[ax]
|
|
503
|
+
ax.set_xmargin(xmargin)
|
|
504
|
+
ax.set_ymargin(ymargin)
|
|
505
|
+
ax.relim(visible_only=True)
|
|
506
|
+
ax.autoscale_view()
|
|
507
|
+
|
|
508
|
+
def _on_release(self, _event) -> None:
|
|
509
|
+
self._drag_pending = None
|
|
510
|
+
if self._blitter is not None and self._blitter.active:
|
|
511
|
+
self._end_drag()
|
|
512
|
+
self.fig.canvas.draw_idle()
|
|
391
513
|
|
|
392
514
|
def _apply_validity(self, ok: bool) -> None:
|
|
393
515
|
# Color/alpha churn only on the valid<->invalid flip, not per frame.
|
|
@@ -52,6 +52,13 @@ class IntTextBox:
|
|
|
52
52
|
# keeping the intended stop-typing-on-resize behavior.
|
|
53
53
|
fig.canvas.mpl_disconnect(self.box._cids[-1])
|
|
54
54
|
fig.canvas.mpl_connect("resize_event", lambda _e: self.box.stop_typing())
|
|
55
|
+
# TextBox.stop_typing() redraws the whole figure on every mouse press
|
|
56
|
+
# outside the box, typing or not. With the Z and N boxes that is two
|
|
57
|
+
# full redraws per click anywhere in the figure, so only stop when
|
|
58
|
+
# the box is actually being typed in.
|
|
59
|
+
stop_typing = self.box.stop_typing
|
|
60
|
+
self.box.stop_typing = (
|
|
61
|
+
lambda: stop_typing() if self.box.capturekeystrokes else None)
|
|
55
62
|
self.value = int(initial)
|
|
56
63
|
self._on_change = on_change
|
|
57
64
|
self.box.on_submit(self._submit)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shape-plotters
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Interactive matplotlib plotters for axially symmetric nuclear shapes (beta, FoS)
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/AleksanderAugustyn/shape-plotters
|
|
@@ -53,6 +53,10 @@ boxes (R₀ = 1.16 A^(1/3) fm), Reset, and Save (300 dpi PNG named after the
|
|
|
53
53
|
parameters, written to the current directory). Red dotted marks on the FoS
|
|
54
54
|
sliders are practical limits.
|
|
55
55
|
|
|
56
|
+
While a slider is dragged the axes hold still, so the shape visibly changes
|
|
57
|
+
instead of the axes rescaling around it; they refit when the mouse is
|
|
58
|
+
released. If the shape outgrows the view mid-drag, the view jumps out once.
|
|
59
|
+
|
|
56
60
|
## Requirements
|
|
57
61
|
|
|
58
62
|
- Linux x86-64. The parameterization libraries ship manylinux wheels only;
|
|
@@ -16,6 +16,7 @@ shape_plotters.egg-info/requires.txt
|
|
|
16
16
|
shape_plotters.egg-info/scm_file_list.json
|
|
17
17
|
shape_plotters.egg-info/top_level.txt
|
|
18
18
|
shape_plotters/core/__init__.py
|
|
19
|
+
shape_plotters/core/blit.py
|
|
19
20
|
shape_plotters/core/energy.py
|
|
20
21
|
shape_plotters/core/engine.py
|
|
21
22
|
shape_plotters/core/fragments.py
|
|
@@ -28,11 +29,14 @@ shape_plotters/renders/__init__.py
|
|
|
28
29
|
shape_plotters/renders/beta.py
|
|
29
30
|
shape_plotters/renders/fos.py
|
|
30
31
|
tests/__init__.py
|
|
32
|
+
tests/dragging.py
|
|
31
33
|
tests/test_beta_render.py
|
|
34
|
+
tests/test_blit.py
|
|
32
35
|
tests/test_cli.py
|
|
33
36
|
tests/test_energy.py
|
|
34
37
|
tests/test_energy_local.py
|
|
35
38
|
tests/test_engine.py
|
|
39
|
+
tests/test_engine_drag.py
|
|
36
40
|
tests/test_fos_render.py
|
|
37
41
|
tests/test_fragments.py
|
|
38
42
|
tests/test_neck.py
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"shape_plotters/__main__.py",
|
|
12
12
|
"shape_plotters/cli.py",
|
|
13
13
|
"shape_plotters/core/__init__.py",
|
|
14
|
+
"shape_plotters/core/blit.py",
|
|
14
15
|
"shape_plotters/core/energy.py",
|
|
15
16
|
"shape_plotters/core/engine.py",
|
|
16
17
|
"shape_plotters/core/fragments.py",
|
|
@@ -23,11 +24,14 @@
|
|
|
23
24
|
"shape_plotters/renders/beta.py",
|
|
24
25
|
"shape_plotters/renders/fos.py",
|
|
25
26
|
"tests/__init__.py",
|
|
27
|
+
"tests/dragging.py",
|
|
26
28
|
"tests/test_beta_render.py",
|
|
29
|
+
"tests/test_blit.py",
|
|
27
30
|
"tests/test_cli.py",
|
|
28
31
|
"tests/test_energy.py",
|
|
29
32
|
"tests/test_energy_local.py",
|
|
30
33
|
"tests/test_engine.py",
|
|
34
|
+
"tests/test_engine_drag.py",
|
|
31
35
|
"tests/test_fos_render.py",
|
|
32
36
|
"tests/test_fragments.py",
|
|
33
37
|
"tests/test_neck.py",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Simulated slider drags for tests: real MouseEvents on the slider axes.
|
|
2
|
+
|
|
3
|
+
Under Agg, draw_idle() draws immediately, so counting draw_event callbacks
|
|
4
|
+
counts full-figure redraws.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from matplotlib.backend_bases import MouseEvent
|
|
10
|
+
|
|
11
|
+
from shape_plotters.core.engine import ShapePlotterApp
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def steps(start: float, stop: float, step: float) -> list[float]:
|
|
15
|
+
"""Inclusive slider values from start to stop, on the 0.01 slider grid."""
|
|
16
|
+
count = round((stop - start) / step)
|
|
17
|
+
return [round(start + i * step, 2) for i in range(count + 1)]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _mouse(app: ShapePlotterApp, name: str, key: str, value: float) -> None:
|
|
21
|
+
slider = app.rows[key].slider
|
|
22
|
+
x, y = slider.ax.transData.transform((value, 0.5))
|
|
23
|
+
canvas = app.fig.canvas
|
|
24
|
+
canvas.callbacks.process(name, MouseEvent(name, canvas, x, y, button=1))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def press(app: ShapePlotterApp, key: str, value: float) -> None:
|
|
28
|
+
_mouse(app, "button_press_event", key, value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def move(app: ShapePlotterApp, key: str, value: float) -> None:
|
|
32
|
+
_mouse(app, "motion_notify_event", key, value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def release(app: ShapePlotterApp, key: str) -> None:
|
|
36
|
+
_mouse(app, "button_release_event", key, app.rows[key].slider.val)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def begin_drag(app: ShapePlotterApp, key: str, values: list[float]) -> list[float]:
|
|
40
|
+
"""Press and move until frames are being blitted; returns the unused values.
|
|
41
|
+
|
|
42
|
+
The press and the first motion are either drawn the ordinary way or start
|
|
43
|
+
drag mode, depending on whether the press itself moved the slider. After
|
|
44
|
+
two motions the background is captured either way.
|
|
45
|
+
"""
|
|
46
|
+
press(app, key, values[0])
|
|
47
|
+
move(app, key, values[1])
|
|
48
|
+
move(app, key, values[2])
|
|
49
|
+
assert app._blitter.active and app._blitter.ready
|
|
50
|
+
return values[3:]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def pixels(app: ShapePlotterApp) -> np.ndarray:
|
|
54
|
+
return np.asarray(app.fig.canvas.buffer_rgba()).copy()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def count_draws(app: ShapePlotterApp) -> list[int]:
|
|
58
|
+
"""A list that grows by one on every full-figure redraw from now on."""
|
|
59
|
+
draws: list[int] = []
|
|
60
|
+
app.fig.canvas.mpl_connect("draw_event", lambda _event: draws.append(1))
|
|
61
|
+
return draws
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def animated(app: ShapePlotterApp) -> list:
|
|
65
|
+
"""Artists still flagged animated: must be empty outside a drag."""
|
|
66
|
+
candidates = (*app._dynamic, *(row.slider.ax for row in app.rows.values()))
|
|
67
|
+
return [artist for artist in candidates if artist.get_animated()]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def data_inside_view(app: ShapePlotterApp) -> bool:
|
|
71
|
+
"""True when every visible line on the data panels lies inside its axes limits."""
|
|
72
|
+
for ax in app._data_axes:
|
|
73
|
+
x0, x1 = sorted(ax.get_xlim())
|
|
74
|
+
y0, y1 = sorted(ax.get_ylim())
|
|
75
|
+
for line in ax.lines:
|
|
76
|
+
x = np.asarray(line.get_xdata(), dtype=float)
|
|
77
|
+
y = np.asarray(line.get_ydata(), dtype=float)
|
|
78
|
+
if not line.get_visible() or x.size == 0:
|
|
79
|
+
continue
|
|
80
|
+
if x.min() < x0 or x.max() > x1 or y.min() < y0 or y.max() > y1:
|
|
81
|
+
return False
|
|
82
|
+
return True
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""DragBlitter on a bare figure: background capture, frame composition, cleanup."""
|
|
2
|
+
import io
|
|
3
|
+
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from shape_plotters.core.blit import DragBlitter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _pixels(fig) -> np.ndarray:
|
|
12
|
+
return np.asarray(fig.canvas.buffer_rgba()).copy()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def scene():
|
|
17
|
+
"""A plot axes with one dynamic line, and a small axes standing in for a slider."""
|
|
18
|
+
fig = plt.figure(figsize=(6, 4))
|
|
19
|
+
ax = fig.add_axes((0.1, 0.3, 0.8, 0.6))
|
|
20
|
+
(line,) = ax.plot([0.0, 1.0], [0.0, 1.0], lw=2)
|
|
21
|
+
ax.set_xlim(0.0, 1.0)
|
|
22
|
+
ax.set_ylim(-1.5, 1.5)
|
|
23
|
+
knob_ax = fig.add_axes((0.1, 0.1, 0.8, 0.05))
|
|
24
|
+
(knob,) = knob_ax.plot([0.2], [0.5], marker="o")
|
|
25
|
+
knob_ax.set_xlim(0.0, 1.0)
|
|
26
|
+
knob_ax.set_ylim(0.0, 1.0)
|
|
27
|
+
blitter = DragBlitter(fig, [line])
|
|
28
|
+
fig.canvas.draw()
|
|
29
|
+
yield fig, ax, line, knob_ax, knob, blitter
|
|
30
|
+
plt.close(fig)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_blitted_frame_equals_full_redraw(scene) -> None:
|
|
34
|
+
fig, _ax, line, knob_ax, knob, blitter = scene
|
|
35
|
+
blitter.begin(knob_ax)
|
|
36
|
+
fig.canvas.draw() # captures the static background
|
|
37
|
+
before = _pixels(fig)
|
|
38
|
+
line.set_ydata([1.0, -1.0]) # the dynamic layer changes
|
|
39
|
+
knob.set_xdata([0.8]) # and so does the dragged control
|
|
40
|
+
blitter.blit()
|
|
41
|
+
blitted = _pixels(fig)
|
|
42
|
+
fig.canvas.draw()
|
|
43
|
+
assert (blitted != before).any() # the frame really changed
|
|
44
|
+
assert np.array_equal(blitted, _pixels(fig))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_background_exists_only_after_a_draw_in_drag_mode(scene) -> None:
|
|
48
|
+
fig, _ax, _line, knob_ax, _knob, blitter = scene
|
|
49
|
+
assert not blitter.active and not blitter.ready # the fixture's draw captured nothing
|
|
50
|
+
blitter.begin(knob_ax)
|
|
51
|
+
assert blitter.active and not blitter.ready
|
|
52
|
+
fig.canvas.draw()
|
|
53
|
+
assert blitter.ready
|
|
54
|
+
blitter.invalidate()
|
|
55
|
+
assert not blitter.ready
|
|
56
|
+
fig.canvas.draw()
|
|
57
|
+
assert blitter.ready
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_foreign_draw_mid_drag_recaptures_background(scene) -> None:
|
|
61
|
+
fig, ax, line, knob_ax, _knob, blitter = scene
|
|
62
|
+
blitter.begin(knob_ax)
|
|
63
|
+
fig.canvas.draw()
|
|
64
|
+
ax.set_title("static change") # stands in for a resize or expose
|
|
65
|
+
fig.canvas.draw() # a full draw the blitter did not ask for
|
|
66
|
+
line.set_ydata([1.0, -1.0])
|
|
67
|
+
blitter.blit()
|
|
68
|
+
blitted = _pixels(fig)
|
|
69
|
+
fig.canvas.draw()
|
|
70
|
+
assert np.array_equal(blitted, _pixels(fig)) # the title is in the blitted frame
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_end_restores_ordinary_drawing(scene) -> None:
|
|
74
|
+
fig, _ax, line, knob_ax, _knob, blitter = scene
|
|
75
|
+
reference = _pixels(fig) # drawn before any drag
|
|
76
|
+
blitter.begin(knob_ax)
|
|
77
|
+
fig.canvas.draw()
|
|
78
|
+
blitter.end()
|
|
79
|
+
fig.canvas.draw()
|
|
80
|
+
assert not blitter.active and not blitter.ready
|
|
81
|
+
assert not line.get_animated() and not knob_ax.get_animated()
|
|
82
|
+
assert np.array_equal(_pixels(fig), reference)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_export_mid_drag_is_complete_and_drops_the_background(scene) -> None:
|
|
86
|
+
# savefig can run while a drag is active (the toolbar's save key, or a
|
|
87
|
+
# Save click after a lost release). It is not a screen draw: it must not
|
|
88
|
+
# become the background, and the exported file must not miss anything.
|
|
89
|
+
fig, _ax, line, knob_ax, _knob, blitter = scene
|
|
90
|
+
|
|
91
|
+
def export(fmt: str) -> bytes:
|
|
92
|
+
buffer = io.BytesIO()
|
|
93
|
+
fig.savefig(buffer, format=fmt, dpi=100)
|
|
94
|
+
return buffer.getvalue()
|
|
95
|
+
|
|
96
|
+
reference = export("rgba") # saved before any drag
|
|
97
|
+
blitter.begin(knob_ax)
|
|
98
|
+
fig.canvas.draw()
|
|
99
|
+
assert export("rgba") == reference # line and slider axes present, each drawn once
|
|
100
|
+
export("pdf") # a canvas that cannot copy regions
|
|
101
|
+
assert not blitter.ready # the export replaced the canvas contents
|
|
102
|
+
fig.canvas.draw()
|
|
103
|
+
line.set_ydata([1.0, -1.0])
|
|
104
|
+
blitter.blit()
|
|
105
|
+
blitted = _pixels(fig)
|
|
106
|
+
fig.canvas.draw()
|
|
107
|
+
assert np.array_equal(blitted, _pixels(fig))
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Slider drags: blitted frames, the held view, and the return to ordinary drawing."""
|
|
2
|
+
import io
|
|
3
|
+
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
from matplotlib.backend_bases import ResizeEvent
|
|
8
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
9
|
+
|
|
10
|
+
from shape_plotters.core.engine import ShapePlotterApp
|
|
11
|
+
from shape_plotters.renders.beta import BetaRender
|
|
12
|
+
from shape_plotters.renders.fos import FoSRender
|
|
13
|
+
from tests.dragging import (animated, begin_drag, count_draws, data_inside_view, move,
|
|
14
|
+
pixels, press, release, steps)
|
|
15
|
+
|
|
16
|
+
# Drags that shrink the shape: the data never leaves the view and neither the
|
|
17
|
+
# title nor the overlay legend changes, so every frame after the start can blit.
|
|
18
|
+
SHRINKING = [
|
|
19
|
+
pytest.param(BetaRender, {"beta2": 1.0}, "beta2", (1.0, 0.85, -0.01), id="beta"),
|
|
20
|
+
pytest.param(FoSRender, {"c": 1.6, "a4": 0.3}, "a4", (0.3, 0.15, -0.01), id="fos"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def make_app():
|
|
26
|
+
apps: list[ShapePlotterApp] = []
|
|
27
|
+
|
|
28
|
+
def make(render_cls, **presets: float) -> ShapePlotterApp:
|
|
29
|
+
app = ShapePlotterApp(render_cls())
|
|
30
|
+
for key, value in presets.items():
|
|
31
|
+
app.rows[key].slider.set_val(value)
|
|
32
|
+
app.fig.canvas.draw()
|
|
33
|
+
apps.append(app)
|
|
34
|
+
return app
|
|
35
|
+
|
|
36
|
+
yield make
|
|
37
|
+
for app in apps:
|
|
38
|
+
plt.close(app.fig)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------- a click is not a drag ----------
|
|
42
|
+
|
|
43
|
+
def test_click_costs_one_full_draw_and_is_not_a_drag(make_app) -> None:
|
|
44
|
+
app = make_app(BetaRender)
|
|
45
|
+
draws = count_draws(app)
|
|
46
|
+
press(app, "beta2", 0.3)
|
|
47
|
+
release(app, "beta2")
|
|
48
|
+
assert len(draws) == 1
|
|
49
|
+
assert not app._blitter.active and animated(app) == []
|
|
50
|
+
reference = make_app(BetaRender, beta2=0.3)
|
|
51
|
+
assert np.array_equal(pixels(app), pixels(reference))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------- blitted frames ----------
|
|
55
|
+
|
|
56
|
+
@pytest.mark.parametrize("render_cls, presets, key, span", SHRINKING)
|
|
57
|
+
def test_drag_frames_are_blitted_not_redrawn(make_app, render_cls, presets, key, span) -> None:
|
|
58
|
+
app = make_app(render_cls, **presets)
|
|
59
|
+
rest = begin_drag(app, key, steps(*span))
|
|
60
|
+
draws = count_draws(app)
|
|
61
|
+
for value in rest:
|
|
62
|
+
move(app, key, value)
|
|
63
|
+
assert draws == [] # no full-figure redraw
|
|
64
|
+
assert app.rows[key].slider.val == pytest.approx(span[1])
|
|
65
|
+
assert np.array_equal(app.r_line.get_ydata(),
|
|
66
|
+
app.last_result.radius * app._scale())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.parametrize("render_cls, presets, key, span", SHRINKING + [
|
|
70
|
+
# The neck line disappears on the way: a dynamic artist turning invisible.
|
|
71
|
+
pytest.param(BetaRender, {"beta2": 1.2}, "beta2", (1.2, 0.4, -0.04),
|
|
72
|
+
id="beta-neck-vanishes"),
|
|
73
|
+
])
|
|
74
|
+
def test_every_blitted_frame_equals_a_full_redraw(
|
|
75
|
+
make_app, render_cls, presets, key, span) -> None:
|
|
76
|
+
app = make_app(render_cls, **presets)
|
|
77
|
+
for value in begin_drag(app, key, steps(*span)):
|
|
78
|
+
move(app, key, value)
|
|
79
|
+
blitted = pixels(app)
|
|
80
|
+
app.fig.canvas.draw()
|
|
81
|
+
assert np.array_equal(blitted, pixels(app)), f"frame at {key}={value}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_widgets_look_the_same_in_blitted_frames(make_app) -> None:
|
|
85
|
+
# Everything below the panels: the dragged slider (handle, fill, value
|
|
86
|
+
# text), the other sliders, buttons and the check marks, which CheckButtons
|
|
87
|
+
# draws through its own blit machinery. A blitted frame must show them
|
|
88
|
+
# exactly as the full redraw after the release does.
|
|
89
|
+
app = make_app(BetaRender, beta2=1.0)
|
|
90
|
+
for value in begin_drag(app, "beta2", steps(1.0, 0.9, -0.01)):
|
|
91
|
+
move(app, "beta2", value)
|
|
92
|
+
panels = (*app._data_axes, app.ax_stats)
|
|
93
|
+
widgets_top = max(ax.bbox.y1 for ax in app.fig.axes if ax not in panels)
|
|
94
|
+
height = pixels(app).shape[0]
|
|
95
|
+
widget_rows = slice(height - int(widgets_top), height) # image rows run top to bottom
|
|
96
|
+
during = pixels(app)[widget_rows]
|
|
97
|
+
release(app, "beta2")
|
|
98
|
+
assert np.array_equal(during, pixels(app)[widget_rows])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------- leaving drag mode ----------
|
|
102
|
+
|
|
103
|
+
@pytest.mark.parametrize("render_cls, presets, key, span", SHRINKING)
|
|
104
|
+
def test_release_restores_ordinary_drawing(make_app, render_cls, presets, key, span) -> None:
|
|
105
|
+
app = make_app(render_cls, **presets)
|
|
106
|
+
for value in begin_drag(app, key, steps(*span)):
|
|
107
|
+
move(app, key, value)
|
|
108
|
+
release(app, key)
|
|
109
|
+
assert not app._blitter.active and animated(app) == []
|
|
110
|
+
reference = make_app(render_cls, **{**presets, key: span[1]})
|
|
111
|
+
assert np.array_equal(pixels(app), pixels(reference))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_updates_outside_a_drag_never_enter_drag_mode(make_app) -> None:
|
|
115
|
+
app = make_app(BetaRender)
|
|
116
|
+
app.rows["beta2"].slider.set_val(0.4) # programmatic
|
|
117
|
+
app.rows["beta2"]._nudge(+0.01) # nudge button
|
|
118
|
+
app._on_check("fm units") # unit toggle
|
|
119
|
+
app._reset() # Reset button
|
|
120
|
+
assert not app._blitter.active and animated(app) == []
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------- the held view ----------
|
|
124
|
+
|
|
125
|
+
GROWING = [
|
|
126
|
+
pytest.param(BetaRender, "beta2", (0.0, 1.5, 0.01), id="beta"),
|
|
127
|
+
pytest.param(FoSRender, "c", (1.0, 2.5, 0.01), id="fos"),
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.parametrize("render_cls, key, span", GROWING)
|
|
132
|
+
def test_growing_drag_refits_with_headroom(make_app, render_cls, key, span) -> None:
|
|
133
|
+
app = make_app(render_cls)
|
|
134
|
+
draws = count_draws(app)
|
|
135
|
+
values = steps(*span)
|
|
136
|
+
press(app, key, values[0])
|
|
137
|
+
for value in values[1:]:
|
|
138
|
+
move(app, key, value)
|
|
139
|
+
assert data_inside_view(app), f"shape clipped at {key}={value}"
|
|
140
|
+
# 150 steps of growth: a handful of refits, not one redraw per step.
|
|
141
|
+
assert 3 <= len(draws) <= 10
|
|
142
|
+
release(app, key)
|
|
143
|
+
reference = make_app(render_cls, **{key: span[1]})
|
|
144
|
+
assert ([ax.margins() for ax in app._data_axes]
|
|
145
|
+
== [ax.margins() for ax in reference._data_axes])
|
|
146
|
+
assert np.array_equal(pixels(app), pixels(reference))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.parametrize("presets, key, span", [
|
|
150
|
+
# The invalid-shape banner (the cross-section title) appears at beta4 = -1.2.
|
|
151
|
+
pytest.param({"beta4": -1.0}, "beta4", (-1.0, -1.3, -0.02), id="title"),
|
|
152
|
+
# The overlay legend hides at beta3 = 0 and comes back at 0.01.
|
|
153
|
+
pytest.param({"beta2": 0.5, "beta3": -0.05}, "beta3", (-0.05, 0.05, 0.01), id="legend"),
|
|
154
|
+
])
|
|
155
|
+
def test_static_change_mid_drag_reaches_the_frame(make_app, presets, key, span) -> None:
|
|
156
|
+
app = make_app(BetaRender, **presets)
|
|
157
|
+
|
|
158
|
+
def static_state() -> tuple[str, bool]:
|
|
159
|
+
return app.ax_shape.get_title(), app.shape_legend.get_visible()
|
|
160
|
+
|
|
161
|
+
seen = {static_state()}
|
|
162
|
+
for value in begin_drag(app, key, steps(*span)):
|
|
163
|
+
move(app, key, value)
|
|
164
|
+
seen.add(static_state())
|
|
165
|
+
frame = pixels(app)
|
|
166
|
+
app.fig.canvas.draw()
|
|
167
|
+
assert np.array_equal(frame, pixels(app)), f"stale static layer at {key}={value}"
|
|
168
|
+
assert len(seen) > 1 # the fixture really changed the title or the legend
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_drag_ending_on_an_invalid_shape_releases_cleanly(make_app) -> None:
|
|
172
|
+
app = make_app(BetaRender, beta4=-1.0)
|
|
173
|
+
for value in begin_drag(app, "beta4", steps(-1.0, -1.3, -0.02)):
|
|
174
|
+
move(app, "beta4", value)
|
|
175
|
+
assert not app.last_result.ok
|
|
176
|
+
release(app, "beta4")
|
|
177
|
+
reference = make_app(BetaRender, beta4=-1.3)
|
|
178
|
+
assert np.array_equal(pixels(app), pixels(reference))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------- robustness ----------
|
|
182
|
+
|
|
183
|
+
def test_lost_release_ends_drag_mode_on_the_next_update(make_app) -> None:
|
|
184
|
+
app = make_app(BetaRender, beta2=1.0)
|
|
185
|
+
for value in begin_drag(app, "beta2", steps(1.0, 0.9, -0.01)):
|
|
186
|
+
move(app, "beta2", value)
|
|
187
|
+
app.rows["beta2"].slider.drag_active = False # the release event never arrived
|
|
188
|
+
app.rows["beta3"].slider.set_val(0.1) # any later update
|
|
189
|
+
assert not app._blitter.active and animated(app) == []
|
|
190
|
+
reference = make_app(BetaRender, beta2=0.9, beta3=0.1)
|
|
191
|
+
assert np.array_equal(pixels(app), pixels(reference))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_drag_on_another_slider_after_a_lost_release_moves_that_slider(make_app) -> None:
|
|
195
|
+
# beta2's release never arrived, so it is still drag_active when beta3 is
|
|
196
|
+
# pressed. Drag mode must follow beta3: blitting with beta2's axes
|
|
197
|
+
# animated would leave beta3's handle frozen in the background.
|
|
198
|
+
app = make_app(BetaRender, beta2=1.0)
|
|
199
|
+
for value in begin_drag(app, "beta2", steps(1.0, 0.9, -0.01)):
|
|
200
|
+
move(app, "beta2", value)
|
|
201
|
+
values = steps(0.0, 0.1, 0.01)
|
|
202
|
+
press(app, "beta3", values[0])
|
|
203
|
+
for value in values[1:]:
|
|
204
|
+
move(app, "beta3", value)
|
|
205
|
+
frame = pixels(app)
|
|
206
|
+
app.fig.canvas.draw()
|
|
207
|
+
assert np.array_equal(frame, pixels(app)), f"frame at beta3={value}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_resize_mid_drag_never_blits_the_old_background(make_app) -> None:
|
|
211
|
+
# A Tk window resize changes the figure size and fires resize_event, but
|
|
212
|
+
# its redraw is only scheduled; a motion event can arrive first and must
|
|
213
|
+
# not blit the old-size background into the new-size canvas.
|
|
214
|
+
app = make_app(BetaRender, beta2=1.0)
|
|
215
|
+
rest = begin_drag(app, "beta2", steps(1.0, 0.9, -0.01))
|
|
216
|
+
width, height = app.fig.get_size_inches()
|
|
217
|
+
app.fig.set_size_inches(width * 1.2, height * 1.2, forward=False)
|
|
218
|
+
ResizeEvent("resize_event", app.fig.canvas)._process()
|
|
219
|
+
for value in rest:
|
|
220
|
+
move(app, "beta2", value)
|
|
221
|
+
frame = pixels(app)
|
|
222
|
+
app.fig.canvas.draw()
|
|
223
|
+
assert np.array_equal(frame, pixels(app)), f"frame at beta2={value}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_export_mid_drag_does_not_poison_later_frames(make_app) -> None:
|
|
227
|
+
app = make_app(BetaRender, beta2=1.0)
|
|
228
|
+
rest = begin_drag(app, "beta2", steps(1.0, 0.9, -0.01))
|
|
229
|
+
app.fig.savefig(io.BytesIO(), format="rgba", dpi=100) # e.g. the toolbar's save key
|
|
230
|
+
for value in rest:
|
|
231
|
+
move(app, "beta2", value)
|
|
232
|
+
frame = pixels(app)
|
|
233
|
+
app.fig.canvas.draw()
|
|
234
|
+
assert np.array_equal(frame, pixels(app)), f"frame at beta2={value}"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_save_button_in_a_stale_drag_saves_the_ordinary_figure(
|
|
238
|
+
make_app, tmp_path, monkeypatch) -> None:
|
|
239
|
+
monkeypatch.chdir(tmp_path) # _save() writes into the cwd
|
|
240
|
+
app = make_app(BetaRender, beta2=1.0)
|
|
241
|
+
for value in begin_drag(app, "beta2", steps(1.0, 0.9, -0.01)):
|
|
242
|
+
move(app, "beta2", value)
|
|
243
|
+
app.rows["beta2"].slider.drag_active = False # the release event never arrived
|
|
244
|
+
app._save()
|
|
245
|
+
assert not app._blitter.active and animated(app) == []
|
|
246
|
+
(png,) = tmp_path.glob("*.png")
|
|
247
|
+
after_drag = png.read_bytes()
|
|
248
|
+
reference = make_app(BetaRender, beta2=0.9)
|
|
249
|
+
reference._save() # same parameters: same file name
|
|
250
|
+
assert after_drag == png.read_bytes()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_canvas_without_blitting_redraws_every_frame(make_app, monkeypatch) -> None:
|
|
254
|
+
monkeypatch.setattr(FigureCanvasAgg, "supports_blit", False)
|
|
255
|
+
app = make_app(BetaRender, beta2=1.0)
|
|
256
|
+
assert app._blitter is None
|
|
257
|
+
draws = count_draws(app)
|
|
258
|
+
values = steps(1.0, 0.9, -0.01)
|
|
259
|
+
press(app, "beta2", values[0]) # on the handle: no value change
|
|
260
|
+
for value in values[1:]:
|
|
261
|
+
move(app, "beta2", value)
|
|
262
|
+
assert len(draws) == len(values) - 1 # one full redraw per motion
|
|
263
|
+
release(app, "beta2")
|
|
264
|
+
assert animated(app) == []
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_dragging_past_the_slider_end_stops_at_the_limit(make_app) -> None:
|
|
268
|
+
app = make_app(BetaRender, beta2=3.9)
|
|
269
|
+
press(app, "beta2", 3.9)
|
|
270
|
+
for value in (3.95, 4.0, 4.3, 4.8): # the last two are off the slider
|
|
271
|
+
move(app, "beta2", value)
|
|
272
|
+
assert app.rows["beta2"].slider.val == pytest.approx(4.0)
|
|
273
|
+
release(app, "beta2")
|
|
274
|
+
assert not app._blitter.active and animated(app) == []
|
|
275
|
+
reference = make_app(BetaRender, beta2=4.0)
|
|
276
|
+
assert np.array_equal(pixels(app), pixels(reference))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_save_after_a_drag_matches_an_ordinary_save(make_app) -> None:
|
|
280
|
+
app = make_app(BetaRender, beta2=1.0)
|
|
281
|
+
for value in begin_drag(app, "beta2", steps(1.0, 0.9, -0.01)):
|
|
282
|
+
move(app, "beta2", value)
|
|
283
|
+
release(app, "beta2")
|
|
284
|
+
reference = make_app(BetaRender, beta2=0.9)
|
|
285
|
+
|
|
286
|
+
def saved(target: ShapePlotterApp) -> bytes:
|
|
287
|
+
buffer = io.BytesIO()
|
|
288
|
+
target.fig.savefig(buffer, format="rgba", dpi=100)
|
|
289
|
+
return buffer.getvalue()
|
|
290
|
+
|
|
291
|
+
assert saved(app) == saved(reference)
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
"""Engine update-loop budgets under Agg (default ext4 venv — see venv caveat).
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Each test prints its figure; `python -m pytest tests/test_perf.py -s` is the
|
|
4
|
+
one-command measurement on any machine. The budgets are calibrated for the
|
|
5
|
+
development PC and may fail on a slower machine; the printed figures are
|
|
6
|
+
still the measurement.
|
|
7
|
+
|
|
8
|
+
Three tiers:
|
|
4
9
|
|
|
5
10
|
- Artist mutation (draw neutered): the clear-and-replot regression guard.
|
|
6
11
|
v0.1 rebuilt every artist per frame: ~15/20 ms (beta/fos). v0.2 persistent
|
|
@@ -9,14 +14,19 @@ Two tiers:
|
|
|
9
14
|
every call resolves from params; the repeated vector is no longer served
|
|
10
15
|
from stored state.
|
|
11
16
|
- One-draw frame (update() alone; under Agg its draw_idle draws immediately):
|
|
12
|
-
loose interactivity backstop. Full-figure
|
|
13
|
-
~73-95 ms
|
|
14
|
-
v0.
|
|
17
|
+
loose interactivity backstop for everything that is not a drag. Full-figure
|
|
18
|
+
rasterization (~35 axes) costs ~73-95 ms; measured v0.1 ~104/135 ms,
|
|
19
|
+
v0.2 ~77/95 ms. Budget 250 ms.
|
|
20
|
+
- Blitted drag step (one slider motion event during a drag): the static layer
|
|
21
|
+
is restored from a cached background and only the data lines, the stats
|
|
22
|
+
text and the dragged slider are drawn. Measured 2026-10-03: ~20-35 ms on
|
|
23
|
+
matplotlib 3.11 (its text rendering dominates), ~10 ms on 3.10. Budget 60 ms.
|
|
15
24
|
|
|
16
25
|
Venv caveat (measured 2026-07-03): identical code is ~5x slower under the
|
|
17
26
|
project-local .venv on /mnt/c (WSL2 9P filesystem). The default ext4 venv
|
|
18
27
|
(/home/alex/.virtualenvs/default) is authoritative for these budgets.
|
|
19
28
|
"""
|
|
29
|
+
import statistics
|
|
20
30
|
import time
|
|
21
31
|
|
|
22
32
|
import pytest
|
|
@@ -24,9 +34,11 @@ import pytest
|
|
|
24
34
|
from shape_plotters.core.engine import ShapePlotterApp
|
|
25
35
|
from shape_plotters.renders.beta import BetaRender
|
|
26
36
|
from shape_plotters.renders.fos import FoSRender
|
|
37
|
+
from tests.dragging import begin_drag, move, release, steps
|
|
27
38
|
|
|
28
39
|
MUTATION_BUDGET_MS = 5.0
|
|
29
40
|
FRAME_BUDGET_MS = 250.0
|
|
41
|
+
DRAG_STEP_BUDGET_MS = 60.0
|
|
30
42
|
|
|
31
43
|
|
|
32
44
|
def _mean_ms(fn, n: int = 10) -> float:
|
|
@@ -45,6 +57,7 @@ def test_artist_mutation_under_budget(render_cls) -> None:
|
|
|
45
57
|
app.fig.canvas.draw() # warm-up: font cache + first layout
|
|
46
58
|
app.fig.canvas.draw_idle = lambda: None # isolate mutation from rasterization
|
|
47
59
|
ms = _mean_ms(app.update)
|
|
60
|
+
print(f"\nPERF artist mutation [{app.render.name}]: {ms:.2f} ms")
|
|
48
61
|
assert ms < MUTATION_BUDGET_MS, f"{ms:.1f} ms/update >= {MUTATION_BUDGET_MS} ms budget"
|
|
49
62
|
finally:
|
|
50
63
|
plt.close(app.fig)
|
|
@@ -59,6 +72,35 @@ def test_frame_time_under_budget(render_cls) -> None:
|
|
|
59
72
|
app.update()
|
|
60
73
|
app.fig.canvas.draw() # warm-up: font cache + first layout
|
|
61
74
|
ms = _mean_ms(app.update) # one full draw per call (immediate under Agg)
|
|
75
|
+
print(f"\nPERF full frame [{app.render.name}]: {ms:.1f} ms")
|
|
62
76
|
assert ms < FRAME_BUDGET_MS, f"{ms:.0f} ms/frame >= {FRAME_BUDGET_MS:.0f} ms budget"
|
|
63
77
|
finally:
|
|
64
78
|
plt.close(app.fig)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.parametrize("render_cls, presets, key, span", [
|
|
82
|
+
# Shrinking drags: no refits, so every timed step is a blitted frame.
|
|
83
|
+
pytest.param(BetaRender, {"beta2": 1.0}, "beta2", (1.0, 0.6, -0.01), id="beta"),
|
|
84
|
+
pytest.param(FoSRender, {"c": 1.6, "a4": 0.3}, "a4", (0.3, -0.1, -0.01), id="fos"),
|
|
85
|
+
])
|
|
86
|
+
def test_drag_step_under_budget(render_cls, presets, key, span) -> None:
|
|
87
|
+
import matplotlib.pyplot as plt
|
|
88
|
+
|
|
89
|
+
app = ShapePlotterApp(render_cls())
|
|
90
|
+
try:
|
|
91
|
+
for name, value in presets.items():
|
|
92
|
+
app.rows[name].slider.set_val(value)
|
|
93
|
+
app.fig.canvas.draw() # warm-up: font cache + first layout
|
|
94
|
+
times_ms = []
|
|
95
|
+
for value in begin_drag(app, key, steps(*span)):
|
|
96
|
+
t0 = time.perf_counter()
|
|
97
|
+
move(app, key, value)
|
|
98
|
+
times_ms.append((time.perf_counter() - t0) * 1000.0)
|
|
99
|
+
release(app, key)
|
|
100
|
+
ms = statistics.median(times_ms)
|
|
101
|
+
print(f"\nPERF blitted drag step [{app.render.name}]: {ms:.1f} ms "
|
|
102
|
+
f"(median of {len(times_ms)})")
|
|
103
|
+
assert ms < DRAG_STEP_BUDGET_MS, (
|
|
104
|
+
f"{ms:.1f} ms/step >= {DRAG_STEP_BUDGET_MS:.0f} ms budget")
|
|
105
|
+
finally:
|
|
106
|
+
plt.close(app.fig)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Widget helpers under the Agg backend (conftest sets MPLBACKEND)."""
|
|
2
2
|
import matplotlib.pyplot as plt
|
|
3
3
|
import pytest
|
|
4
|
+
from matplotlib.backend_bases import MouseEvent
|
|
4
5
|
|
|
5
6
|
from shape_plotters.core.widgets import IntTextBox, SliderRow
|
|
6
7
|
|
|
@@ -12,6 +13,14 @@ def fig():
|
|
|
12
13
|
plt.close(f)
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
def _press(fig, x_frac: float, y_frac: float) -> None:
|
|
17
|
+
"""Deliver a left-button press at a position given in figure fractions."""
|
|
18
|
+
width, height = fig.canvas.get_width_height()
|
|
19
|
+
event = MouseEvent("button_press_event", fig.canvas,
|
|
20
|
+
x_frac * width, y_frac * height, button=1)
|
|
21
|
+
fig.canvas.callbacks.process("button_press_event", event)
|
|
22
|
+
|
|
23
|
+
|
|
15
24
|
def test_slider_row_nudges_within_range(fig) -> None:
|
|
16
25
|
row = SliderRow(fig, 0.5, "b2", 0.0, 4.0, 0.0, 0.01)
|
|
17
26
|
row._nudge(+0.01)
|
|
@@ -48,3 +57,25 @@ def test_int_textbox_accepts_and_reverts(fig) -> None:
|
|
|
48
57
|
assert box.value == 96 # reverted
|
|
49
58
|
box._submit("-3")
|
|
50
59
|
assert box.value == 96 # positive ints only
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_press_elsewhere_does_not_redraw_when_not_typing(fig) -> None:
|
|
63
|
+
# TextBox.stop_typing() does a full canvas.draw() on every press outside
|
|
64
|
+
# the box. With the Z and N boxes that was two full redraws per click
|
|
65
|
+
# anywhere in the figure.
|
|
66
|
+
IntTextBox(fig, (0.1, 0.1, 0.1, 0.05), "Z", 92, lambda: None)
|
|
67
|
+
fig.canvas.draw()
|
|
68
|
+
draws: list[int] = []
|
|
69
|
+
fig.canvas.mpl_connect("draw_event", lambda _event: draws.append(1))
|
|
70
|
+
_press(fig, 0.8, 0.8) # far from the box
|
|
71
|
+
assert draws == []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_press_elsewhere_still_ends_typing(fig) -> None:
|
|
75
|
+
calls: list[int] = []
|
|
76
|
+
box = IntTextBox(fig, (0.1, 0.1, 0.1, 0.05), "Z", 92, lambda: calls.append(1))
|
|
77
|
+
fig.canvas.draw()
|
|
78
|
+
box.box.begin_typing()
|
|
79
|
+
_press(fig, 0.8, 0.8)
|
|
80
|
+
assert not box.box.capturekeystrokes # typing ended
|
|
81
|
+
assert calls # and the text was submitted
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|