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.
Files changed (59) hide show
  1. pygeomatic-0.2/PKG-INFO +203 -0
  2. pygeomatic-0.2/README.md +182 -0
  3. {pygeomatic-0.1.0 → pygeomatic-0.2}/pyproject.toml +1 -1
  4. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/__init__.py +10 -0
  5. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/article.py +33 -1
  6. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/macros.py +22 -1
  7. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/parse.py +18 -6
  8. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/registry.py +80 -3
  9. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/store.py +24 -0
  10. pygeomatic-0.2/src/pygeomatic/tex.py +397 -0
  11. pygeomatic-0.1.0/PKG-INFO +0 -395
  12. pygeomatic-0.1.0/README.md +0 -374
  13. {pygeomatic-0.1.0 → pygeomatic-0.2}/.gitignore +0 -0
  14. {pygeomatic-0.1.0 → pygeomatic-0.2}/LICENSE +0 -0
  15. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/coercions.json +0 -0
  16. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/coercions.py +0 -0
  17. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/emit.py +0 -0
  18. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/extensions.py +0 -0
  19. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/__init__.py +0 -0
  20. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/helpers.py +0 -0
  21. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/__init__.py +0 -0
  22. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/annotation_functions.py +0 -0
  23. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/array.py +0 -0
  24. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/autograd_functions.py +0 -0
  25. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/basic_figures.py +0 -0
  26. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/boolean_functions.py +0 -0
  27. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/complex_functions.py +0 -0
  28. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/curve_functions.py +0 -0
  29. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/intersections.py +0 -0
  30. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/ode_functions.py +0 -0
  31. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/planar_geometry.py +0 -0
  32. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/polygons.py +0 -0
  33. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/property_functions.py +0 -0
  34. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/rotation_functions.py +0 -0
  35. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/scalar_functions.py +0 -0
  36. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/special_functions.py +0 -0
  37. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/tangent_functions.py +0 -0
  38. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/tensor_functions.py +0 -0
  39. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/implementations/translation_functions.py +0 -0
  40. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/__init__.py +0 -0
  41. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/abs.py +0 -0
  42. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/add.py +0 -0
  43. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/create_overload.py +0 -0
  44. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/div.py +0 -0
  45. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/exp.py +0 -0
  46. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/log.py +0 -0
  47. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/mul.py +0 -0
  48. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/neg.py +0 -0
  49. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/pow.py +0 -0
  50. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/sqrt.py +0 -0
  51. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/functions/overloads/sub.py +0 -0
  52. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/generate.py +0 -0
  53. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/inference.py +0 -0
  54. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/macros.json +0 -0
  55. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/nodes.py +0 -0
  56. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/palette.py +0 -0
  57. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/prompting.py +0 -0
  58. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/runner.py +0 -0
  59. {pygeomatic-0.1.0 → pygeomatic-0.2}/src/pygeomatic/system_nodes.py +0 -0
@@ -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.
@@ -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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pygeomatic"
3
- version = "0.1.0"
3
+ version = "0.2"
4
4
  description = "Python mirror of the geomatic DSL function library; records calls for deterministic DSL emission"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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. Define-before-use is enforced (an id must
15
- name an earlier output).
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
- raise DslParseError(
74
- lineno, line, f"unknown node id {base!r} geomatic requires define-before-use"
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: