pyctures 0.1.0__py3-none-any.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.
- pyctures/__init__.py +890 -0
- pyctures/__main__.py +5 -0
- pyctures/_worker.py +89 -0
- pyctures/build.py +198 -0
- pyctures/cli.py +48 -0
- pyctures/pages.py +187 -0
- pyctures/server.py +408 -0
- pyctures/web/pyctures.js +1260 -0
- pyctures/web/three/LICENSE +21 -0
- pyctures/web/three/OrbitControls.js +1860 -0
- pyctures/web/three/three.core.min.js +6 -0
- pyctures/web/three/three.module.min.js +6 -0
- pyctures-0.1.0.dist-info/METADATA +196 -0
- pyctures-0.1.0.dist-info/RECORD +19 -0
- pyctures-0.1.0.dist-info/WHEEL +5 -0
- pyctures-0.1.0.dist-info/entry_points.txt +2 -0
- pyctures-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyctures-0.1.0.dist-info/licenses/THIRD-PARTY-NOTICES +53 -0
- pyctures-0.1.0.dist-info/top_level.txt +1 -0
pyctures/__init__.py
ADDED
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
"""Pyctures: Python 2D and 3D graphics, drawn in a web browser.
|
|
2
|
+
|
|
3
|
+
This module is the Python half of Pyctures. The same source runs in your own
|
|
4
|
+
CPython (``pyctures run``) and in the browser with Pyodide (``pyctures build``):
|
|
5
|
+
the browser half loads this very file as the module ``pyctures``.
|
|
6
|
+
|
|
7
|
+
NAMESPACE MAPPING. ``js``, ``canvas``, ``ctx``, ``THREE`` and ``addons`` are
|
|
8
|
+
proxies for objects that live in the browser page. Using one RECORDS a command;
|
|
9
|
+
nothing crosses to JavaScript until the frame ends (``draw()`` returns,
|
|
10
|
+
``await frame()``, or ``await flush()``). So a whole frame is one message.
|
|
11
|
+
|
|
12
|
+
- ``a.b`` a lazy reference (nothing sent until it is used)
|
|
13
|
+
- ``a.b(...)`` a call with ``this = a``; the result gets a handle number at
|
|
14
|
+
once, on the Python side, so it can be used in the same frame
|
|
15
|
+
- ``A.B(...)`` a capitalised name is a constructor: ``new A.B(...)``
|
|
16
|
+
- ``a.b = v`` a property write; the value is also remembered here, so
|
|
17
|
+
reading it back (``a.b``) needs no round trip
|
|
18
|
+
- ``a.b += 1`` maths on a value that lives in the page is DEFERRED: it is
|
|
19
|
+
sent as an expression and worked out there
|
|
20
|
+
- ``await a.b`` a real read, answered at the end of the frame
|
|
21
|
+
- keyword args become one trailing options object (``antialias=True``)
|
|
22
|
+
- snake_case ``fill_rect``, ``fill_style`` reach ``fillRect``, ``fillStyle``
|
|
23
|
+
- functions passed as callbacks (addEventListener) come back as events
|
|
24
|
+
|
|
25
|
+
Command shape (the contract with ``web/pyctures.js``). Every command is
|
|
26
|
+
``[kind, line, ...]``, ``line`` being the user's source line that made it::
|
|
27
|
+
|
|
28
|
+
['get', line, newH, objH, name]
|
|
29
|
+
['set', line, objH, name, value]
|
|
30
|
+
['call', line, newH, objH, name, args]
|
|
31
|
+
['new', line, newH, objH, name, args]
|
|
32
|
+
['fcall',line, newH, fnH, args]
|
|
33
|
+
['fnew', line, newH, fnH, args]
|
|
34
|
+
['free', line, h]
|
|
35
|
+
['read', line, readId, value, newH] (newH: where a promise's object lands)
|
|
36
|
+
|
|
37
|
+
Values: plain JSON, or {"$h": h}, {"$e": expr}, {"$cb": id}, {"$d": {...}}.
|
|
38
|
+
|
|
39
|
+
The host calls ``start(source, info_json)`` once, then ``tick(payload_json)``
|
|
40
|
+
once per frame, and ``stop()``. Each returns a JSON reply:
|
|
41
|
+
``{"cmds", "out", "err", "running", "error", "errorLine"}``.
|
|
42
|
+
"""
|
|
43
|
+
import sys, io, json, time, math, traceback, threading, types, linecache
|
|
44
|
+
|
|
45
|
+
__version__ = "0.1.0"
|
|
46
|
+
|
|
47
|
+
VERSION = "10"
|
|
48
|
+
USER_FILE = "main.py"
|
|
49
|
+
|
|
50
|
+
# Fixed handles the frame defines before anything runs.
|
|
51
|
+
H_WINDOW, H_CANVAS, H_THREE, H_CTX, H_ADDONS = 1, 2, 3, 4, 5
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _State:
|
|
55
|
+
def __init__(self):
|
|
56
|
+
self.cmds = []
|
|
57
|
+
self.next_read = 1
|
|
58
|
+
self.reads = {} # read id -> result once answered
|
|
59
|
+
self.callbacks = {} # callback id -> Python function
|
|
60
|
+
self.next_cb = 1
|
|
61
|
+
self.tasks = [] # live coroutines: [coro, waiting]
|
|
62
|
+
self.ns = None # the user's namespace
|
|
63
|
+
self.out = io.StringIO()
|
|
64
|
+
self.err = io.StringIO()
|
|
65
|
+
self.started = 0.0
|
|
66
|
+
self.frame_no = 0
|
|
67
|
+
self.thread = None
|
|
68
|
+
self.busy = False
|
|
69
|
+
self.stopped = False
|
|
70
|
+
self.pictures = {} # (text, size) -> a canvas with the text drawn once
|
|
71
|
+
self.measuring = False # measure mode: time each part of a frame
|
|
72
|
+
self.sections = {} # name -> ms this frame (measure mode)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# WHICH RUN A THREAD IS WORKING FOR. A host may run every frame as its own
|
|
76
|
+
# request on its own thread, and a frame from a run the user has just stopped
|
|
77
|
+
# can still be finishing while the next run starts. If both shared one state,
|
|
78
|
+
# the old frame's commands landed in the new run (objects the new preview never
|
|
79
|
+
# made). So each start()/tick() pins its run's state to its thread, and `S`
|
|
80
|
+
# reads through that pin; with no pin, it is the latest run.
|
|
81
|
+
_tls = threading.local()
|
|
82
|
+
_latest = [_State()]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _Current:
|
|
86
|
+
__slots__ = ()
|
|
87
|
+
|
|
88
|
+
def __getattr__(self, name):
|
|
89
|
+
return getattr(getattr(_tls, "state", None) or _latest[0], name)
|
|
90
|
+
|
|
91
|
+
def __setattr__(self, name, value):
|
|
92
|
+
setattr(getattr(_tls, "state", None) or _latest[0], name, value)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
S = _Current()
|
|
96
|
+
|
|
97
|
+
# Never reset between runs: a reference from the LAST run that is collected
|
|
98
|
+
# during this one sends a 'free' for its number, and that number must not
|
|
99
|
+
# belong to anything new.
|
|
100
|
+
_next_handle = [1000]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _user_line():
|
|
104
|
+
f = sys._getframe(1)
|
|
105
|
+
while f is not None:
|
|
106
|
+
if f.f_code.co_filename == USER_FILE:
|
|
107
|
+
return f.f_lineno
|
|
108
|
+
f = f.f_back
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _emit(*cmd):
|
|
113
|
+
S.cmds.append([cmd[0], _user_line(), *cmd[1:]])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _new_handle():
|
|
117
|
+
_next_handle[0] += 1
|
|
118
|
+
return _next_handle[0]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class PreviewValueError(TypeError):
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_READ_HINT = ("this value lives in the preview, not in Python — "
|
|
126
|
+
"read it with value = await <expression>")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _Read:
|
|
130
|
+
"""An awaitable for one value the frame has to send back.
|
|
131
|
+
|
|
132
|
+
A PROMISE is awaited in the page: what it resolves to comes back as data,
|
|
133
|
+
or, when it is an object (a GPU adapter, a device, a loaded font), as a
|
|
134
|
+
reference that can be used like any other. Its handle number is reserved
|
|
135
|
+
here, with the read, so the two sides never disagree on it.
|
|
136
|
+
"""
|
|
137
|
+
__slots__ = ("rid",)
|
|
138
|
+
|
|
139
|
+
def __init__(self, encoded):
|
|
140
|
+
self.rid = S.next_read
|
|
141
|
+
S.next_read += 1
|
|
142
|
+
_emit("read", self.rid, encoded, _new_handle())
|
|
143
|
+
|
|
144
|
+
def __await__(self):
|
|
145
|
+
result = yield self
|
|
146
|
+
if isinstance(result, dict) and len(result) == 1:
|
|
147
|
+
if "$h" in result:
|
|
148
|
+
return JsRef(result["$h"], owned=True)
|
|
149
|
+
if "$error" in result:
|
|
150
|
+
raise RuntimeError(result["$error"])
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class _Frame:
|
|
155
|
+
"""'await frame()' — park until the next frame (also sends this one)."""
|
|
156
|
+
__slots__ = ("until",)
|
|
157
|
+
|
|
158
|
+
def __init__(self, seconds=0.0):
|
|
159
|
+
self.until = time.monotonic() + seconds
|
|
160
|
+
|
|
161
|
+
def __await__(self):
|
|
162
|
+
yield self
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def frame():
|
|
166
|
+
return _Frame()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def flush():
|
|
170
|
+
"""Send what has been drawn so far, now, and carry on next frame."""
|
|
171
|
+
return _Frame()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def sleep(seconds):
|
|
175
|
+
return _Frame(seconds)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------- expressions
|
|
179
|
+
|
|
180
|
+
class _Expr:
|
|
181
|
+
__slots__ = ("tree",)
|
|
182
|
+
|
|
183
|
+
def __init__(self, tree):
|
|
184
|
+
object.__setattr__(self, "tree", tree)
|
|
185
|
+
|
|
186
|
+
def _enc(self):
|
|
187
|
+
return {"$e": self.tree}
|
|
188
|
+
|
|
189
|
+
def __await__(self):
|
|
190
|
+
return _Read(self._enc()).__await__()
|
|
191
|
+
|
|
192
|
+
def __bool__(self):
|
|
193
|
+
raise PreviewValueError(_READ_HINT)
|
|
194
|
+
|
|
195
|
+
def __repr__(self):
|
|
196
|
+
return "<preview expression>"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _expr_of(v):
|
|
200
|
+
if isinstance(v, _Expr):
|
|
201
|
+
return v.tree
|
|
202
|
+
if isinstance(v, JsRef):
|
|
203
|
+
return ["ref", v._enc()]
|
|
204
|
+
return ["v", _encode(v)]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _binop(op):
|
|
208
|
+
def fwd(self, other):
|
|
209
|
+
return _Expr([op, _expr_of(self), _expr_of(other)])
|
|
210
|
+
|
|
211
|
+
def rev(self, other):
|
|
212
|
+
return _Expr([op, _expr_of(other), _expr_of(self)])
|
|
213
|
+
return fwd, rev
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
for _name, _op in (("add", "+"), ("sub", "-"), ("mul", "*"), ("truediv", "/"),
|
|
217
|
+
("mod", "%"), ("pow", "**"), ("floordiv", "//")):
|
|
218
|
+
_f, _r = _binop(_op)
|
|
219
|
+
setattr(_Expr, "__%s__" % _name, _f)
|
|
220
|
+
setattr(_Expr, "__r%s__" % _name, _r)
|
|
221
|
+
_Expr.__neg__ = lambda self: _Expr(["neg", _expr_of(self)])
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------- references
|
|
225
|
+
|
|
226
|
+
class JsRef:
|
|
227
|
+
"""A JavaScript object in the preview frame, seen from Python."""
|
|
228
|
+
__slots__ = ("_h", "_parent", "_name", "_shadow", "_kids", "_owned", "_cmd", "__weakref__")
|
|
229
|
+
|
|
230
|
+
def __init__(self, handle=None, parent=None, name=None, owned=False, cmd=None):
|
|
231
|
+
object.__setattr__(self, "_h", handle)
|
|
232
|
+
object.__setattr__(self, "_parent", parent)
|
|
233
|
+
object.__setattr__(self, "_name", name)
|
|
234
|
+
object.__setattr__(self, "_shadow", {})
|
|
235
|
+
object.__setattr__(self, "_kids", {})
|
|
236
|
+
object.__setattr__(self, "_owned", owned)
|
|
237
|
+
# The unsent command that made this value. While it is still unsent and
|
|
238
|
+
# the value is unused, a discarded result needs no handle at all.
|
|
239
|
+
object.__setattr__(self, "_cmd", cmd)
|
|
240
|
+
|
|
241
|
+
def _use(self):
|
|
242
|
+
object.__setattr__(self, "_cmd", None)
|
|
243
|
+
|
|
244
|
+
# A lazy member becomes a real handle the first time it is used as one.
|
|
245
|
+
def _handle(self):
|
|
246
|
+
self._use()
|
|
247
|
+
if self._h is None:
|
|
248
|
+
h = _new_handle()
|
|
249
|
+
_emit("get", h, self._parent._handle(), self._name)
|
|
250
|
+
object.__setattr__(self, "_h", h)
|
|
251
|
+
object.__setattr__(self, "_owned", True)
|
|
252
|
+
return self._h
|
|
253
|
+
|
|
254
|
+
def _enc(self):
|
|
255
|
+
return {"$h": self._handle()}
|
|
256
|
+
|
|
257
|
+
def __getattr__(self, name):
|
|
258
|
+
if name.startswith("__") and name.endswith("__"):
|
|
259
|
+
raise AttributeError(name)
|
|
260
|
+
self._use()
|
|
261
|
+
shadow = self._shadow
|
|
262
|
+
if name in shadow:
|
|
263
|
+
return shadow[name]
|
|
264
|
+
kid = self._kids.get(name)
|
|
265
|
+
if kid is None:
|
|
266
|
+
kid = JsRef(parent=self, name=name)
|
|
267
|
+
self._kids[name] = kid
|
|
268
|
+
return kid
|
|
269
|
+
|
|
270
|
+
def __setattr__(self, name, value):
|
|
271
|
+
self._use()
|
|
272
|
+
_emit("set", self._handle(), name, _encode(value))
|
|
273
|
+
if isinstance(value, (int, float, str, bool)) or value is None:
|
|
274
|
+
self._shadow[name] = value
|
|
275
|
+
else:
|
|
276
|
+
self._shadow.pop(name, None)
|
|
277
|
+
self._kids.pop(name, None)
|
|
278
|
+
|
|
279
|
+
def __getitem__(self, key):
|
|
280
|
+
return self.__getattr__(str(key))
|
|
281
|
+
|
|
282
|
+
def __setitem__(self, key, value):
|
|
283
|
+
self.__setattr__(str(key), value)
|
|
284
|
+
|
|
285
|
+
def __call__(self, *args, **kwargs):
|
|
286
|
+
enc = [_encode(a) for a in args]
|
|
287
|
+
if kwargs:
|
|
288
|
+
enc.append({"$d": {k: _encode(v) for k, v in kwargs.items()}})
|
|
289
|
+
self._use()
|
|
290
|
+
h = _new_handle()
|
|
291
|
+
if self._parent is not None and self._h is None:
|
|
292
|
+
kind = "new" if self._name[:1].isupper() else "call"
|
|
293
|
+
_emit(kind, h, self._parent._handle(), self._name, enc)
|
|
294
|
+
else:
|
|
295
|
+
_emit("fcall", h, self._handle(), enc)
|
|
296
|
+
return JsRef(h, owned=True, cmd=S.cmds[-1])
|
|
297
|
+
|
|
298
|
+
def new(self, *args, **kwargs):
|
|
299
|
+
enc = [_encode(a) for a in args]
|
|
300
|
+
if kwargs:
|
|
301
|
+
enc.append({"$d": {k: _encode(v) for k, v in kwargs.items()}})
|
|
302
|
+
h = _new_handle()
|
|
303
|
+
if self._parent is not None and self._h is None:
|
|
304
|
+
_emit("new", h, self._parent._handle(), self._name, enc)
|
|
305
|
+
else:
|
|
306
|
+
_emit("fnew", h, self._handle(), enc)
|
|
307
|
+
return JsRef(h, owned=True)
|
|
308
|
+
|
|
309
|
+
def __await__(self):
|
|
310
|
+
if self._h is None and self._parent is not None:
|
|
311
|
+
enc = {"$e": ["get", _expr_of(self._parent), self._name]}
|
|
312
|
+
else:
|
|
313
|
+
enc = self._enc()
|
|
314
|
+
return _Read(enc).__await__()
|
|
315
|
+
|
|
316
|
+
def __bool__(self):
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
def __del__(self):
|
|
320
|
+
try:
|
|
321
|
+
cmd = self._cmd
|
|
322
|
+
if cmd is not None and S.cmds and any(c is cmd for c in S.cmds[-64:]):
|
|
323
|
+
cmd[2] = 0 # never used and not sent yet: do not keep it
|
|
324
|
+
elif self._owned and self._h is not None and not S.stopped:
|
|
325
|
+
S.cmds.append(["free", 0, self._h])
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
328
|
+
|
|
329
|
+
def __repr__(self):
|
|
330
|
+
if self._parent is not None and self._h is None:
|
|
331
|
+
return "<preview %s>" % self._name
|
|
332
|
+
return "<preview object #%s>" % self._h
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# Arithmetic on a reference to a value in the frame is deferred.
|
|
336
|
+
def _ref_expr(op, rev=False):
|
|
337
|
+
def f(self, other):
|
|
338
|
+
lhs = ["get", _expr_of(self._parent), self._name] if (self._h is None and self._parent is not None) else _expr_of(self)
|
|
339
|
+
rhs = _expr_of(other)
|
|
340
|
+
return _Expr([op, rhs, lhs] if rev else [op, lhs, rhs])
|
|
341
|
+
return f
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
for _name, _op in (("add", "+"), ("sub", "-"), ("mul", "*"), ("truediv", "/"),
|
|
345
|
+
("mod", "%"), ("pow", "**"), ("floordiv", "//")):
|
|
346
|
+
setattr(JsRef, "__%s__" % _name, _ref_expr(_op))
|
|
347
|
+
setattr(JsRef, "__r%s__" % _name, _ref_expr(_op, rev=True))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _cmp_error(self, other):
|
|
351
|
+
raise PreviewValueError(_READ_HINT)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
for _name in ("lt", "le", "gt", "ge"):
|
|
355
|
+
setattr(JsRef, "__%s__" % _name, _cmp_error)
|
|
356
|
+
setattr(_Expr, "__%s__" % _name, _cmp_error)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ---------------------------------------------------------------- values
|
|
360
|
+
|
|
361
|
+
def _encode(v):
|
|
362
|
+
if v is None or isinstance(v, (bool, int, float, str)):
|
|
363
|
+
if isinstance(v, float) and (v != v or v in (float("inf"), float("-inf"))):
|
|
364
|
+
return {"$e": ["num", "NaN" if v != v else ("Infinity" if v > 0 else "-Infinity")]}
|
|
365
|
+
return v
|
|
366
|
+
if isinstance(v, JsRef):
|
|
367
|
+
return v._enc()
|
|
368
|
+
if isinstance(v, _Expr):
|
|
369
|
+
return v._enc()
|
|
370
|
+
if isinstance(v, (list, tuple)):
|
|
371
|
+
return [_encode(x) for x in v]
|
|
372
|
+
if isinstance(v, dict):
|
|
373
|
+
return {"$d": {str(k): _encode(x) for k, x in v.items()}}
|
|
374
|
+
if callable(v):
|
|
375
|
+
cid = S.next_cb
|
|
376
|
+
S.next_cb += 1
|
|
377
|
+
S.callbacks[cid] = v
|
|
378
|
+
return {"$cb": cid}
|
|
379
|
+
tolist = getattr(v, "tolist", None) # numpy arrays and scalars
|
|
380
|
+
if tolist is not None:
|
|
381
|
+
return _encode(tolist())
|
|
382
|
+
return str(v)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class Event(dict):
|
|
386
|
+
"""An event from the preview: event.key, event.x … (also event["key"])."""
|
|
387
|
+
def __getattr__(self, name):
|
|
388
|
+
try:
|
|
389
|
+
return self[name]
|
|
390
|
+
except KeyError:
|
|
391
|
+
raise AttributeError(name)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ---------------------------------------------------------------- input state
|
|
395
|
+
|
|
396
|
+
keys = set()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class _Mouse:
|
|
400
|
+
def __init__(self):
|
|
401
|
+
self.x = 0.0
|
|
402
|
+
self.y = 0.0
|
|
403
|
+
self.down = False
|
|
404
|
+
|
|
405
|
+
def __repr__(self):
|
|
406
|
+
return "mouse(x=%g, y=%g, down=%s)" % (self.x, self.y, self.down)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
mouse = _Mouse()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------- sprites
|
|
413
|
+
|
|
414
|
+
class Sprite:
|
|
415
|
+
"""A picture (an image, or any emoji/text) that moves. Draws itself with ctx."""
|
|
416
|
+
|
|
417
|
+
def __init__(self, picture, x=0, y=0, size=48):
|
|
418
|
+
self.picture = picture
|
|
419
|
+
self.x, self.y = x, y
|
|
420
|
+
self.vx = self.vy = 0.0
|
|
421
|
+
self.angle = 0.0
|
|
422
|
+
self.size = size
|
|
423
|
+
self.visible = True
|
|
424
|
+
|
|
425
|
+
def update(self, dt):
|
|
426
|
+
self.x += self.vx * dt
|
|
427
|
+
self.y += self.vy * dt
|
|
428
|
+
|
|
429
|
+
def draw(self):
|
|
430
|
+
if not self.visible:
|
|
431
|
+
return
|
|
432
|
+
c = ctx
|
|
433
|
+
c.save()
|
|
434
|
+
c.translate(self.x, self.y)
|
|
435
|
+
if self.angle:
|
|
436
|
+
c.rotate(self.angle)
|
|
437
|
+
s = self.size
|
|
438
|
+
if isinstance(self.picture, str):
|
|
439
|
+
# Emoji/text is drawn ONCE into a small canvas and stamped from then
|
|
440
|
+
# on: redrawing text every frame is the slow path on phones.
|
|
441
|
+
box = s * 1.3
|
|
442
|
+
c.drawImage(_picture(self.picture, s), -box / 2, -box / 2, box, box)
|
|
443
|
+
else:
|
|
444
|
+
c.drawImage(self.picture, -s / 2, -s / 2, s, s)
|
|
445
|
+
c.restore()
|
|
446
|
+
|
|
447
|
+
def touches(self, other):
|
|
448
|
+
r = (self.size + other.size) * 0.4
|
|
449
|
+
return (self.x - other.x) ** 2 + (self.y - other.y) ** 2 < r * r
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _picture(text, size):
|
|
453
|
+
key = (text, size)
|
|
454
|
+
pic = S.pictures.get(key)
|
|
455
|
+
if pic is None:
|
|
456
|
+
ratio = (S.ns or {}).get("pixel_ratio", 1) or 1
|
|
457
|
+
px = max(1, int(size * 1.3 * ratio + 0.5))
|
|
458
|
+
pic = js.document.createElement("canvas")
|
|
459
|
+
pic.width = px
|
|
460
|
+
pic.height = px
|
|
461
|
+
g = pic.getContext("2d")
|
|
462
|
+
g.font = "%dpx system-ui, sans-serif" % int(size * ratio)
|
|
463
|
+
g.textAlign = "center"
|
|
464
|
+
g.textBaseline = "middle"
|
|
465
|
+
g.fillText(text, px / 2, px / 2 + size * ratio * 0.06)
|
|
466
|
+
S.pictures[key] = pic
|
|
467
|
+
return pic
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def load_image(src):
|
|
471
|
+
"""An image for drawImage or a Sprite. Draws once it has loaded."""
|
|
472
|
+
img = js.Image()
|
|
473
|
+
img.src = src
|
|
474
|
+
return img
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ---------------------------------------------------------------- the namespace
|
|
478
|
+
|
|
479
|
+
js = JsRef(H_WINDOW)
|
|
480
|
+
canvas = JsRef(H_CANVAS)
|
|
481
|
+
THREE = JsRef(H_THREE)
|
|
482
|
+
ctx = JsRef(H_CTX)
|
|
483
|
+
addons = JsRef(H_ADDONS)
|
|
484
|
+
|
|
485
|
+
class measure:
|
|
486
|
+
"""Time a section of your frame: `with measure("physics"): ...`. Shown in measure mode."""
|
|
487
|
+
__slots__ = ("name", "t0")
|
|
488
|
+
|
|
489
|
+
def __init__(self, name):
|
|
490
|
+
self.name = str(name)
|
|
491
|
+
|
|
492
|
+
def __enter__(self):
|
|
493
|
+
self.t0 = time.perf_counter() if S.measuring else 0.0
|
|
494
|
+
return self
|
|
495
|
+
|
|
496
|
+
def __exit__(self, *exc):
|
|
497
|
+
if self.t0:
|
|
498
|
+
sec = S.sections
|
|
499
|
+
sec[self.name] = sec.get(self.name, 0.0) + (time.perf_counter() - self.t0) * 1000
|
|
500
|
+
return False
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
EXPORTS = ("js", "canvas", "ctx", "THREE", "addons", "frame", "flush", "sleep",
|
|
504
|
+
"keys", "mouse", "Sprite", "load_image", "Event", "measure")
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# ---------------------------------------------------------------- breakpoints
|
|
508
|
+
#
|
|
509
|
+
# A pause HOLDS the thread running the frame, so only a host that answers
|
|
510
|
+
# other requests meanwhile (one thread per request) can pause. The
|
|
511
|
+
# ``pyctures`` command line does not use breakpoints; they are kept so the
|
|
512
|
+
# module stays the same everywhere it runs. The host asks debug_state() and answers with debug_resume()
|
|
513
|
+
# from those other requests. Only the user's file is traced.
|
|
514
|
+
|
|
515
|
+
class _Debug:
|
|
516
|
+
def __init__(self):
|
|
517
|
+
self.on = False
|
|
518
|
+
self.bps = set()
|
|
519
|
+
self.mode = None # 'over' | 'into' | 'out' after a step
|
|
520
|
+
self.depth = 0
|
|
521
|
+
self.paused = False
|
|
522
|
+
self.line = 0
|
|
523
|
+
self.vars = {}
|
|
524
|
+
self.action = None
|
|
525
|
+
self.seq = 0 # one number per pause, so the host answers each once
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
DBG = _Debug()
|
|
529
|
+
_STEP = {"stepOver": "over", "stepInto": "into", "stepOut": "out"}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _user_depth(frame):
|
|
533
|
+
d = 0
|
|
534
|
+
while frame is not None:
|
|
535
|
+
if frame.f_code.co_filename == USER_FILE:
|
|
536
|
+
d += 1
|
|
537
|
+
frame = frame.f_back
|
|
538
|
+
return d
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _vars(frame):
|
|
542
|
+
out = {}
|
|
543
|
+
names = dict(frame.f_globals) if frame.f_globals is not None else {}
|
|
544
|
+
names.update(frame.f_locals)
|
|
545
|
+
for name, value in names.items():
|
|
546
|
+
if name.startswith("_") or name in EXPORTS:
|
|
547
|
+
continue
|
|
548
|
+
if isinstance(value, types.ModuleType) or (callable(value) and not isinstance(value, type)):
|
|
549
|
+
continue
|
|
550
|
+
try:
|
|
551
|
+
if isinstance(value, (int, float, str, bool, type(None))):
|
|
552
|
+
out[name] = {"value": value, "type": type(value).__name__}
|
|
553
|
+
elif isinstance(value, (list, tuple, dict, set)):
|
|
554
|
+
out[name] = {"value": repr(value)[:200], "type": type(value).__name__, "length": len(value)}
|
|
555
|
+
else:
|
|
556
|
+
out[name] = {"value": repr(value)[:200], "type": type(value).__name__}
|
|
557
|
+
except Exception:
|
|
558
|
+
out[name] = {"value": "<unreadable>", "type": type(value).__name__}
|
|
559
|
+
return out
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _local_trace(frame, event, arg):
|
|
563
|
+
if event == "line" and DBG.on and not S.stopped:
|
|
564
|
+
line = frame.f_lineno
|
|
565
|
+
mode = DBG.mode
|
|
566
|
+
d = _user_depth(frame) if mode else 0
|
|
567
|
+
if (line in DBG.bps or mode == "into"
|
|
568
|
+
or (mode == "over" and d <= DBG.depth)
|
|
569
|
+
or (mode == "out" and d < DBG.depth)):
|
|
570
|
+
_pause(frame, line)
|
|
571
|
+
return _local_trace
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _global_trace(frame, event, arg):
|
|
575
|
+
if frame.f_code.co_filename != USER_FILE:
|
|
576
|
+
return None
|
|
577
|
+
return _local_trace
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _pause(frame, line):
|
|
581
|
+
# This run's state, not whatever run is latest: a new run (or a reloaded
|
|
582
|
+
# page) must still release a pause left behind.
|
|
583
|
+
state = getattr(_tls, "state", None) or _latest[0]
|
|
584
|
+
DBG.mode = None
|
|
585
|
+
DBG.line = line
|
|
586
|
+
DBG.vars = _vars(frame)
|
|
587
|
+
DBG.action = None
|
|
588
|
+
DBG.seq += 1
|
|
589
|
+
DBG.paused = True
|
|
590
|
+
try:
|
|
591
|
+
while DBG.action is None and not state.stopped:
|
|
592
|
+
time.sleep(0.02)
|
|
593
|
+
finally:
|
|
594
|
+
DBG.paused = False
|
|
595
|
+
action = DBG.action
|
|
596
|
+
if action == "stop" or state.stopped:
|
|
597
|
+
raise KeyboardInterrupt("Stopped")
|
|
598
|
+
DBG.mode = _STEP.get(action)
|
|
599
|
+
DBG.depth = _user_depth(frame)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def debug_set(lines_json, on):
|
|
603
|
+
DBG.on = bool(on)
|
|
604
|
+
DBG.bps = set(json.loads(lines_json))
|
|
605
|
+
DBG.mode = None
|
|
606
|
+
return "ok"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def debug_state():
|
|
610
|
+
return json.dumps({"paused": DBG.paused, "seq": DBG.seq, "line": DBG.line,
|
|
611
|
+
"vars": DBG.vars if DBG.paused else {}}, default=str)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def debug_resume(action):
|
|
615
|
+
DBG.action = action
|
|
616
|
+
return "ok"
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
class _Traced:
|
|
620
|
+
"""Trace the user's file for the length of one start() or tick()."""
|
|
621
|
+
def __enter__(self):
|
|
622
|
+
if DBG.on:
|
|
623
|
+
sys.settrace(_global_trace)
|
|
624
|
+
|
|
625
|
+
def __exit__(self, *exc):
|
|
626
|
+
if DBG.on:
|
|
627
|
+
sys.settrace(None)
|
|
628
|
+
return False
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# ---------------------------------------------------------------- running
|
|
632
|
+
|
|
633
|
+
def _drive(task, value=None):
|
|
634
|
+
"""Step one coroutine until it waits; returns False when it has finished."""
|
|
635
|
+
coro = task[0]
|
|
636
|
+
try:
|
|
637
|
+
waiting = coro.send(value)
|
|
638
|
+
except StopIteration:
|
|
639
|
+
return False
|
|
640
|
+
task[1] = waiting
|
|
641
|
+
return True
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _format_error(e):
|
|
645
|
+
tb = traceback.extract_tb(e.__traceback__)
|
|
646
|
+
user = [fr for fr in tb if fr.filename == USER_FILE]
|
|
647
|
+
lines = ["Traceback (most recent call last):"]
|
|
648
|
+
for fr in user:
|
|
649
|
+
lines.append(' line %d, in %s' % (fr.lineno, fr.name))
|
|
650
|
+
if fr.line:
|
|
651
|
+
lines.append(" " + fr.line.strip())
|
|
652
|
+
line = user[-1].lineno if user else 0
|
|
653
|
+
if isinstance(e, SyntaxError) and e.filename == USER_FILE and e.lineno:
|
|
654
|
+
# A mistake in how the file is written: it never ran, so there is no
|
|
655
|
+
# traceback into it, only the line the parser stopped at.
|
|
656
|
+
line = e.lineno
|
|
657
|
+
lines.append(" line %d" % line)
|
|
658
|
+
if e.text:
|
|
659
|
+
lines.append(" " + e.text.strip())
|
|
660
|
+
lines.append("%s: %s" % (type(e).__name__, e.msg))
|
|
661
|
+
return "\n".join(lines), line
|
|
662
|
+
lines.append("%s: %s" % (type(e).__name__, e))
|
|
663
|
+
return "\n".join(lines), line
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _reply(error=None, error_line=0):
|
|
667
|
+
cmds, S.cmds = S.cmds, []
|
|
668
|
+
out = S.out.getvalue()
|
|
669
|
+
err = S.err.getvalue()
|
|
670
|
+
S.out = io.StringIO()
|
|
671
|
+
S.err = io.StringIO()
|
|
672
|
+
running = S.ns is not None and not error and (
|
|
673
|
+
bool(S.tasks) or bool(S.callbacks)
|
|
674
|
+
or any(callable(S.ns.get(n)) for n in _LIVE_NAMES))
|
|
675
|
+
reply = {
|
|
676
|
+
"cmds": cmds, "out": out, "err": err, "running": running,
|
|
677
|
+
"error": error, "errorLine": error_line,
|
|
678
|
+
}
|
|
679
|
+
if S.measuring:
|
|
680
|
+
reply["t"] = S.sections
|
|
681
|
+
S.sections = {}
|
|
682
|
+
return json.dumps(reply, default=str)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
_LIVE_NAMES = ("draw", "update", "on_key_down", "on_key_up",
|
|
686
|
+
"on_mouse_down", "on_mouse_move", "on_mouse_up")
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _apply_info(p):
|
|
690
|
+
ns = S.ns
|
|
691
|
+
if ns is None:
|
|
692
|
+
return
|
|
693
|
+
for k in ("width", "height", "pixel_ratio"):
|
|
694
|
+
if k in p:
|
|
695
|
+
ns[k] = p[k]
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _apply_events(p):
|
|
699
|
+
ns = S.ns
|
|
700
|
+
for ev in p.get("events", ()):
|
|
701
|
+
e = Event(ev.get("e") or {})
|
|
702
|
+
kind = ev.get("k")
|
|
703
|
+
if kind == "cb":
|
|
704
|
+
fn = S.callbacks.get(ev.get("id"))
|
|
705
|
+
if fn is not None:
|
|
706
|
+
r = fn(e)
|
|
707
|
+
if hasattr(r, "send"):
|
|
708
|
+
# Kept only while it is still waiting: a callback that
|
|
709
|
+
# finishes without awaiting must not be driven again.
|
|
710
|
+
task = [r, None]
|
|
711
|
+
if _drive(task):
|
|
712
|
+
S.tasks.append(task)
|
|
713
|
+
continue
|
|
714
|
+
t = e.get("type")
|
|
715
|
+
if t == "keydown":
|
|
716
|
+
keys.add(e.get("key"))
|
|
717
|
+
fn = ns.get("on_key_down")
|
|
718
|
+
if callable(fn):
|
|
719
|
+
fn(e.get("key"))
|
|
720
|
+
elif t == "keyup":
|
|
721
|
+
keys.discard(e.get("key"))
|
|
722
|
+
fn = ns.get("on_key_up")
|
|
723
|
+
if callable(fn):
|
|
724
|
+
fn(e.get("key"))
|
|
725
|
+
elif t in ("pointerdown", "pointermove", "pointerup"):
|
|
726
|
+
mouse.x, mouse.y = e.get("x", mouse.x), e.get("y", mouse.y)
|
|
727
|
+
if t == "pointerdown":
|
|
728
|
+
mouse.down = True
|
|
729
|
+
elif t == "pointerup":
|
|
730
|
+
mouse.down = False
|
|
731
|
+
fn = ns.get({"pointerdown": "on_mouse_down", "pointermove": "on_mouse_move",
|
|
732
|
+
"pointerup": "on_mouse_up"}[t])
|
|
733
|
+
if callable(fn):
|
|
734
|
+
fn(mouse.x, mouse.y)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _step_tasks(answers):
|
|
738
|
+
now = time.monotonic()
|
|
739
|
+
alive = []
|
|
740
|
+
for task in S.tasks:
|
|
741
|
+
waiting = task[1]
|
|
742
|
+
if isinstance(waiting, _Read):
|
|
743
|
+
if waiting.rid not in answers:
|
|
744
|
+
alive.append(task)
|
|
745
|
+
continue
|
|
746
|
+
keep = _drive(task, answers[waiting.rid])
|
|
747
|
+
elif isinstance(waiting, _Frame):
|
|
748
|
+
if waiting.until > now:
|
|
749
|
+
alive.append(task)
|
|
750
|
+
continue
|
|
751
|
+
keep = _drive(task)
|
|
752
|
+
else:
|
|
753
|
+
keep = _drive(task)
|
|
754
|
+
if keep:
|
|
755
|
+
alive.append(task)
|
|
756
|
+
S.tasks = alive
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _call_frame_functions(dt):
|
|
760
|
+
ns = S.ns
|
|
761
|
+
for name, args in (("update", (dt,)), ("draw", ())):
|
|
762
|
+
fn = ns.get(name)
|
|
763
|
+
if not callable(fn):
|
|
764
|
+
continue
|
|
765
|
+
if any(t[2:] == [name] for t in S.tasks):
|
|
766
|
+
continue # last frame's async draw is still waiting on a read
|
|
767
|
+
takes = getattr(getattr(fn, "__code__", None), "co_argcount", 1)
|
|
768
|
+
t0 = time.perf_counter() if S.measuring else 0.0
|
|
769
|
+
r = fn(*args) if (name != "update" or takes) else fn()
|
|
770
|
+
if t0:
|
|
771
|
+
S.sections[name] = S.sections.get(name, 0.0) + (time.perf_counter() - t0) * 1000
|
|
772
|
+
if hasattr(r, "send"):
|
|
773
|
+
task = [r, None, name]
|
|
774
|
+
if _drive(task):
|
|
775
|
+
S.tasks.append(task)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def start(source, info_json):
|
|
779
|
+
"""Run the file's top level. Returns the first frame's reply (JSON)."""
|
|
780
|
+
_latest[0].stopped = True
|
|
781
|
+
_latest[0] = _State()
|
|
782
|
+
_tls.state = _latest[0]
|
|
783
|
+
# FRESH ROOT NAMES EVERY RUN. A name like `js` remembers what was looked
|
|
784
|
+
# up through it (js.document, THREE.DoubleSide) as handles in the preview
|
|
785
|
+
# page; a new run has a new page, so reused names pointed at objects that
|
|
786
|
+
# no longer exist ("that object no longer exists in the preview").
|
|
787
|
+
global js, canvas, THREE, ctx, addons
|
|
788
|
+
js, canvas, THREE = JsRef(H_WINDOW), JsRef(H_CANVAS), JsRef(H_THREE)
|
|
789
|
+
ctx, addons = JsRef(H_CTX), JsRef(H_ADDONS)
|
|
790
|
+
keys.clear()
|
|
791
|
+
mouse.x = mouse.y = 0.0
|
|
792
|
+
mouse.down = False
|
|
793
|
+
S.thread = threading.get_ident()
|
|
794
|
+
S.busy = True
|
|
795
|
+
S.started = time.monotonic()
|
|
796
|
+
info = json.loads(info_json)
|
|
797
|
+
S.measuring = bool(info.get("measure"))
|
|
798
|
+
ns = {"__name__": "__main__", "__builtins__": __builtins__}
|
|
799
|
+
for name in EXPORTS:
|
|
800
|
+
ns[name] = globals()[name]
|
|
801
|
+
S.ns = ns
|
|
802
|
+
_apply_info(info)
|
|
803
|
+
old_out, old_err = sys.stdout, sys.stderr
|
|
804
|
+
sys.stdout, sys.stderr = S.out, S.err
|
|
805
|
+
tracer = _Traced()
|
|
806
|
+
try:
|
|
807
|
+
tracer.__enter__()
|
|
808
|
+
import ast
|
|
809
|
+
flags = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0)
|
|
810
|
+
# Tracebacks quote the line that failed: let them find the source.
|
|
811
|
+
linecache.cache[USER_FILE] = (len(source), None, source.splitlines(True), USER_FILE)
|
|
812
|
+
code = compile(source, USER_FILE, "exec", flags=flags)
|
|
813
|
+
result = eval(code, ns)
|
|
814
|
+
if hasattr(result, "send"):
|
|
815
|
+
task = [result, None]
|
|
816
|
+
if _drive(task):
|
|
817
|
+
S.tasks.append(task)
|
|
818
|
+
if callable(ns.get("setup")):
|
|
819
|
+
r = ns["setup"]()
|
|
820
|
+
if hasattr(r, "send"):
|
|
821
|
+
task = [r, None]
|
|
822
|
+
if _drive(task):
|
|
823
|
+
S.tasks.append(task)
|
|
824
|
+
_call_frame_functions(0.0)
|
|
825
|
+
except BaseException as e: # noqa: BLE001 — every failure is the user's to read
|
|
826
|
+
msg, line = _format_error(e)
|
|
827
|
+
S.ns = None
|
|
828
|
+
return _reply(msg, line)
|
|
829
|
+
finally:
|
|
830
|
+
tracer.__exit__()
|
|
831
|
+
sys.stdout, sys.stderr = old_out, old_err
|
|
832
|
+
S.busy = False
|
|
833
|
+
return _reply()
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def tick(payload_json):
|
|
837
|
+
"""One frame: events and read answers in, this frame's commands out (JSON)."""
|
|
838
|
+
_tls.state = _latest[0]
|
|
839
|
+
if S.ns is None:
|
|
840
|
+
return _reply("Nothing is running", 0)
|
|
841
|
+
S.thread = threading.get_ident()
|
|
842
|
+
S.busy = True
|
|
843
|
+
p = json.loads(payload_json)
|
|
844
|
+
S.frame_no += 1
|
|
845
|
+
S.measuring = bool(p.get("measure"))
|
|
846
|
+
t_tick = time.perf_counter() if S.measuring else 0.0
|
|
847
|
+
old_out, old_err = sys.stdout, sys.stderr
|
|
848
|
+
sys.stdout, sys.stderr = S.out, S.err
|
|
849
|
+
tracer = _Traced()
|
|
850
|
+
try:
|
|
851
|
+
tracer.__enter__()
|
|
852
|
+
if "bps" in p:
|
|
853
|
+
DBG.bps = set(p["bps"])
|
|
854
|
+
_apply_info(p)
|
|
855
|
+
answers = {int(k): v for k, v in (p.get("reads") or {}).items()}
|
|
856
|
+
_apply_events(p)
|
|
857
|
+
_step_tasks(answers)
|
|
858
|
+
_call_frame_functions(float(p.get("dt", 0.0)))
|
|
859
|
+
except BaseException as e: # noqa: BLE001
|
|
860
|
+
msg, line = _format_error(e)
|
|
861
|
+
S.ns = None
|
|
862
|
+
return _reply(msg, line)
|
|
863
|
+
finally:
|
|
864
|
+
tracer.__exit__()
|
|
865
|
+
sys.stdout, sys.stderr = old_out, old_err
|
|
866
|
+
S.busy = False
|
|
867
|
+
if t_tick:
|
|
868
|
+
S.sections["python"] = (time.perf_counter() - t_tick) * 1000
|
|
869
|
+
return _reply()
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def stop():
|
|
873
|
+
"""Stop: drop the program, and interrupt it if it is stuck in a loop."""
|
|
874
|
+
_tls.state = _latest[0]
|
|
875
|
+
S.stopped = True
|
|
876
|
+
S.ns = None
|
|
877
|
+
S.tasks = []
|
|
878
|
+
DBG.action = "stop"
|
|
879
|
+
tid = S.thread
|
|
880
|
+
# Only while a frame is actually running: a finished frame's thread id may
|
|
881
|
+
# already belong to another request.
|
|
882
|
+
if S.busy and tid is not None and tid != threading.get_ident():
|
|
883
|
+
try:
|
|
884
|
+
import ctypes
|
|
885
|
+
ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_ulong(tid), ctypes.py_object(KeyboardInterrupt))
|
|
886
|
+
except Exception:
|
|
887
|
+
pass
|
|
888
|
+
return "stopped"
|
|
889
|
+
|
|
890
|
+
|