pygeomatic 0.1.0__tar.gz → 0.2__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.
- pygeomatic-0.2/PKG-INFO +203 -0
- pygeomatic-0.2/README.md +182 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/pyproject.toml +1 -1
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/__init__.py +10 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/article.py +33 -1
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/macros.py +22 -1
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/parse.py +18 -6
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/registry.py +80 -3
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/store.py +24 -0
- pygeomatic-0.2/src/pygeomatic/tex.py +397 -0
- pygeomatic-0.1.0/PKG-INFO +0 -395
- pygeomatic-0.1.0/README.md +0 -374
- {pygeomatic-0.1.0 → pygeomatic-0.2}/.gitignore +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/LICENSE +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/coercions.json +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/coercions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/emit.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/extensions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/__init__.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/helpers.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/__init__.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/annotation_functions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/array.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/autograd_functions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/basic_figures.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/boolean_functions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/complex_functions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/curve_functions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/intersections.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/ode_functions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/planar_geometry.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/polygons.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/property_functions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/rotation_functions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/scalar_functions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/special_functions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/tangent_functions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/tensor_functions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/translation_functions.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/__init__.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/abs.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/add.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/create_overload.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/div.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/exp.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/log.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/mul.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/neg.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/pow.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/sqrt.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/sub.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/generate.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/inference.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/macros.json +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/nodes.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/palette.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/prompting.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/runner.py +0 -0
- {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/system_nodes.py +0 -0
pygeomatic-0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pygeomatic
|
|
3
|
+
Version: 0.2
|
|
4
|
+
Summary: Python mirror of the geomatic DSL function library; records calls for deterministic DSL emission
|
|
5
|
+
Project-URL: Homepage, https://github.com/TinyVolt/pygeomatic
|
|
6
|
+
Project-URL: Repository, https://github.com/TinyVolt/pygeomatic
|
|
7
|
+
Project-URL: Issues, https://github.com/TinyVolt/pygeomatic/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: numpy>=1.26
|
|
19
|
+
Requires-Dist: pydantic>=2.7
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# pygeomatic
|
|
23
|
+
|
|
24
|
+
Python mirror of the [geomatic](https://www.tinyvolt.com/geomatic) DSL. Intended to be used in [Nova editor](https://www.tinyvolt.com/nova).
|
|
25
|
+
|
|
26
|
+
Every public function maps 1:1 to a geomatic command; calling it computes numeric values (numpy) where
|
|
27
|
+
possible **and** records the call onto a tape, from which `emit()` produces
|
|
28
|
+
geomatic DSL lines deterministically.
|
|
29
|
+
|
|
30
|
+
## Naming
|
|
31
|
+
|
|
32
|
+
Every node has an id. You control it in three ways.
|
|
33
|
+
|
|
34
|
+
**Programmatically** — pass `out=` with an f-string (or any computed string),
|
|
35
|
+
so ids can be generated in a loop:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
>>> for i in range(5):
|
|
39
|
+
... gm.point(i-3, 0, out=f'point{i}')
|
|
40
|
+
...
|
|
41
|
+
Point(id='point0', x=-3.0, y=0.0)
|
|
42
|
+
Point(id='point1', x=-2.0, y=0.0)
|
|
43
|
+
Point(id='point2', x=-1.0, y=0.0)
|
|
44
|
+
Point(id='point3', x=0.0, y=0.0)
|
|
45
|
+
Point(id='point4', x=1.0, y=0.0)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**By assignment** — the assignment target names the output (underscores become
|
|
49
|
+
dashes). Without a target, the id is auto-assigned a dashed form (`p-0`,
|
|
50
|
+
`num-1`):
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
>>> my_p
|
|
54
|
+
Point(id='my-p', x=2.0, y=3.0)
|
|
55
|
+
>>> gm.point(3,2)
|
|
56
|
+
Point(id='p-0', x=3.0, y=2.0)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Assignment naming works at any arity — `a, b, c = gm.scalar(1), gm.scalar(2), gm.scalar(3)` names all three.
|
|
60
|
+
An explicit `out="my-id"` always overrides the
|
|
61
|
+
inferred name.
|
|
62
|
+
|
|
63
|
+
Anything ambiguous or unsafe (no assignment target, attribute targets, reusing
|
|
64
|
+
a name in a loop, a name shaped like an engine auto-name, an already-taken id)
|
|
65
|
+
silently falls back to a dashed auto-id. Explicit ids must match the DSL
|
|
66
|
+
grammar: start with a letter, then letters/digits/dashes, **no underscores** —
|
|
67
|
+
and must not look like an engine auto-name (`num0`, `p3`, `text1`). The engine
|
|
68
|
+
generates those for its own internal nodes, and a collision creates a reactive
|
|
69
|
+
cycle that hangs the tab; pygeomatic's dashed ids can never collide. Name
|
|
70
|
+
inference reads the caller's bytecode, not its source, so it behaves
|
|
71
|
+
identically everywhere: files, the REPL, `python -c`, notebooks, `exec`'d
|
|
72
|
+
strings.
|
|
73
|
+
|
|
74
|
+
Whichever way a node was named, `gm.emit()` renders the tape as DSL, one line
|
|
75
|
+
per call, using each node's resolved id. The examples above emit as:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
>>> print(gm.emit())
|
|
79
|
+
point0 = \point -3 0
|
|
80
|
+
point1 = \point -2 0
|
|
81
|
+
point2 = \point -1 0
|
|
82
|
+
point3 = \point 0 0
|
|
83
|
+
point4 = \point 1 0
|
|
84
|
+
my-p = \point 2 3
|
|
85
|
+
p-0 = \point 3 2
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
## Built-in variables
|
|
90
|
+
|
|
91
|
+
Every canvas — and every `Store` — starts with the engine's default nodes
|
|
92
|
+
already registered, so a scene may reference them by id without defining them
|
|
93
|
+
first. They record no commands (they exist implicitly on every canvas) and
|
|
94
|
+
resolve against the active store at access time. Access them as module
|
|
95
|
+
attributes with dashes turned into underscores (`gm.p0`, `gm.learning_rate`),
|
|
96
|
+
or by id with `gm.node("learning-rate")`.
|
|
97
|
+
|
|
98
|
+
You may reassign any of them (`gm.scalar(0.5, out="learning-rate")`), matching
|
|
99
|
+
the engine's last-write-wins `saveNode`.
|
|
100
|
+
|
|
101
|
+
| Variable | Value | What it is / when to use it |
|
|
102
|
+
| --- | --- | --- |
|
|
103
|
+
| `p0` | `(0, 0)` | The world origin. It is the default `center`/`point2` for many commands (`\circle`, `\ellipse`, `\square`, `\rectangle`, `\reflect-point`, …), so reference it for a fixed origin instead of re-declaring `\point 0 0`. |
|
|
104
|
+
| `learning-rate` | `0.01` | Gradient-descent step size, read by `\gradient-descent-step` and `\minimize`. Reassign before a descent step to tune training speed. |
|
|
105
|
+
| `animation-speed` | `0.001` | Per-frame step size for `\animate`. Reassign larger to make animations run faster, smaller to slow them. |
|
|
106
|
+
| `grid-points` | Point array | Every integer-lattice Point currently on the canvas (built from the live canvas bounds, so numerically unknown here). Reference it to act on the whole background grid at once, e.g. apply a linear map / `\translate-array` to every grid point. |
|
|
107
|
+
| `unit` | `50` | Zoom: pixels per world unit (`unitX == unitY == unit`). Reassign to zoom (larger = more zoomed in). |
|
|
108
|
+
| `grid-opacity` | `1` | Opacity of the grid lines and axes. Set to `0` to hide the grid. |
|
|
109
|
+
| `grid-bg-color` | `""` | Solid fill painted behind the grid (empty = transparent). Reassign to a color (`grid-bg-color = COLOR-BLACK`) to give the canvas a background. |
|
|
110
|
+
| `grid-origin` | `(0, 0)` | Where the world origin sits on the canvas, in world units (`(0, 0)` = centered). Reassign to pan the view. |
|
|
111
|
+
| `T` | `true` | The boolean literal `true`, referenceable wherever a Bool argument is expected. |
|
|
112
|
+
| `F` | `false` | The boolean literal `false`, referenceable wherever a Bool argument is expected. |
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
gm.line(gm.p0, gm.point(1, 1)) # p0 by attribute
|
|
116
|
+
gm.scalar(0.5, out="learning-rate") # reassign a default (last-write-wins)
|
|
117
|
+
gm.node("unit") # the string-keyed equivalent
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Authoring articles with Nova
|
|
121
|
+
|
|
122
|
+
Write geomatic articles as markdown with pygeomatic Python instead of raw DSL;
|
|
123
|
+
`compile_article` turns them into the `{label}(command)` span format. The Python
|
|
124
|
+
runs once at compile time — readers only ever receive deterministic DSL text.
|
|
125
|
+
|
|
126
|
+
````markdown
|
|
127
|
+
```pygeomatic
|
|
128
|
+
origin = gm.p0 # top-level code → hidden {}(...) setup spans
|
|
129
|
+
a = gm.point(3, 0)
|
|
130
|
+
walk = gm.line(origin, a)
|
|
131
|
+
gm.hide(walk)
|
|
132
|
+
|
|
133
|
+
with group("walk-x"): # a named run of commands for prose to reveal
|
|
134
|
+
gm.highlight(walk)
|
|
135
|
+
gm.show(walk) # last command gets the visible label
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Reach the point by {moving a distance}(ref:walk-x) of $3$ units.
|
|
139
|
+
Or reset it inline: {set scale to 1}(scale = gm.scalar(1)).
|
|
140
|
+
````
|
|
141
|
+
|
|
142
|
+
- **One store per article**: all fences and inline spans run in document order
|
|
143
|
+
sharing state, so ids and auto-names stay consistent.
|
|
144
|
+
- **Ref expansion**: every command of a group but the last becomes a hidden
|
|
145
|
+
`{}()` span before the visible one — a click always lands on a fully set-up
|
|
146
|
+
scene.
|
|
147
|
+
- **Inline spans** (`{label}(python statement)`) are the escape hatch for
|
|
148
|
+
one-offs; article mode is last-write-wins, so `s1 = gm.scalar(1)` reassigns
|
|
149
|
+
like the DSL line it becomes.
|
|
150
|
+
- **Round-trip gate**: the compiled document is replayed with `parse_dsl` in
|
|
151
|
+
document order; broken ordering or invalid DSL fails the compile, not the
|
|
152
|
+
reader.
|
|
153
|
+
- Regular code fences and `$...$` math are never scanned for spans.
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
uv run python scripts/compile_article.py article.md -o compiled.md # one file
|
|
157
|
+
uv run python scripts/compile_articles.py articles/ dist/ # a tree
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
(equivalently `gm.compile_article(md)` in-process or `gm.run_article(md)` in a
|
|
161
|
+
subprocess; both take `extensions=` / `macros=` / `allow_coercions=`.)
|
|
162
|
+
|
|
163
|
+
### Publishing from a content repo (GitHub Action)
|
|
164
|
+
|
|
165
|
+
A content repo publishes compiled articles with one workflow file; a compile
|
|
166
|
+
error in any article fails the push and nothing is published:
|
|
167
|
+
|
|
168
|
+
```yaml
|
|
169
|
+
name: Publish articles
|
|
170
|
+
on:
|
|
171
|
+
push: {branches: [main]}
|
|
172
|
+
jobs:
|
|
173
|
+
publish:
|
|
174
|
+
permissions: {contents: write}
|
|
175
|
+
uses: TinyVolt/pygeomatic/.github/workflows/publish-articles.yml@main
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
This compiles `articles/` and force-pushes the result (plus any non-markdown
|
|
179
|
+
assets) to a `dist` branch, which raw.githubusercontent.com serves with CORS.
|
|
180
|
+
Inputs: `source`, `publish-branch`, `extensions`, `macros`, `allow-coercions`,
|
|
181
|
+
and `pygeomatic-ref` — pin the latter to a tag or SHA for reproducible output.
|
|
182
|
+
For custom pipelines, the composite action `TinyVolt/pygeomatic@<ref>` runs just
|
|
183
|
+
the compile step (the `<ref>` you pin is also the exact pygeomatic version
|
|
184
|
+
used).
|
|
185
|
+
|
|
186
|
+
### Reading published articles
|
|
187
|
+
|
|
188
|
+
Once the `dist` branch exists, the article is live — readers open it at
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
https://www.tinyvolt.com/nova/<username>/<repo>/<article>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
where `<article>` is the markdown file's path within `dist` (the `.md` suffix
|
|
195
|
+
is optional). E.g. `articles/intro.md` compiled from the repo `alice/vectors`
|
|
196
|
+
is read at `/nova/alice/vectors/intro`.
|
|
197
|
+
|
|
198
|
+
If the article uses extension commands or macros, bake their URLs into the link
|
|
199
|
+
you share so readers never load anything manually:
|
|
200
|
+
|
|
201
|
+
- `?ext=<manifest-url>` — extension manifest, loaded (sandboxed, from
|
|
202
|
+
whitelisted domains only) before the article renders.
|
|
203
|
+
- `?esm=<url>` — macro definitions, fetched and registered on page load.
|
pygeomatic-0.2/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# pygeomatic
|
|
2
|
+
|
|
3
|
+
Python mirror of the [geomatic](https://www.tinyvolt.com/geomatic) DSL. Intended to be used in [Nova editor](https://www.tinyvolt.com/nova).
|
|
4
|
+
|
|
5
|
+
Every public function maps 1:1 to a geomatic command; calling it computes numeric values (numpy) where
|
|
6
|
+
possible **and** records the call onto a tape, from which `emit()` produces
|
|
7
|
+
geomatic DSL lines deterministically.
|
|
8
|
+
|
|
9
|
+
## Naming
|
|
10
|
+
|
|
11
|
+
Every node has an id. You control it in three ways.
|
|
12
|
+
|
|
13
|
+
**Programmatically** — pass `out=` with an f-string (or any computed string),
|
|
14
|
+
so ids can be generated in a loop:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
>>> for i in range(5):
|
|
18
|
+
... gm.point(i-3, 0, out=f'point{i}')
|
|
19
|
+
...
|
|
20
|
+
Point(id='point0', x=-3.0, y=0.0)
|
|
21
|
+
Point(id='point1', x=-2.0, y=0.0)
|
|
22
|
+
Point(id='point2', x=-1.0, y=0.0)
|
|
23
|
+
Point(id='point3', x=0.0, y=0.0)
|
|
24
|
+
Point(id='point4', x=1.0, y=0.0)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**By assignment** — the assignment target names the output (underscores become
|
|
28
|
+
dashes). Without a target, the id is auto-assigned a dashed form (`p-0`,
|
|
29
|
+
`num-1`):
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
>>> my_p
|
|
33
|
+
Point(id='my-p', x=2.0, y=3.0)
|
|
34
|
+
>>> gm.point(3,2)
|
|
35
|
+
Point(id='p-0', x=3.0, y=2.0)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Assignment naming works at any arity — `a, b, c = gm.scalar(1), gm.scalar(2), gm.scalar(3)` names all three.
|
|
39
|
+
An explicit `out="my-id"` always overrides the
|
|
40
|
+
inferred name.
|
|
41
|
+
|
|
42
|
+
Anything ambiguous or unsafe (no assignment target, attribute targets, reusing
|
|
43
|
+
a name in a loop, a name shaped like an engine auto-name, an already-taken id)
|
|
44
|
+
silently falls back to a dashed auto-id. Explicit ids must match the DSL
|
|
45
|
+
grammar: start with a letter, then letters/digits/dashes, **no underscores** —
|
|
46
|
+
and must not look like an engine auto-name (`num0`, `p3`, `text1`). The engine
|
|
47
|
+
generates those for its own internal nodes, and a collision creates a reactive
|
|
48
|
+
cycle that hangs the tab; pygeomatic's dashed ids can never collide. Name
|
|
49
|
+
inference reads the caller's bytecode, not its source, so it behaves
|
|
50
|
+
identically everywhere: files, the REPL, `python -c`, notebooks, `exec`'d
|
|
51
|
+
strings.
|
|
52
|
+
|
|
53
|
+
Whichever way a node was named, `gm.emit()` renders the tape as DSL, one line
|
|
54
|
+
per call, using each node's resolved id. The examples above emit as:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
>>> print(gm.emit())
|
|
58
|
+
point0 = \point -3 0
|
|
59
|
+
point1 = \point -2 0
|
|
60
|
+
point2 = \point -1 0
|
|
61
|
+
point3 = \point 0 0
|
|
62
|
+
point4 = \point 1 0
|
|
63
|
+
my-p = \point 2 3
|
|
64
|
+
p-0 = \point 3 2
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
## Built-in variables
|
|
69
|
+
|
|
70
|
+
Every canvas — and every `Store` — starts with the engine's default nodes
|
|
71
|
+
already registered, so a scene may reference them by id without defining them
|
|
72
|
+
first. They record no commands (they exist implicitly on every canvas) and
|
|
73
|
+
resolve against the active store at access time. Access them as module
|
|
74
|
+
attributes with dashes turned into underscores (`gm.p0`, `gm.learning_rate`),
|
|
75
|
+
or by id with `gm.node("learning-rate")`.
|
|
76
|
+
|
|
77
|
+
You may reassign any of them (`gm.scalar(0.5, out="learning-rate")`), matching
|
|
78
|
+
the engine's last-write-wins `saveNode`.
|
|
79
|
+
|
|
80
|
+
| Variable | Value | What it is / when to use it |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| `p0` | `(0, 0)` | The world origin. It is the default `center`/`point2` for many commands (`\circle`, `\ellipse`, `\square`, `\rectangle`, `\reflect-point`, …), so reference it for a fixed origin instead of re-declaring `\point 0 0`. |
|
|
83
|
+
| `learning-rate` | `0.01` | Gradient-descent step size, read by `\gradient-descent-step` and `\minimize`. Reassign before a descent step to tune training speed. |
|
|
84
|
+
| `animation-speed` | `0.001` | Per-frame step size for `\animate`. Reassign larger to make animations run faster, smaller to slow them. |
|
|
85
|
+
| `grid-points` | Point array | Every integer-lattice Point currently on the canvas (built from the live canvas bounds, so numerically unknown here). Reference it to act on the whole background grid at once, e.g. apply a linear map / `\translate-array` to every grid point. |
|
|
86
|
+
| `unit` | `50` | Zoom: pixels per world unit (`unitX == unitY == unit`). Reassign to zoom (larger = more zoomed in). |
|
|
87
|
+
| `grid-opacity` | `1` | Opacity of the grid lines and axes. Set to `0` to hide the grid. |
|
|
88
|
+
| `grid-bg-color` | `""` | Solid fill painted behind the grid (empty = transparent). Reassign to a color (`grid-bg-color = COLOR-BLACK`) to give the canvas a background. |
|
|
89
|
+
| `grid-origin` | `(0, 0)` | Where the world origin sits on the canvas, in world units (`(0, 0)` = centered). Reassign to pan the view. |
|
|
90
|
+
| `T` | `true` | The boolean literal `true`, referenceable wherever a Bool argument is expected. |
|
|
91
|
+
| `F` | `false` | The boolean literal `false`, referenceable wherever a Bool argument is expected. |
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
gm.line(gm.p0, gm.point(1, 1)) # p0 by attribute
|
|
95
|
+
gm.scalar(0.5, out="learning-rate") # reassign a default (last-write-wins)
|
|
96
|
+
gm.node("unit") # the string-keyed equivalent
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Authoring articles with Nova
|
|
100
|
+
|
|
101
|
+
Write geomatic articles as markdown with pygeomatic Python instead of raw DSL;
|
|
102
|
+
`compile_article` turns them into the `{label}(command)` span format. The Python
|
|
103
|
+
runs once at compile time — readers only ever receive deterministic DSL text.
|
|
104
|
+
|
|
105
|
+
````markdown
|
|
106
|
+
```pygeomatic
|
|
107
|
+
origin = gm.p0 # top-level code → hidden {}(...) setup spans
|
|
108
|
+
a = gm.point(3, 0)
|
|
109
|
+
walk = gm.line(origin, a)
|
|
110
|
+
gm.hide(walk)
|
|
111
|
+
|
|
112
|
+
with group("walk-x"): # a named run of commands for prose to reveal
|
|
113
|
+
gm.highlight(walk)
|
|
114
|
+
gm.show(walk) # last command gets the visible label
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Reach the point by {moving a distance}(ref:walk-x) of $3$ units.
|
|
118
|
+
Or reset it inline: {set scale to 1}(scale = gm.scalar(1)).
|
|
119
|
+
````
|
|
120
|
+
|
|
121
|
+
- **One store per article**: all fences and inline spans run in document order
|
|
122
|
+
sharing state, so ids and auto-names stay consistent.
|
|
123
|
+
- **Ref expansion**: every command of a group but the last becomes a hidden
|
|
124
|
+
`{}()` span before the visible one — a click always lands on a fully set-up
|
|
125
|
+
scene.
|
|
126
|
+
- **Inline spans** (`{label}(python statement)`) are the escape hatch for
|
|
127
|
+
one-offs; article mode is last-write-wins, so `s1 = gm.scalar(1)` reassigns
|
|
128
|
+
like the DSL line it becomes.
|
|
129
|
+
- **Round-trip gate**: the compiled document is replayed with `parse_dsl` in
|
|
130
|
+
document order; broken ordering or invalid DSL fails the compile, not the
|
|
131
|
+
reader.
|
|
132
|
+
- Regular code fences and `$...$` math are never scanned for spans.
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
uv run python scripts/compile_article.py article.md -o compiled.md # one file
|
|
136
|
+
uv run python scripts/compile_articles.py articles/ dist/ # a tree
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
(equivalently `gm.compile_article(md)` in-process or `gm.run_article(md)` in a
|
|
140
|
+
subprocess; both take `extensions=` / `macros=` / `allow_coercions=`.)
|
|
141
|
+
|
|
142
|
+
### Publishing from a content repo (GitHub Action)
|
|
143
|
+
|
|
144
|
+
A content repo publishes compiled articles with one workflow file; a compile
|
|
145
|
+
error in any article fails the push and nothing is published:
|
|
146
|
+
|
|
147
|
+
```yaml
|
|
148
|
+
name: Publish articles
|
|
149
|
+
on:
|
|
150
|
+
push: {branches: [main]}
|
|
151
|
+
jobs:
|
|
152
|
+
publish:
|
|
153
|
+
permissions: {contents: write}
|
|
154
|
+
uses: TinyVolt/pygeomatic/.github/workflows/publish-articles.yml@main
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
This compiles `articles/` and force-pushes the result (plus any non-markdown
|
|
158
|
+
assets) to a `dist` branch, which raw.githubusercontent.com serves with CORS.
|
|
159
|
+
Inputs: `source`, `publish-branch`, `extensions`, `macros`, `allow-coercions`,
|
|
160
|
+
and `pygeomatic-ref` — pin the latter to a tag or SHA for reproducible output.
|
|
161
|
+
For custom pipelines, the composite action `TinyVolt/pygeomatic@<ref>` runs just
|
|
162
|
+
the compile step (the `<ref>` you pin is also the exact pygeomatic version
|
|
163
|
+
used).
|
|
164
|
+
|
|
165
|
+
### Reading published articles
|
|
166
|
+
|
|
167
|
+
Once the `dist` branch exists, the article is live — readers open it at
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
https://www.tinyvolt.com/nova/<username>/<repo>/<article>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
where `<article>` is the markdown file's path within `dist` (the `.md` suffix
|
|
174
|
+
is optional). E.g. `articles/intro.md` compiled from the repo `alice/vectors`
|
|
175
|
+
is read at `/nova/alice/vectors/intro`.
|
|
176
|
+
|
|
177
|
+
If the article uses extension commands or macros, bake their URLs into the link
|
|
178
|
+
you share so readers never load anything manually:
|
|
179
|
+
|
|
180
|
+
- `?ext=<manifest-url>` — extension manifest, loaded (sandboxed, from
|
|
181
|
+
whitelisted domains only) before the article renders.
|
|
182
|
+
- `?esm=<url>` — macro definitions, fetched and registered on page load.
|
|
@@ -105,6 +105,16 @@ from .system_nodes import ( # noqa: F401
|
|
|
105
105
|
SystemNode,
|
|
106
106
|
register_system_nodes,
|
|
107
107
|
)
|
|
108
|
+
from .tex import ( # noqa: F401
|
|
109
|
+
SCHEMA,
|
|
110
|
+
AxisExpr,
|
|
111
|
+
Selector,
|
|
112
|
+
Tex,
|
|
113
|
+
TexError,
|
|
114
|
+
harvest_tex_bindings,
|
|
115
|
+
register_tex_schema,
|
|
116
|
+
tex,
|
|
117
|
+
)
|
|
108
118
|
|
|
109
119
|
|
|
110
120
|
def __getattr__(name: str) -> GNode:
|
|
@@ -38,10 +38,27 @@ from contextvars import ContextVar
|
|
|
38
38
|
from dataclasses import dataclass, field
|
|
39
39
|
from typing import Optional, Sequence, Union
|
|
40
40
|
|
|
41
|
+
import json
|
|
42
|
+
|
|
41
43
|
from .coercions import allow_coercions
|
|
42
44
|
from .emit import emit, render_command
|
|
43
45
|
from .parse import DslParseError, parse_dsl
|
|
44
|
-
from .store import Store, _article_replay, current_store
|
|
46
|
+
from .store import Store, _article_replay, _auto_create_enabled, current_store
|
|
47
|
+
from .tex import harvest_tex_bindings
|
|
48
|
+
|
|
49
|
+
# texatlas bindings (gm.tex) are declarative page config, not DSL: the compiler
|
|
50
|
+
# harvests them from the session and snapshots them into the compiled article as
|
|
51
|
+
# an HTML comment (invisible to any markdown renderer, easily extracted by the
|
|
52
|
+
# reader). Not a span, not a fence — the round-trip gate never sees it.
|
|
53
|
+
_TEX_MANIFEST_OPEN = "<!-- texatlas:v1"
|
|
54
|
+
_TEX_MANIFEST_CLOSE = "-->"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _append_tex_manifest(compiled: str, manifest: dict) -> str:
|
|
58
|
+
body = json.dumps(manifest, separators=(",", ":"), sort_keys=False)
|
|
59
|
+
block = f"{_TEX_MANIFEST_OPEN}\n{body}\n{_TEX_MANIFEST_CLOSE}\n"
|
|
60
|
+
sep = "" if compiled.endswith("\n") or not compiled else "\n"
|
|
61
|
+
return f"{compiled}{sep}\n{block}"
|
|
45
62
|
|
|
46
63
|
|
|
47
64
|
class ArticleError(ValueError):
|
|
@@ -528,6 +545,11 @@ def compile_article(markdown: str, *, allow_coercions: bool = False) -> str:
|
|
|
528
545
|
for s in _scan_spans(part.text)
|
|
529
546
|
]
|
|
530
547
|
with Store() as check, article_mode():
|
|
548
|
+
# Strict replay: auto-creating a missing Point/Scalar/Text here would
|
|
549
|
+
# mask a define-before-use violation (the reader's engine would bind
|
|
550
|
+
# the consumer to a random-valued auto-created node, not the article's
|
|
551
|
+
# later definition), so unknown ids must fail the gate.
|
|
552
|
+
auto_create_token = _auto_create_enabled.set(False)
|
|
531
553
|
try:
|
|
532
554
|
parse_dsl(commands)
|
|
533
555
|
except DslParseError as exc:
|
|
@@ -536,6 +558,8 @@ def compile_article(markdown: str, *, allow_coercions: bool = False) -> str:
|
|
|
536
558
|
f"compiled article failed the parse round-trip (command "
|
|
537
559
|
f"{exc.lineno} in document order): {exc}",
|
|
538
560
|
) from exc
|
|
561
|
+
finally:
|
|
562
|
+
_auto_create_enabled.reset(auto_create_token)
|
|
539
563
|
replayed = emit(check).splitlines()
|
|
540
564
|
if replayed != commands:
|
|
541
565
|
for i, (got, want) in enumerate(zip(replayed, commands)):
|
|
@@ -550,6 +574,14 @@ def compile_article(markdown: str, *, allow_coercions: bool = False) -> str:
|
|
|
550
574
|
f"round-trip drift: {len(commands)} command(s) in the document, "
|
|
551
575
|
f"{len(replayed)} after replay",
|
|
552
576
|
)
|
|
577
|
+
|
|
578
|
+
# -- texatlas snapshot ---------------------------------------------------
|
|
579
|
+
# Harvest the session's gm.tex bindings (recorded off-tape) and embed them,
|
|
580
|
+
# after the round-trip gate so the DSL replay is never disturbed. Articles
|
|
581
|
+
# with no bindings are byte-for-byte unchanged.
|
|
582
|
+
manifest = harvest_tex_bindings(store)
|
|
583
|
+
if manifest:
|
|
584
|
+
compiled = _append_tex_manifest(compiled, manifest)
|
|
553
585
|
return compiled
|
|
554
586
|
|
|
555
587
|
|
|
@@ -35,14 +35,16 @@ from typing import Any, Optional, Union
|
|
|
35
35
|
from .coercions import _coercions_enabled
|
|
36
36
|
from .emit import _render_number
|
|
37
37
|
from .inference import infer_out_name
|
|
38
|
-
from .nodes import GNode
|
|
38
|
+
from .nodes import GNode, IdRef
|
|
39
39
|
from .prompting import python_name
|
|
40
40
|
from .registry import FunctionDef, P, REGISTRY
|
|
41
41
|
from .store import (
|
|
42
42
|
IDENTIFIER_RE,
|
|
43
|
+
UnresolvedId,
|
|
43
44
|
_allow_engine_ids,
|
|
44
45
|
_macro_replay,
|
|
45
46
|
current_store,
|
|
47
|
+
validate_identifier,
|
|
46
48
|
)
|
|
47
49
|
|
|
48
50
|
MACRO_CATEGORY = "Macros"
|
|
@@ -178,6 +180,25 @@ def _make_wrapper(macro: MacroDef, source: str):
|
|
|
178
180
|
subs: dict[str, str] = {}
|
|
179
181
|
tokens = []
|
|
180
182
|
for name, arg in zip(macro.params, args):
|
|
183
|
+
if isinstance(arg, (str, UnresolvedId)):
|
|
184
|
+
# A string names a node. An existing one binds like a GNode; a
|
|
185
|
+
# missing one substitutes as a bare id and the body replay
|
|
186
|
+
# auto-creates it on first typed use (engine parity: the engine
|
|
187
|
+
# runs macro bodies through the executor line-by-line).
|
|
188
|
+
ref = arg.name if isinstance(arg, UnresolvedId) else arg
|
|
189
|
+
found = store.nodes.get(ref)
|
|
190
|
+
if found is not None:
|
|
191
|
+
arg = found
|
|
192
|
+
else:
|
|
193
|
+
validate_identifier(ref)
|
|
194
|
+
if ref in macro.assigned_ids:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"\\{macro.keyword}: input id {ref!r} conflicts with "
|
|
197
|
+
"an internal macro variable"
|
|
198
|
+
)
|
|
199
|
+
tokens.append(IdRef(ref))
|
|
200
|
+
subs[name] = ref
|
|
201
|
+
continue
|
|
181
202
|
if isinstance(arg, GNode):
|
|
182
203
|
if arg.id is None:
|
|
183
204
|
raise TypeError(
|
|
@@ -11,8 +11,11 @@ Supported grammar (exactly what emit produces / the engine accepts):
|
|
|
11
11
|
|
|
12
12
|
where an arg is a number (positional notation), a node id, a property chain
|
|
13
13
|
(`v.p1.x`, whitelist-checked per node type), or — for `\\text` only — a quoted
|
|
14
|
-
string. Blank lines are skipped.
|
|
15
|
-
|
|
14
|
+
string. Blank lines are skipped. An id normally names an earlier output, but —
|
|
15
|
+
engine parity (CommandExecutor.createAndSaveNode) — an unknown bare id filling
|
|
16
|
+
a Point/Scalar/Text parameter is auto-created (random Point/Scalar payload,
|
|
17
|
+
the id itself as a Text's value) instead of erroring, so `\\line a b` works on
|
|
18
|
+
a fresh store. Auto-created nodes record no command, exactly like the engine.
|
|
16
19
|
|
|
17
20
|
Engine-generated ids (`p0`, `num1`, ...) are accepted here — pasted scenes
|
|
18
21
|
legitimately contain them — even though pygeomatic rejects them for ids YOU
|
|
@@ -36,7 +39,7 @@ from .coercions import _coercions_enabled
|
|
|
36
39
|
from .nodes import GNode, NODE_PROPERTIES
|
|
37
40
|
from .prompting import python_name
|
|
38
41
|
from .registry import REGISTRY
|
|
39
|
-
from .store import _allow_engine_ids, current_store
|
|
42
|
+
from .store import UnresolvedId, _allow_engine_ids, current_store
|
|
40
43
|
|
|
41
44
|
_LINE_RE = re.compile(
|
|
42
45
|
r"^(?:(?P<out>[A-Za-z][A-Za-z0-9-]*)\s*=\s*)?"
|
|
@@ -70,9 +73,18 @@ def _resolve_ref(tok: str, store, lineno: int, line: str):
|
|
|
70
73
|
base = m.group("base")
|
|
71
74
|
node = store.nodes.get(base)
|
|
72
75
|
if node is None:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
if m.group("path"):
|
|
77
|
+
# Engine parity (resolvePropertyAccess): a `.prop` chain carries no
|
|
78
|
+
# type hint to auto-create its base from.
|
|
79
|
+
raise DslParseError(
|
|
80
|
+
lineno,
|
|
81
|
+
line,
|
|
82
|
+
f"unknown node id {base!r} — cannot access "
|
|
83
|
+
f"'{m.group('path').lstrip('.')}' on it",
|
|
84
|
+
)
|
|
85
|
+
# A bare unknown identifier is auto-created by the binder from the
|
|
86
|
+
# parameter type it fills (engine parity: createAndSaveNode).
|
|
87
|
+
return UnresolvedId(base)
|
|
76
88
|
for field in m.group("path").lstrip(".").split(".") if m.group("path") else []:
|
|
77
89
|
allowed = NODE_PROPERTIES.get(node.type, {})
|
|
78
90
|
if field not in allowed:
|