plotline 0.1.0.dev0__cp311-abi3-win_amd64.whl
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.
- plotline/__init__.py +30 -0
- plotline/_core.pyd +0 -0
- plotline/_core.pyi +58 -0
- plotline/cli/__init__.py +0 -0
- plotline/cli/main.py +294 -0
- plotline/mcp_server.py +313 -0
- plotline/py.typed +0 -0
- plotline/registry.py +16 -0
- plotline/session.py +400 -0
- plotline/types.py +130 -0
- plotline/web/THIRD-PARTY-LICENSES.txt +1492 -0
- plotline/web/assets/index-B3NFwj0a.css +1 -0
- plotline/web/assets/index-BcHuSLsL.js +1 -0
- plotline/web/assets/plotline_wasm_bg-BxSp_00A.wasm +0 -0
- plotline/web/assets/worker-DB9SFtUV.js +1 -0
- plotline/web/index.html +15 -0
- plotline/web/lucide-preact-LICENSE.txt +43 -0
- plotline/web/preact-LICENSE.txt +21 -0
- plotline-0.1.0.dev0.dist-info/METADATA +159 -0
- plotline-0.1.0.dev0.dist-info/RECORD +24 -0
- plotline-0.1.0.dev0.dist-info/WHEEL +4 -0
- plotline-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- plotline-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- plotline-0.1.0.dev0.dist-info/sboms/plotline-py.cyclonedx.json +2984 -0
plotline/session.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""The session: one working context over the engine's session, with the side effects the engine
|
|
2
|
+
leaves to its host.
|
|
3
|
+
|
|
4
|
+
The engine (``plotline._core``) keeps the source image, the canvas, the pens, the chosen style with
|
|
5
|
+
its settings, the plot speeds, the stage cache and the latest drawing, and checks every change. This
|
|
6
|
+
module adds what a Python host does: it reads and writes files, drives a run on a worker thread or
|
|
7
|
+
in the caller's, passes frames to subscribers at a capped rate, and turns the engine's dicts and
|
|
8
|
+
flat path buffers into the dataclasses of ``plotline.types``. The CLI, the GUI and the MCP server
|
|
9
|
+
call this layer and nothing below it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import warnings
|
|
18
|
+
from collections.abc import Callable, Sequence
|
|
19
|
+
from dataclasses import asdict
|
|
20
|
+
from itertools import pairwise
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, TypeVar
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
from numpy.typing import NDArray
|
|
26
|
+
|
|
27
|
+
from plotline import _core
|
|
28
|
+
from plotline.registry import get_style
|
|
29
|
+
from plotline.types import (
|
|
30
|
+
Cancelled,
|
|
31
|
+
Canvas,
|
|
32
|
+
Drawing,
|
|
33
|
+
Frame,
|
|
34
|
+
Layer,
|
|
35
|
+
Pen,
|
|
36
|
+
RunState,
|
|
37
|
+
Speeds,
|
|
38
|
+
Stats,
|
|
39
|
+
Style,
|
|
40
|
+
ToneMap,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
T = TypeVar("T")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _split(coords: NDArray[np.float64], offsets: NDArray[np.uint32]) -> list[NDArray[np.float64]]:
|
|
47
|
+
"""The paths of a flat buffer as ``(n, 2)`` views: path ``i`` is
|
|
48
|
+
``coords[offsets[i]:offsets[i + 1]]``."""
|
|
49
|
+
return [coords[a:b] for a, b in pairwise(offsets.tolist())]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _frame(f: dict[str, Any]) -> Frame:
|
|
53
|
+
coords = f["coords"]
|
|
54
|
+
paths = None if coords is None else _split(coords, f["offsets"])
|
|
55
|
+
return Frame(f["stage"], f["progress"], f["label"], f["points"], paths, f["pen"])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _drawing(d: dict[str, Any]) -> Drawing:
|
|
59
|
+
layers = [Layer(Pen(**x["pen"]), _split(x["coords"], x["offsets"])) for x in d["layers"]]
|
|
60
|
+
tone = None if d["tone"] is None else ToneMap(**d["tone"])
|
|
61
|
+
return Drawing(Canvas(**d["canvas"]), layers, tone)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _have(value: T | None) -> T:
|
|
65
|
+
if value is None:
|
|
66
|
+
raise RuntimeError("there is no result yet; run the style first")
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RunHandle:
|
|
71
|
+
"""One run of a style, as ``Session.run`` or ``Session.run_blocking`` makes it.
|
|
72
|
+
|
|
73
|
+
``state`` starts as ``running`` and ends as ``done``, ``cancelled`` or ``error``. ``result``
|
|
74
|
+
is set when the run is ``done`` and ``error`` holds the exception when it ended in ``error``:
|
|
75
|
+
a ``RuntimeError`` with the engine's message for a failed stage, or what a subscriber raised.
|
|
76
|
+
All of them can be read from any thread. A handle from ``run_blocking`` has no thread, so it
|
|
77
|
+
is finished by the time the caller has it.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, run: _core.Run) -> None:
|
|
81
|
+
self._run = run
|
|
82
|
+
self._thread: threading.Thread | None = None
|
|
83
|
+
self._state = RunState.running
|
|
84
|
+
self._result: Drawing | None = None
|
|
85
|
+
self._error: BaseException | None = None
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def state(self) -> RunState:
|
|
89
|
+
return self._state
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def result(self) -> Drawing | None:
|
|
93
|
+
return self._result
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def error(self) -> BaseException | None:
|
|
97
|
+
return self._error
|
|
98
|
+
|
|
99
|
+
def cancel(self) -> None:
|
|
100
|
+
"""Ask the run to stop; the stage under way sees it at its next check. A run that has
|
|
101
|
+
already finished stays as it ended."""
|
|
102
|
+
self._run.cancel()
|
|
103
|
+
|
|
104
|
+
def wait(self, timeout: float | None = None) -> RunState:
|
|
105
|
+
"""Block until the run has ended, or for ``timeout`` seconds at most, and return
|
|
106
|
+
``state``, which is still ``running`` after a timeout. A subscriber runs on the worker
|
|
107
|
+
thread, which cannot wait for itself, so a call from there raises ``RuntimeError``
|
|
108
|
+
instead of hanging."""
|
|
109
|
+
if self._thread is threading.current_thread():
|
|
110
|
+
raise RuntimeError("a run cannot wait for itself: wait() was called on its own thread")
|
|
111
|
+
if self._thread is not None:
|
|
112
|
+
self._thread.join(timeout)
|
|
113
|
+
return self._state
|
|
114
|
+
|
|
115
|
+
def _end(
|
|
116
|
+
self,
|
|
117
|
+
state: RunState,
|
|
118
|
+
result: Drawing | None = None,
|
|
119
|
+
error: BaseException | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
# The state goes last, so a reader that sees the end state sees the result or error too.
|
|
122
|
+
self._result = result
|
|
123
|
+
self._error = error
|
|
124
|
+
self._state = state
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Session:
|
|
128
|
+
"""One working context: an image, a canvas, pens, a style with its settings, plot speeds, the
|
|
129
|
+
stage cache and the latest result.
|
|
130
|
+
|
|
131
|
+
The attributes can be read at any time; ``image``, ``canvas``, ``pens``, ``style`` and
|
|
132
|
+
``settings`` are copies of what the engine holds. Change them through the ``set_*`` methods and
|
|
133
|
+
``update``, which the engine checks: a refused value raises ``ValueError`` and changes
|
|
134
|
+
nothing. ``cache_bytes`` bounds the stage cache and ``max_fps`` caps how often frames are
|
|
135
|
+
delivered to the subscribers. ``run`` works on a worker thread and ``run_blocking`` in the
|
|
136
|
+
caller's. Starting either one stops the run before it, so at most one is active. A session is
|
|
137
|
+
not safe for concurrent changes, so a front end should call it from one thread. ``speeds``,
|
|
138
|
+
which ``stats`` reads, has no setter and is edited in place.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self, cache_bytes: int = 512 * 2**20, max_fps: float = 15.0) -> None:
|
|
142
|
+
self._core = _core.Session(cache_bytes)
|
|
143
|
+
self.result: Drawing | None = None
|
|
144
|
+
self.speeds = Speeds()
|
|
145
|
+
self.max_fps = max_fps
|
|
146
|
+
self._handle: RunHandle | None = None
|
|
147
|
+
self._subscribers: dict[object, Callable[[Frame], None]] = {}
|
|
148
|
+
self._last_delivery = -math.inf
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def image(self) -> NDArray[np.float32] | None:
|
|
152
|
+
"""The source image as an ``(H, W, 3)`` float32 array in 0..1, or ``None``."""
|
|
153
|
+
return self._core.image()
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def canvas(self) -> Canvas:
|
|
157
|
+
return Canvas(**self._core.canvas())
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def pens(self) -> tuple[Pen, ...]:
|
|
161
|
+
return tuple(Pen(**pen) for pen in self._core.pens())
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def style(self) -> Style:
|
|
165
|
+
return get_style(self._core.style_name())
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def settings(self) -> dict[str, dict[str, Any]]:
|
|
169
|
+
"""Every stage's settings, keyed by stage name."""
|
|
170
|
+
return self._core.settings()
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def state(self) -> RunState:
|
|
174
|
+
"""The state of the latest run, or ``idle`` before any run."""
|
|
175
|
+
return RunState.idle if self._handle is None else self._handle.state
|
|
176
|
+
|
|
177
|
+
def load_image(self, path: str | os.PathLike[str]) -> None:
|
|
178
|
+
"""Read the PNG, JPEG or WebP file at ``path`` as the source image. Raises ``OSError``
|
|
179
|
+
when the file cannot be read and ``ValueError`` when the engine cannot decode it."""
|
|
180
|
+
self._core.set_image_bytes(Path(path).read_bytes())
|
|
181
|
+
|
|
182
|
+
def set_image(self, image: NDArray[Any]) -> None:
|
|
183
|
+
"""Use ``image`` as the source image. It must have shape ``(H, W, 3)`` or ``(H, W)``,
|
|
184
|
+
where a grey image is repeated into three channels, else ``ValueError`` is raised. An
|
|
185
|
+
integer array is divided by the largest value of its type, so ``uint8`` by 255 and
|
|
186
|
+
``uint16`` by 65535; a float array is taken to be in 0..1 already. The engine keeps a
|
|
187
|
+
copy, so the caller may change the array afterwards."""
|
|
188
|
+
pixels = np.asarray(image)
|
|
189
|
+
if pixels.ndim == 2:
|
|
190
|
+
pixels = np.repeat(pixels[:, :, None], 3, axis=2)
|
|
191
|
+
elif pixels.ndim != 3 or pixels.shape[2] != 3:
|
|
192
|
+
raise ValueError(f"image must have shape (H, W) or (H, W, 3), got {pixels.shape}")
|
|
193
|
+
rgb = np.asarray(pixels, dtype=np.float32)
|
|
194
|
+
if np.issubdtype(pixels.dtype, np.integer):
|
|
195
|
+
# rgb is a new array, since the dtype changed, so the caller's is untouched.
|
|
196
|
+
rgb /= np.iinfo(pixels.dtype).max
|
|
197
|
+
self._core.set_image(rgb)
|
|
198
|
+
|
|
199
|
+
def analysis(self) -> dict[str, Any]:
|
|
200
|
+
"""What the image looks like and what would suit it, as ``{"stats", "suggestions"}``.
|
|
201
|
+
``stats`` holds numbers measured when the image was set. Each suggestion is
|
|
202
|
+
``{"kind", "reason", "patch"}``, where ``patch`` is ``None`` or any of ``style``,
|
|
203
|
+
``settings`` (``{stage: {key: value}}``) and ``page`` (``{width_mm, height_mm}``), which
|
|
204
|
+
the caller applies; the list follows the current style, settings and page. Raises
|
|
205
|
+
``RuntimeError`` when no image is loaded."""
|
|
206
|
+
analysis = self._core.analysis()
|
|
207
|
+
if analysis is None:
|
|
208
|
+
raise RuntimeError("no image is loaded")
|
|
209
|
+
return analysis
|
|
210
|
+
|
|
211
|
+
def set_canvas(self, canvas: Canvas) -> None:
|
|
212
|
+
"""Use ``canvas`` as the page. Raises ``ValueError`` for a page or margin that leaves no
|
|
213
|
+
drawing area."""
|
|
214
|
+
self._core.set_canvas(asdict(canvas))
|
|
215
|
+
|
|
216
|
+
def set_pens(self, pens: Sequence[Pen]) -> None:
|
|
217
|
+
"""Use ``pens``, in drawing order. Raises ``ValueError`` when there are none, because
|
|
218
|
+
every result needs a pen, and for a pen the engine refuses."""
|
|
219
|
+
self._core.set_pens([asdict(pen) for pen in pens])
|
|
220
|
+
|
|
221
|
+
def set_style(self, name: str) -> None:
|
|
222
|
+
"""Switch to the built-in style called ``name`` and reset the settings to its defaults.
|
|
223
|
+
The cache is kept, so a stage that the styles share, with the same settings and
|
|
224
|
+
inputs, is not run again. Raises
|
|
225
|
+
``ValueError`` for an unknown name."""
|
|
226
|
+
self._core.set_style(name)
|
|
227
|
+
|
|
228
|
+
def update(self, stage: str, **values: Any) -> None:
|
|
229
|
+
"""Change settings of one stage, leaving the rest as they were. The whole stage is
|
|
230
|
+
checked again, so a bad value or an unknown stage raises ``ValueError`` and changes
|
|
231
|
+
nothing."""
|
|
232
|
+
self._core.update(stage, values)
|
|
233
|
+
|
|
234
|
+
def save_preset(self, path: str | os.PathLike[str]) -> None:
|
|
235
|
+
"""Write the style, canvas, pens and settings to ``path`` as a JSON preset, UTF-8 with LF
|
|
236
|
+
line ends and indented by two spaces so that it reads and diffs well."""
|
|
237
|
+
text = json.dumps(self._core.preset(), indent=2) + "\n"
|
|
238
|
+
Path(path).write_text(text, encoding="utf-8", newline="\n")
|
|
239
|
+
|
|
240
|
+
def load_preset(self, path: str | os.PathLike[str]) -> None:
|
|
241
|
+
"""Read the preset file at ``path`` and apply it, so its style, canvas, pens and settings
|
|
242
|
+
replace the current ones. A key the file leaves out keeps its default, and each stage or
|
|
243
|
+
setting the style does not have is dropped with a ``UserWarning``. Raises ``ValueError``
|
|
244
|
+
for a file that is not a preset or that the engine refuses."""
|
|
245
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
246
|
+
for message in self._core.load_preset(data):
|
|
247
|
+
warnings.warn(message, UserWarning, stacklevel=2)
|
|
248
|
+
|
|
249
|
+
def run_blocking(self) -> Drawing:
|
|
250
|
+
"""Run the style in the calling thread and return the drawing, which is also kept in
|
|
251
|
+
``result``.
|
|
252
|
+
|
|
253
|
+
A run under way on a worker is cancelled and waited for first. Each frame goes through
|
|
254
|
+
``_deliver`` as it arrives. A failed stage raises ``RuntimeError`` with the engine's
|
|
255
|
+
message and sets ``state`` to ``error``; a cancelled run raises ``Cancelled`` and sets it
|
|
256
|
+
to ``cancelled``. Either way ``result`` stays as it was. Raises ``RuntimeError`` when no
|
|
257
|
+
image is loaded.
|
|
258
|
+
"""
|
|
259
|
+
return self._execute(self._start())
|
|
260
|
+
|
|
261
|
+
def run(self) -> RunHandle:
|
|
262
|
+
"""Run the style on a worker thread and return its handle at once.
|
|
263
|
+
|
|
264
|
+
A run under way is cancelled and waited for first. The style, settings, canvas, pens and
|
|
265
|
+
image are taken now, so later changes do not reach the run. The subscribers get its
|
|
266
|
+
frames on the worker thread. Unlike ``run_blocking`` it raises no stage error: the run
|
|
267
|
+
ends in ``error`` and the handle keeps the exception. Raises ``RuntimeError`` when no
|
|
268
|
+
image is loaded.
|
|
269
|
+
"""
|
|
270
|
+
handle = self._start()
|
|
271
|
+
thread = threading.Thread(target=self._work, args=(handle,), daemon=True)
|
|
272
|
+
handle._thread = thread
|
|
273
|
+
thread.start()
|
|
274
|
+
return handle
|
|
275
|
+
|
|
276
|
+
def cancel(self) -> None:
|
|
277
|
+
"""Ask the latest run to stop, which ends it in ``cancelled``. Nothing happens when there
|
|
278
|
+
has been no run, and a run that has ended stays as it ended."""
|
|
279
|
+
if self._handle is not None:
|
|
280
|
+
self._handle.cancel()
|
|
281
|
+
|
|
282
|
+
def subscribe(self, callback: Callable[[Frame], None]) -> Callable[[], None]:
|
|
283
|
+
"""Call ``callback(frame)`` for the frames of every later run, and return a function that
|
|
284
|
+
removes it again.
|
|
285
|
+
|
|
286
|
+
``max_fps`` caps the rate, see ``_deliver``. The callback runs on the thread that drives
|
|
287
|
+
the run, the worker for ``run`` and the caller for ``run_blocking``, so it should return
|
|
288
|
+
quickly. An exception from it ends the run in ``error``, as a failed stage does.
|
|
289
|
+
"""
|
|
290
|
+
key = object()
|
|
291
|
+
self._subscribers[key] = callback
|
|
292
|
+
|
|
293
|
+
def unsubscribe() -> None:
|
|
294
|
+
self._subscribers.pop(key, None)
|
|
295
|
+
|
|
296
|
+
return unsubscribe
|
|
297
|
+
|
|
298
|
+
def _start(self) -> RunHandle:
|
|
299
|
+
"""Stop the latest run, wait for it, and start the next one in the engine, which takes a
|
|
300
|
+
snapshot of the session."""
|
|
301
|
+
if self._handle is not None:
|
|
302
|
+
self._handle.cancel()
|
|
303
|
+
self._handle.wait()
|
|
304
|
+
self._handle = handle = RunHandle(self._core.start())
|
|
305
|
+
return handle
|
|
306
|
+
|
|
307
|
+
def _work(self, handle: RunHandle) -> None:
|
|
308
|
+
"""The body of a worker thread."""
|
|
309
|
+
try:
|
|
310
|
+
self._execute(handle)
|
|
311
|
+
except Exception:
|
|
312
|
+
pass # the handle keeps it, and a thread has no caller to raise to
|
|
313
|
+
|
|
314
|
+
def _execute(self, handle: RunHandle) -> Drawing:
|
|
315
|
+
"""Drive the run to its end in the current thread, passing each frame to ``_deliver``,
|
|
316
|
+
and record how it ended on ``handle``. This is the one place that moves a run through its
|
|
317
|
+
states. ``Cancelled`` and any error are raised, with ``result`` as it was."""
|
|
318
|
+
self._last_delivery = -math.inf
|
|
319
|
+
run = handle._run
|
|
320
|
+
try:
|
|
321
|
+
while (frame := run.next_frame()) is not None:
|
|
322
|
+
self._deliver(_frame(frame))
|
|
323
|
+
state = RunState(run.state())
|
|
324
|
+
if state is RunState.done:
|
|
325
|
+
# No newer run has started, so the engine's latest drawing is this run's.
|
|
326
|
+
self.result = result = _drawing(_have(self._core.drawing()))
|
|
327
|
+
handle._end(state, result)
|
|
328
|
+
return result
|
|
329
|
+
if state is RunState.cancelled:
|
|
330
|
+
handle._end(state)
|
|
331
|
+
raise Cancelled("the run was cancelled")
|
|
332
|
+
failure = RuntimeError(run.error())
|
|
333
|
+
handle._end(RunState.error, error=failure)
|
|
334
|
+
raise failure
|
|
335
|
+
except BaseException as error:
|
|
336
|
+
# Anything raised before the handle ended, such as a subscriber's error, ends it here.
|
|
337
|
+
if handle.state is RunState.running:
|
|
338
|
+
handle._end(RunState.error, error=error)
|
|
339
|
+
raise
|
|
340
|
+
|
|
341
|
+
def _deliver(self, frame: Frame) -> None:
|
|
342
|
+
"""Pass ``frame`` to the subscribers, unless less than ``1 / max_fps`` seconds have
|
|
343
|
+
passed since the last delivery. The first frame of a run and every frame with ``progress``
|
|
344
|
+
of 1 or more are always passed on, so a throttled preview still ends on the finished
|
|
345
|
+
state of each stage."""
|
|
346
|
+
# Not time.monotonic: before Python 3.13 it ticks every 15.6 ms on Windows, so a fast stage
|
|
347
|
+
# would lose frames even when max_fps asks for all of them.
|
|
348
|
+
now = time.perf_counter()
|
|
349
|
+
if frame.progress < 1 and now - self._last_delivery < 1 / self.max_fps:
|
|
350
|
+
return
|
|
351
|
+
self._last_delivery = now
|
|
352
|
+
for callback in list(self._subscribers.values()):
|
|
353
|
+
callback(frame)
|
|
354
|
+
|
|
355
|
+
def svg(self) -> str:
|
|
356
|
+
"""The result as SVG text. Raises ``RuntimeError`` when there is no result."""
|
|
357
|
+
return _have(self._core.svg())
|
|
358
|
+
|
|
359
|
+
def json_dict(self) -> dict[str, Any]:
|
|
360
|
+
"""The result as the dict of the JSON paths format. Raises ``RuntimeError`` when there is
|
|
361
|
+
no result."""
|
|
362
|
+
return _have(self._core.json())
|
|
363
|
+
|
|
364
|
+
def export_svg(self, path: str | os.PathLike[str]) -> None:
|
|
365
|
+
"""Write the SVG to ``path`` as UTF-8 with LF line ends. Raises ``RuntimeError`` when
|
|
366
|
+
there is no result."""
|
|
367
|
+
Path(path).write_text(self.svg(), encoding="utf-8", newline="\n")
|
|
368
|
+
|
|
369
|
+
def export_json(self, path: str | os.PathLike[str]) -> None:
|
|
370
|
+
"""Write the JSON paths format to ``path`` as UTF-8 with LF line ends, one value per line
|
|
371
|
+
and no indentation, which diffs well and is a quarter the size of indenting by two.
|
|
372
|
+
Raises ``RuntimeError`` when there is no result."""
|
|
373
|
+
text = json.dumps(self.json_dict(), indent=0) + "\n"
|
|
374
|
+
Path(path).write_text(text, encoding="utf-8", newline="\n")
|
|
375
|
+
|
|
376
|
+
def stats(self) -> Stats:
|
|
377
|
+
"""The path count, pen-down and pen-up distances and estimated plot time of the result at
|
|
378
|
+
``speeds``. Raises ``RuntimeError`` when there is no result."""
|
|
379
|
+
self._core.set_speeds(asdict(self.speeds))
|
|
380
|
+
return Stats(**_have(self._core.stats()))
|
|
381
|
+
|
|
382
|
+
def preview_png(
|
|
383
|
+
self,
|
|
384
|
+
width_px: int = 1000,
|
|
385
|
+
travel: bool = False,
|
|
386
|
+
source: bool = False,
|
|
387
|
+
region: tuple[float, float, float, float] | None = None,
|
|
388
|
+
) -> bytes:
|
|
389
|
+
"""The result drawn at true pen width as PNG bytes that are ``width_px`` wide, with the
|
|
390
|
+
pen-up moves when ``travel`` is set and the tone map under the paths when ``source`` is.
|
|
391
|
+
``region`` is ``(x, y, width, height)`` in page mm, origin top-left; the picture then shows
|
|
392
|
+
only that part, which may reach past the page, and is as high as the region's shape needs.
|
|
393
|
+
Raises ``ValueError`` for a region with a value that is not finite or a width or height
|
|
394
|
+
that is not positive, and ``RuntimeError`` when there is no result."""
|
|
395
|
+
return _have(self._core.preview_png(width_px, travel, source, region))
|
|
396
|
+
|
|
397
|
+
def cache_stats(self) -> dict[str, int]:
|
|
398
|
+
"""What the stage cache holds and how often it was asked: ``hits``, ``misses``,
|
|
399
|
+
``bytes`` and ``entries``."""
|
|
400
|
+
return self._core.cache_stats()
|
plotline/types.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""The values the Python package hands out and takes in: page, pens, speeds, stats, drawings,
|
|
2
|
+
frames, styles and run states.
|
|
3
|
+
|
|
4
|
+
They are plain dataclasses with no checks of their own. The engine checks a canvas and pens when a
|
|
5
|
+
session is given them and raises ``ValueError`` for one it refuses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from numpy.typing import NDArray
|
|
14
|
+
|
|
15
|
+
from plotline import _core
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Canvas:
|
|
20
|
+
"""The page in mm, the margin on every side, and whether the image fits inside the drawing
|
|
21
|
+
area (``fit``) or fills it (``fill``)."""
|
|
22
|
+
|
|
23
|
+
width_mm: float
|
|
24
|
+
height_mm: float
|
|
25
|
+
margin_mm: float = 0.0
|
|
26
|
+
fit: Literal["fit", "fill"] = "fit"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Pen:
|
|
31
|
+
"""A pen: a name, the line width in mm and the colour as ``#rrggbb``."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
width_mm: float
|
|
35
|
+
color: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Speeds:
|
|
40
|
+
"""How fast a plotter moves, as a front end lets the user set it.
|
|
41
|
+
|
|
42
|
+
``draw_mm_s`` is the speed with the pen down and ``travel_mm_s`` the speed with it up, both in
|
|
43
|
+
mm per second. ``pen_lift_s`` is the time to lift the pen and set it down again, which every
|
|
44
|
+
path costs once. The class is not frozen, so a front end can edit the values in place.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
draw_mm_s: float = 50.0
|
|
48
|
+
travel_mm_s: float = 100.0
|
|
49
|
+
pen_lift_s: float = 0.2
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Stats:
|
|
54
|
+
"""What a drawing costs to plot: the number of paths, the pen-down and pen-up distances in mm
|
|
55
|
+
and the estimated plot time in seconds."""
|
|
56
|
+
|
|
57
|
+
path_count: int
|
|
58
|
+
pen_down_mm: float
|
|
59
|
+
pen_up_mm: float
|
|
60
|
+
estimated_seconds: float
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class ToneMap:
|
|
65
|
+
"""The ink density in 0..1 that a drawing was made from, one value per pixel of ``ink``, with
|
|
66
|
+
the size of a pixel in mm and the page position of the top left pixel."""
|
|
67
|
+
|
|
68
|
+
ink: NDArray[np.float32]
|
|
69
|
+
mm_per_px: float
|
|
70
|
+
origin_mm: tuple[float, float]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Layer:
|
|
75
|
+
"""The paths drawn with one pen, each an ``(n, 2)`` array of points in mm; a path of one
|
|
76
|
+
point is a dot."""
|
|
77
|
+
|
|
78
|
+
pen: Pen
|
|
79
|
+
paths: list[NDArray[np.float64]]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Drawing:
|
|
84
|
+
"""A finished drawing: the page, a layer per pen and the tone map, if the style made one."""
|
|
85
|
+
|
|
86
|
+
canvas: Canvas
|
|
87
|
+
layers: list[Layer]
|
|
88
|
+
tone: ToneMap | None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class Frame:
|
|
93
|
+
"""One step of a run as the preview sees it: the stage, its progress in 0..1, a label, the
|
|
94
|
+
points or paths it has so far, if any, and the pen whose lane gave the frame (0 outside
|
|
95
|
+
lanes)."""
|
|
96
|
+
|
|
97
|
+
stage: str
|
|
98
|
+
progress: float
|
|
99
|
+
label: str
|
|
100
|
+
points: NDArray[np.float64] | None
|
|
101
|
+
paths: list[NDArray[np.float64]] | None
|
|
102
|
+
pen: int
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Style:
|
|
107
|
+
"""A built-in style: its name, a short label and a description."""
|
|
108
|
+
|
|
109
|
+
name: str
|
|
110
|
+
label: str
|
|
111
|
+
description: str
|
|
112
|
+
|
|
113
|
+
def schema(self) -> dict[str, Any]:
|
|
114
|
+
"""The style with each stage's name, label and settings as JSON Schema."""
|
|
115
|
+
return _core.describe(self.name)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class RunState(StrEnum):
|
|
119
|
+
"""Where the latest run stands: ``idle`` before any run, ``running`` while one is under way,
|
|
120
|
+
then ``done``, ``cancelled`` or ``error`` for how it ended."""
|
|
121
|
+
|
|
122
|
+
idle = "idle"
|
|
123
|
+
running = "running"
|
|
124
|
+
done = "done"
|
|
125
|
+
cancelled = "cancelled"
|
|
126
|
+
error = "error"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Cancelled(Exception):
|
|
130
|
+
"""Raised by ``Session.run_blocking`` when the run was cancelled before it finished."""
|