pygeomatic 0.2__tar.gz → 0.2.1__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 → pygeomatic-0.2.1}/PKG-INFO +102 -1
- {pygeomatic-0.2 → pygeomatic-0.2.1}/README.md +101 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/pyproject.toml +1 -1
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/__init__.py +3 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/store.py +2 -1
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/tex.py +206 -9
- {pygeomatic-0.2 → pygeomatic-0.2.1}/.gitignore +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/LICENSE +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/article.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/coercions.json +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/coercions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/emit.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/extensions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/__init__.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/helpers.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/__init__.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/annotation_functions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/array.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/autograd_functions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/basic_figures.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/boolean_functions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/complex_functions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/curve_functions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/intersections.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/ode_functions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/planar_geometry.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/polygons.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/property_functions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/rotation_functions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/scalar_functions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/special_functions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/tangent_functions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/tensor_functions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/translation_functions.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/__init__.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/abs.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/add.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/create_overload.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/div.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/exp.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/log.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/mul.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/neg.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/pow.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/sqrt.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/overloads/sub.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/generate.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/inference.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/macros.json +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/macros.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/nodes.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/palette.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/parse.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/prompting.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/registry.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/runner.py +0 -0
- {pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/system_nodes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pygeomatic
|
|
3
|
-
Version: 0.2
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Python mirror of the geomatic DSL function library; records calls for deterministic DSL emission
|
|
5
5
|
Project-URL: Homepage, https://github.com/TinyVolt/pygeomatic
|
|
6
6
|
Project-URL: Repository, https://github.com/TinyVolt/pygeomatic
|
|
@@ -160,6 +160,107 @@ uv run python scripts/compile_articles.py articles/ dist/ # a tree
|
|
|
160
160
|
(equivalently `gm.compile_article(md)` in-process or `gm.run_article(md)` in a
|
|
161
161
|
subprocess; both take `extensions=` / `macros=` / `allow_coercions=`.)
|
|
162
162
|
|
|
163
|
+
### Live formulas (texatlas)
|
|
164
|
+
|
|
165
|
+
Make a KaTeX formula *addressable and reactive* — a slot showing a store node's
|
|
166
|
+
live value, matrix cells highlighted by a store node — with `gm.tex`. It is
|
|
167
|
+
**not** DSL: no `\tex-*` command exists and `emit()` never sees it. Bindings are
|
|
168
|
+
harvested straight from the session into a trailing `<!-- texatlas:v1 … -->`
|
|
169
|
+
comment on the compiled article, which the Nova reader applies at load. Reactivity
|
|
170
|
+
flows through the bound *node*: whatever drives it (a `\scalar` CommandLink, an
|
|
171
|
+
`\animate`) reflows the formula with no re-render.
|
|
172
|
+
|
|
173
|
+
Give the formula an id with a `%id:` line as the first line inside the `$$…$$`
|
|
174
|
+
block, then bind nodes to that id in a fence. (The id syntax is owned entirely
|
|
175
|
+
by the Nova/web reader — pygeomatic never parses the LaTeX; it only needs to
|
|
176
|
+
know which id string a `gm.tex(...)` call refers to.)
|
|
177
|
+
|
|
178
|
+
````markdown
|
|
179
|
+
$$
|
|
180
|
+
%id:energy
|
|
181
|
+
\int_{a}^{b} x^2 \, dx
|
|
182
|
+
$$
|
|
183
|
+
|
|
184
|
+
```pygeomatic
|
|
185
|
+
b = gm.scalar(3, out="b")
|
|
186
|
+
energy = gm.tex("energy") # matches %id:energy
|
|
187
|
+
energy.int.upper.bind(b) # show b's value in the upper limit
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Change it: {b = 5}(b = gm.scalar(5))
|
|
191
|
+
````
|
|
192
|
+
|
|
193
|
+
Matrix cells are highlighted by **selectors** built from store nodes — the
|
|
194
|
+
selector returns a weight in `[0,1]`, so a fractional (`\animate`d) node
|
|
195
|
+
crossfades between adjacent rows:
|
|
196
|
+
|
|
197
|
+
````markdown
|
|
198
|
+
$$
|
|
199
|
+
%id:M
|
|
200
|
+
\begin{pmatrix} a & b & c \\ d & e & f \\ g & h & i \end{pmatrix}
|
|
201
|
+
$$
|
|
202
|
+
|
|
203
|
+
```pygeomatic
|
|
204
|
+
r = gm.scalar(0, out="r")
|
|
205
|
+
M = gm.tex("M")
|
|
206
|
+
M.highlight(M.rows() == r, color="pink") # row r
|
|
207
|
+
M.triu().highlight(color="blue") # upper triangle
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Move it: {row 1}(r = gm.scalar(1)) · {row 2}(r = gm.scalar(2))
|
|
211
|
+
````
|
|
212
|
+
|
|
213
|
+
- **Value bind**: `t.<family>.<slot>.bind(node, show=, fmt=)`. Families/slots are
|
|
214
|
+
`int`/`sum`/`prod` → `lower`/`upper`/`body`, `frac` → `num`/`denom`,
|
|
215
|
+
`sqrt` → `body` (extend with `gm.register_tex_schema(family, slots)`). `fmt`
|
|
216
|
+
is `".2f"` / `"d"` (omit → trim to ≤4 dp); `show="symbol"` links without
|
|
217
|
+
substituting the glyph. Repeated command? `t.ints[1].upper` picks an
|
|
218
|
+
occurrence (discouraged — an edit silently retargets it).
|
|
219
|
+
- **Highlights**: build a selector over a cell's grid position and paint it.
|
|
220
|
+
Axes are `M.rows()` / `M.cols()` (or module-level `rows` / `cols` / `dim(i)`),
|
|
221
|
+
with arithmetic (`cols - rows`, `+`/`-`). Compare with `==` / `>=` / `<=` /
|
|
222
|
+
`>` / `<` (or `.eq/.ge/.le/.gt/.lt`) against a node or number; select
|
|
223
|
+
axis-aligned boxes with numpy-style slicing (`M[3:, 4:]`, node bounds stay
|
|
224
|
+
reactive); or use the named regions `M.diag()` / `M.triu()` / `M.tril()`.
|
|
225
|
+
Combine with `&` / `|` (`.and_` / `.or_`) and `.scale(node)` to fade a whole
|
|
226
|
+
selection — `scale` by a node + a `\scalar u 1` CommandLink gates a highlight
|
|
227
|
+
behind a click. Palette names (`"pink"`, `"BLUE"`) resolve to hex; raw
|
|
228
|
+
`"#f472b6"` / CSS names pass through. Full reference:
|
|
229
|
+
[docs/tex-highlight-ergonomics.md](docs/tex-highlight-ergonomics.md).
|
|
230
|
+
|
|
231
|
+
Two rules that will bite otherwise:
|
|
232
|
+
|
|
233
|
+
- **Bind replaces content, it never creates structure** — write placeholder
|
|
234
|
+
symbols in every slot you bind (`\int_{a}^{b}`, not a bare `\int`). An empty
|
|
235
|
+
slot fails validation.
|
|
236
|
+
- **The bound node must already exist** when you call `.bind()` / `.eq()` — define
|
|
237
|
+
it (`gm.scalar(…, out="b")`) above the binding so it is part of the runtime env.
|
|
238
|
+
|
|
239
|
+
**Repeated commands (`ints[i]`).** When one formula uses the same command twice
|
|
240
|
+
— e.g. `$$ \int_a^b f + \int_c^d g $$` — `t.int` is ambiguous. `t.ints` is the
|
|
241
|
+
list of all of them: `t.ints[0]` is the first `\int`, `t.ints[1]` the second.
|
|
242
|
+
The index is positional, so editing the formula (reordering or inserting an
|
|
243
|
+
`\int`) silently retargets it — prefer splitting into two formulas with distinct
|
|
244
|
+
ids, each with a single `\int`.
|
|
245
|
+
|
|
246
|
+
**Gating a highlight behind a click (`.scale`).** A bound highlight is on as
|
|
247
|
+
soon as it's bound. To reveal it only after a reader clicks, scale its weight by
|
|
248
|
+
a node that starts at `0`:
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
u = gm.scalar(0, out="u") # 0 → highlight invisible
|
|
252
|
+
M.highlight((M.rows() == r).scale(u)) # weight = row-weight × u
|
|
253
|
+
```
|
|
254
|
+
```markdown
|
|
255
|
+
{reveal}(u = gm.scalar(1)) # click sets u=1 → highlight appears
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`u=0` hides it, `u=1` shows it fully, `u=0.5` at half — the click is just a
|
|
259
|
+
normal CommandLink writing `u`.
|
|
260
|
+
|
|
261
|
+
`gm.harvest_tex_bindings(store)` returns the raw `{texId: {values, highlights}}`
|
|
262
|
+
manifest if you need it directly.
|
|
263
|
+
|
|
163
264
|
### Publishing from a content repo (GitHub Action)
|
|
164
265
|
|
|
165
266
|
A content repo publishes compiled articles with one workflow file; a compile
|
|
@@ -139,6 +139,107 @@ uv run python scripts/compile_articles.py articles/ dist/ # a tree
|
|
|
139
139
|
(equivalently `gm.compile_article(md)` in-process or `gm.run_article(md)` in a
|
|
140
140
|
subprocess; both take `extensions=` / `macros=` / `allow_coercions=`.)
|
|
141
141
|
|
|
142
|
+
### Live formulas (texatlas)
|
|
143
|
+
|
|
144
|
+
Make a KaTeX formula *addressable and reactive* — a slot showing a store node's
|
|
145
|
+
live value, matrix cells highlighted by a store node — with `gm.tex`. It is
|
|
146
|
+
**not** DSL: no `\tex-*` command exists and `emit()` never sees it. Bindings are
|
|
147
|
+
harvested straight from the session into a trailing `<!-- texatlas:v1 … -->`
|
|
148
|
+
comment on the compiled article, which the Nova reader applies at load. Reactivity
|
|
149
|
+
flows through the bound *node*: whatever drives it (a `\scalar` CommandLink, an
|
|
150
|
+
`\animate`) reflows the formula with no re-render.
|
|
151
|
+
|
|
152
|
+
Give the formula an id with a `%id:` line as the first line inside the `$$…$$`
|
|
153
|
+
block, then bind nodes to that id in a fence. (The id syntax is owned entirely
|
|
154
|
+
by the Nova/web reader — pygeomatic never parses the LaTeX; it only needs to
|
|
155
|
+
know which id string a `gm.tex(...)` call refers to.)
|
|
156
|
+
|
|
157
|
+
````markdown
|
|
158
|
+
$$
|
|
159
|
+
%id:energy
|
|
160
|
+
\int_{a}^{b} x^2 \, dx
|
|
161
|
+
$$
|
|
162
|
+
|
|
163
|
+
```pygeomatic
|
|
164
|
+
b = gm.scalar(3, out="b")
|
|
165
|
+
energy = gm.tex("energy") # matches %id:energy
|
|
166
|
+
energy.int.upper.bind(b) # show b's value in the upper limit
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Change it: {b = 5}(b = gm.scalar(5))
|
|
170
|
+
````
|
|
171
|
+
|
|
172
|
+
Matrix cells are highlighted by **selectors** built from store nodes — the
|
|
173
|
+
selector returns a weight in `[0,1]`, so a fractional (`\animate`d) node
|
|
174
|
+
crossfades between adjacent rows:
|
|
175
|
+
|
|
176
|
+
````markdown
|
|
177
|
+
$$
|
|
178
|
+
%id:M
|
|
179
|
+
\begin{pmatrix} a & b & c \\ d & e & f \\ g & h & i \end{pmatrix}
|
|
180
|
+
$$
|
|
181
|
+
|
|
182
|
+
```pygeomatic
|
|
183
|
+
r = gm.scalar(0, out="r")
|
|
184
|
+
M = gm.tex("M")
|
|
185
|
+
M.highlight(M.rows() == r, color="pink") # row r
|
|
186
|
+
M.triu().highlight(color="blue") # upper triangle
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Move it: {row 1}(r = gm.scalar(1)) · {row 2}(r = gm.scalar(2))
|
|
190
|
+
````
|
|
191
|
+
|
|
192
|
+
- **Value bind**: `t.<family>.<slot>.bind(node, show=, fmt=)`. Families/slots are
|
|
193
|
+
`int`/`sum`/`prod` → `lower`/`upper`/`body`, `frac` → `num`/`denom`,
|
|
194
|
+
`sqrt` → `body` (extend with `gm.register_tex_schema(family, slots)`). `fmt`
|
|
195
|
+
is `".2f"` / `"d"` (omit → trim to ≤4 dp); `show="symbol"` links without
|
|
196
|
+
substituting the glyph. Repeated command? `t.ints[1].upper` picks an
|
|
197
|
+
occurrence (discouraged — an edit silently retargets it).
|
|
198
|
+
- **Highlights**: build a selector over a cell's grid position and paint it.
|
|
199
|
+
Axes are `M.rows()` / `M.cols()` (or module-level `rows` / `cols` / `dim(i)`),
|
|
200
|
+
with arithmetic (`cols - rows`, `+`/`-`). Compare with `==` / `>=` / `<=` /
|
|
201
|
+
`>` / `<` (or `.eq/.ge/.le/.gt/.lt`) against a node or number; select
|
|
202
|
+
axis-aligned boxes with numpy-style slicing (`M[3:, 4:]`, node bounds stay
|
|
203
|
+
reactive); or use the named regions `M.diag()` / `M.triu()` / `M.tril()`.
|
|
204
|
+
Combine with `&` / `|` (`.and_` / `.or_`) and `.scale(node)` to fade a whole
|
|
205
|
+
selection — `scale` by a node + a `\scalar u 1` CommandLink gates a highlight
|
|
206
|
+
behind a click. Palette names (`"pink"`, `"BLUE"`) resolve to hex; raw
|
|
207
|
+
`"#f472b6"` / CSS names pass through. Full reference:
|
|
208
|
+
[docs/tex-highlight-ergonomics.md](docs/tex-highlight-ergonomics.md).
|
|
209
|
+
|
|
210
|
+
Two rules that will bite otherwise:
|
|
211
|
+
|
|
212
|
+
- **Bind replaces content, it never creates structure** — write placeholder
|
|
213
|
+
symbols in every slot you bind (`\int_{a}^{b}`, not a bare `\int`). An empty
|
|
214
|
+
slot fails validation.
|
|
215
|
+
- **The bound node must already exist** when you call `.bind()` / `.eq()` — define
|
|
216
|
+
it (`gm.scalar(…, out="b")`) above the binding so it is part of the runtime env.
|
|
217
|
+
|
|
218
|
+
**Repeated commands (`ints[i]`).** When one formula uses the same command twice
|
|
219
|
+
— e.g. `$$ \int_a^b f + \int_c^d g $$` — `t.int` is ambiguous. `t.ints` is the
|
|
220
|
+
list of all of them: `t.ints[0]` is the first `\int`, `t.ints[1]` the second.
|
|
221
|
+
The index is positional, so editing the formula (reordering or inserting an
|
|
222
|
+
`\int`) silently retargets it — prefer splitting into two formulas with distinct
|
|
223
|
+
ids, each with a single `\int`.
|
|
224
|
+
|
|
225
|
+
**Gating a highlight behind a click (`.scale`).** A bound highlight is on as
|
|
226
|
+
soon as it's bound. To reveal it only after a reader clicks, scale its weight by
|
|
227
|
+
a node that starts at `0`:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
u = gm.scalar(0, out="u") # 0 → highlight invisible
|
|
231
|
+
M.highlight((M.rows() == r).scale(u)) # weight = row-weight × u
|
|
232
|
+
```
|
|
233
|
+
```markdown
|
|
234
|
+
{reveal}(u = gm.scalar(1)) # click sets u=1 → highlight appears
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`u=0` hides it, `u=1` shows it fully, `u=0.5` at half — the click is just a
|
|
238
|
+
normal CommandLink writing `u`.
|
|
239
|
+
|
|
240
|
+
`gm.harvest_tex_bindings(store)` returns the raw `{texId: {values, highlights}}`
|
|
241
|
+
manifest if you need it directly.
|
|
242
|
+
|
|
142
243
|
### Publishing from a content repo (GitHub Action)
|
|
143
244
|
|
|
144
245
|
A content repo publishes compiled articles with one workflow file; a compile
|
|
@@ -247,7 +247,8 @@ class Store:
|
|
|
247
247
|
# texatlas bindings (gm.tex): declarative formula↔node links recorded on
|
|
248
248
|
# a channel SEPARATE from the command tape — they never emit DSL and are
|
|
249
249
|
# harvested straight from the session (tex.harvest_tex_bindings). Keyed
|
|
250
|
-
# by
|
|
250
|
+
# by formula id (the `%id:` line inside a `$$…$$` block) →
|
|
251
|
+
# {"values": [...], "highlights": [...]}.
|
|
251
252
|
self.tex_bindings: dict[str, dict] = {}
|
|
252
253
|
self._token = None
|
|
253
254
|
# Every canvas starts with the engine's default nodes (`p0`, `T`/`F`,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""gm.tex — texatlas bindings recorder (addressable, reactively styled KaTeX).
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A `$$…$$` formula in a markdown article (given an id by a `%id:` line as its
|
|
4
|
+
first line) is made addressable and reactive
|
|
4
5
|
NOT by any DSL command but by declarative *bindings* recorded here: a schema
|
|
5
6
|
slot can show a store node's live value (`energy.int.upper.bind(a)`), and matrix
|
|
6
7
|
cells can be highlighted by selectors built from store nodes
|
|
@@ -122,6 +123,26 @@ def _env_ref(value: Union[GNode, str, int, float], *, what: str) -> dict:
|
|
|
122
123
|
return {"node": _node_id(value, what=what)}
|
|
123
124
|
|
|
124
125
|
|
|
126
|
+
def _matrix_index(matrix: int) -> int:
|
|
127
|
+
"""Validate an author-supplied matrix occurrence index (a non-negative int).
|
|
128
|
+
|
|
129
|
+
This is which matrix in a multi-matrix formula a highlight paints — see
|
|
130
|
+
`Tex.highlight` for the source-order counting rule. Python never counts the
|
|
131
|
+
matrices itself (it does not parse LaTeX); the author supplies the integer."""
|
|
132
|
+
if isinstance(matrix, bool) or not isinstance(matrix, int) or matrix < 0:
|
|
133
|
+
raise TexError(f"matrix index must be a non-negative int, got {matrix!r}")
|
|
134
|
+
return matrix
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _highlight_entry(selector_json: dict, color: str, matrix: int) -> dict:
|
|
138
|
+
"""A `HighlightBinding` wire entry. `matrix` (occurrence index) is omitted
|
|
139
|
+
when 0 so single-matrix formulas stay byte-identical to the v1 output."""
|
|
140
|
+
entry: dict = {"selector": selector_json, "color": _resolve_color(color)}
|
|
141
|
+
if _matrix_index(matrix):
|
|
142
|
+
entry["matrix"] = matrix
|
|
143
|
+
return entry
|
|
144
|
+
|
|
145
|
+
|
|
125
146
|
def _resolve_color(color: str) -> str:
|
|
126
147
|
"""Resolve a palette name to its hex before it crosses the wire; pass any
|
|
127
148
|
other CSS color string (`#f472b6`, `pink`, `rgb(...)`) through unchanged."""
|
|
@@ -163,6 +184,40 @@ class AxisExpr:
|
|
|
163
184
|
def le(self, value: Union[GNode, str, int, float]) -> "Selector":
|
|
164
185
|
return Selector({"op": "le", "axis": self._json(), "value": _env_ref(value, what="selector value")})
|
|
165
186
|
|
|
187
|
+
def gt(self, value: Union[GNode, str, int, float]) -> "Selector":
|
|
188
|
+
return Selector({"op": "gt", "axis": self._json(), "value": _env_ref(value, what="selector value")})
|
|
189
|
+
|
|
190
|
+
def lt(self, value: Union[GNode, str, int, float]) -> "Selector":
|
|
191
|
+
return Selector({"op": "lt", "axis": self._json(), "value": _env_ref(value, what="selector value")})
|
|
192
|
+
|
|
193
|
+
# Rich comparison sugar: `axis == v` / `>= v` / `<= v` / `> v` / `< v` are the
|
|
194
|
+
# `.eq/.ge/.le/.gt/.lt` wire ops. Strict `>`/`<` are first-class in the runtime
|
|
195
|
+
# (CONTRACT.md), so a node bound is fine — no integer shift here.
|
|
196
|
+
def __eq__(self, value: object) -> "Selector": # type: ignore[override]
|
|
197
|
+
return self.eq(value) # type: ignore[arg-type]
|
|
198
|
+
|
|
199
|
+
def __ge__(self, value: Union[GNode, str, int, float]) -> "Selector":
|
|
200
|
+
return self.ge(value)
|
|
201
|
+
|
|
202
|
+
def __le__(self, value: Union[GNode, str, int, float]) -> "Selector":
|
|
203
|
+
return self.le(value)
|
|
204
|
+
|
|
205
|
+
def __gt__(self, value: Union[GNode, str, int, float]) -> "Selector":
|
|
206
|
+
return self.gt(value)
|
|
207
|
+
|
|
208
|
+
def __lt__(self, value: Union[GNode, str, int, float]) -> "Selector":
|
|
209
|
+
return self.lt(value)
|
|
210
|
+
|
|
211
|
+
def __radd__(self, other: Union[int, float]) -> "AxisExpr": # 1 + rows
|
|
212
|
+
return _as_axis(other).add(self)
|
|
213
|
+
|
|
214
|
+
def __rsub__(self, other: Union[int, float]) -> "AxisExpr": # 1 - rows
|
|
215
|
+
return _as_axis(other).sub(self)
|
|
216
|
+
|
|
217
|
+
# `__eq__` makes instances non-hashable by Python's rules; that is correct
|
|
218
|
+
# here (an axis is an expression builder, never a dict key or set member).
|
|
219
|
+
__hash__ = None # type: ignore[assignment]
|
|
220
|
+
|
|
166
221
|
def _json(self) -> dict: # pragma: no cover - overridden
|
|
167
222
|
raise NotImplementedError
|
|
168
223
|
|
|
@@ -199,6 +254,31 @@ def _as_axis(value: Union[AxisExpr, int, float]) -> AxisExpr:
|
|
|
199
254
|
return _AxisConst(value)
|
|
200
255
|
|
|
201
256
|
|
|
257
|
+
# Free axis handles — an axis carries no reference to any formula, so these are
|
|
258
|
+
# shared module singletons (immutable: every operator returns a NEW node). Write
|
|
259
|
+
# `rows`/`cols`/`dim(i)` directly instead of `M.rows()`/`M.cols()`.
|
|
260
|
+
_AXIS_NAMES: tuple[str, ...] = ("row", "col")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def dim(i: int) -> AxisExpr:
|
|
264
|
+
"""The `i`-th grid axis as an expression (`dim(0)` == `rows`, `dim(1)` == `cols`).
|
|
265
|
+
|
|
266
|
+
Matrices are 2-D browser-side, so only dims 0 and 1 resolve today; higher
|
|
267
|
+
ranks await browser-schema support (keeping the wire's `row`/`col` names)."""
|
|
268
|
+
if isinstance(i, bool) or not isinstance(i, int) or i < 0:
|
|
269
|
+
raise TexError(f"axis index must be a non-negative int, got {i!r}")
|
|
270
|
+
if i >= len(_AXIS_NAMES):
|
|
271
|
+
raise TexError(
|
|
272
|
+
f"axis {i} is unsupported: matrices are 2-D browser-side (dims 0 and 1); "
|
|
273
|
+
"rank > 2 awaits browser-schema support"
|
|
274
|
+
)
|
|
275
|
+
return _NamedAxis(_AXIS_NAMES[i])
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
rows: AxisExpr = _NamedAxis("row")
|
|
279
|
+
cols: AxisExpr = _NamedAxis("col")
|
|
280
|
+
|
|
281
|
+
|
|
202
282
|
class Selector:
|
|
203
283
|
"""A highlight selector: `(cell, env) -> weight`. Combine with `and_`/`or_`
|
|
204
284
|
(min/max) or fade the whole selection with `scale`."""
|
|
@@ -228,6 +308,37 @@ def _as_selector(value: "Selector") -> dict:
|
|
|
228
308
|
return value._json_
|
|
229
309
|
|
|
230
310
|
|
|
311
|
+
class _Region(Selector):
|
|
312
|
+
"""A selector that remembers which formula it targets, so it can paint
|
|
313
|
+
itself directly: `M[3:, 4:].highlight()`, `M.triu().highlight(color=...)`.
|
|
314
|
+
It IS a `Selector`, so it still composes (`M[3:, :] | M[:, 4:]`) and can be
|
|
315
|
+
passed to `M.highlight(...)`; the combinators keep the target so the result
|
|
316
|
+
stays paintable."""
|
|
317
|
+
|
|
318
|
+
def __init__(self, tex_id: str, json: dict) -> None:
|
|
319
|
+
super().__init__(json)
|
|
320
|
+
self._tex_id = tex_id
|
|
321
|
+
|
|
322
|
+
def highlight(self, *, color: str = "COLOR-YELLOW", matrix: int = 0) -> None:
|
|
323
|
+
"""Paint this region on its formula, in `color`, on the `matrix`-th
|
|
324
|
+
matrix of the formula (see `Tex.highlight` for both)."""
|
|
325
|
+
_bindings(self._tex_id)["highlights"].append(
|
|
326
|
+
_highlight_entry(self._json_, color, matrix)
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def and_(self, other: "Selector") -> "_Region":
|
|
330
|
+
return _Region(self._tex_id, {"op": "and", "a": self._json_, "b": _as_selector(other)})
|
|
331
|
+
|
|
332
|
+
def or_(self, other: "Selector") -> "_Region":
|
|
333
|
+
return _Region(self._tex_id, {"op": "or", "a": self._json_, "b": _as_selector(other)})
|
|
334
|
+
|
|
335
|
+
def scale(self, by: Union[GNode, str, int, float]) -> "_Region":
|
|
336
|
+
return _Region(self._tex_id, {"op": "scale", "sel": self._json_, "by": _env_ref(by, what="scale factor")})
|
|
337
|
+
|
|
338
|
+
__and__ = and_
|
|
339
|
+
__or__ = or_
|
|
340
|
+
|
|
341
|
+
|
|
231
342
|
# ---------------------------------------------------------------------------
|
|
232
343
|
# Slot addressing — energy.int.upper.bind(a)
|
|
233
344
|
# ---------------------------------------------------------------------------
|
|
@@ -313,14 +424,23 @@ class _FamilyList:
|
|
|
313
424
|
|
|
314
425
|
|
|
315
426
|
# ---------------------------------------------------------------------------
|
|
316
|
-
# Tex — the callable matching a
|
|
427
|
+
# Tex — the callable matching a $$…$$ formula (by its `%id:` id)
|
|
317
428
|
# ---------------------------------------------------------------------------
|
|
318
429
|
|
|
319
430
|
|
|
320
431
|
class Tex:
|
|
321
|
-
"""Handle to a
|
|
322
|
-
value (`t.int.upper.bind(a)`), or
|
|
323
|
-
|
|
432
|
+
"""Handle to a `$$…$$` formula (addressed by its `%id:` id). Descend into a slot family to bind a
|
|
433
|
+
value (`t.int.upper.bind(a)`), or highlight matrix cells. Highlights have
|
|
434
|
+
several ergonomic surfaces, all lowering to the same selector JSON (see
|
|
435
|
+
docs/tex-highlight-ergonomics.md):
|
|
436
|
+
|
|
437
|
+
t.highlight(rows == r, color="pink") # free axes + operators (#1, #2)
|
|
438
|
+
t.highlight(cols - rows > 0) # cross-axis relation
|
|
439
|
+
t[3:, 4:].highlight(color="green") # numpy-style box (#3)
|
|
440
|
+
t.triu().highlight() # named region (#6)
|
|
441
|
+
|
|
442
|
+
`rows`/`cols`/`dim(i)` are module-level axis handles; `t.rows()`/`t.cols()`
|
|
443
|
+
remain as aliases."""
|
|
324
444
|
|
|
325
445
|
def __init__(self, tex_id: str) -> None:
|
|
326
446
|
self.id = tex_id
|
|
@@ -336,13 +456,90 @@ class Tex:
|
|
|
336
456
|
"""The cell's column index, as an axis expression."""
|
|
337
457
|
return _NamedAxis("col")
|
|
338
458
|
|
|
339
|
-
def highlight(
|
|
459
|
+
def highlight(
|
|
460
|
+
self, selector: Selector, *, color: str = "COLOR-YELLOW", matrix: int = 0
|
|
461
|
+
) -> None:
|
|
340
462
|
"""Paint the cells the selector weights, in `color` (a palette name is
|
|
341
|
-
resolved to hex here; any other CSS color passes through).
|
|
463
|
+
resolved to hex here; any other CSS color passes through).
|
|
464
|
+
|
|
465
|
+
`matrix` is the 0-based occurrence index of which matrix in the formula
|
|
466
|
+
to paint, in document (source) order — for a formula with more than one
|
|
467
|
+
matrix. It defaults to 0 (the first / only matrix) and is omitted from
|
|
468
|
+
the wire JSON when 0, so single-matrix formulas are unchanged. Each
|
|
469
|
+
highlight carries its own index, so different highlights on one formula
|
|
470
|
+
may target different matrices.
|
|
471
|
+
|
|
472
|
+
Python does NOT parse the LaTeX to count matrices — you supply the
|
|
473
|
+
integer — so you must count the SAME way the browser does: count only
|
|
474
|
+
genuine matrices, in source order, SKIPPING equation-layout blocks.
|
|
475
|
+
Skip: `aligned`, `align`, `split`, `alignat`, `gathered`, `gather`, `CD`.
|
|
476
|
+
Count: `matrix` / `pmatrix` / `bmatrix` / `Bmatrix` / `vmatrix` /
|
|
477
|
+
`Vmatrix` (and `*`-variants), plain `array`, `cases` / `dcases` /
|
|
478
|
+
`rcases`, `smallmatrix`, `subarray`. So in
|
|
479
|
+
`\\begin{aligned} ... \\begin{pmatrix}...\\end{pmatrix} ... \\end{aligned}`
|
|
480
|
+
the pmatrix is matrix index 0 (the aligned wrapper is not counted). An
|
|
481
|
+
out-of-range index is non-fatal browser-side (that highlight paints
|
|
482
|
+
nothing; the rest still render), so no error is raised here."""
|
|
342
483
|
_bindings(self.id)["highlights"].append(
|
|
343
|
-
|
|
484
|
+
_highlight_entry(_as_selector(selector), color, matrix)
|
|
344
485
|
)
|
|
345
486
|
|
|
487
|
+
def __getitem__(self, key) -> _Region:
|
|
488
|
+
"""numpy-style cell region: `M[3:, 4:]`, `M[:, c]`, `M[r, ...]`.
|
|
489
|
+
|
|
490
|
+
Each index constrains one axis (0 = row, 1 = col, ...): a slice is an
|
|
491
|
+
axis-aligned box (`start` inclusive, `stop` exclusive), a bare int or
|
|
492
|
+
node is an exact index (`==`). A node as a `start` or an index stays
|
|
493
|
+
reactive — retarget it with `\\scalar` and the region follows. Omitted
|
|
494
|
+
or `:` axes are unconstrained; a trailing `...` documents that. Returns a
|
|
495
|
+
paintable region: `M[3:, 4:].highlight(color="pink")`. Cross-axis
|
|
496
|
+
relations (diagonals, triangles) are NOT boxes — use the axis operators
|
|
497
|
+
or `.diag()`/`.triu()`/`.tril()`."""
|
|
498
|
+
keys = key if isinstance(key, tuple) else (key,)
|
|
499
|
+
# Ellipsis is a no-op (unmentioned axes are already unconstrained), but
|
|
500
|
+
# accepted at the end for readability. Use identity — `==` is overloaded.
|
|
501
|
+
if any(k is Ellipsis for k in keys):
|
|
502
|
+
if sum(1 for k in keys if k is Ellipsis) > 1 or keys[-1] is not Ellipsis:
|
|
503
|
+
raise TexError(
|
|
504
|
+
"'...' is only supported as the final index "
|
|
505
|
+
"(trailing axes are unconstrained by default)"
|
|
506
|
+
)
|
|
507
|
+
keys = keys[:-1]
|
|
508
|
+
parts: list[Selector] = []
|
|
509
|
+
for axis_i, k in enumerate(keys):
|
|
510
|
+
ax = dim(axis_i)
|
|
511
|
+
if isinstance(k, slice):
|
|
512
|
+
if k.step not in (None, 1):
|
|
513
|
+
raise TexError("slice step is not supported in a cell region")
|
|
514
|
+
if k.start is not None:
|
|
515
|
+
parts.append(ax.ge(k.start)) # inclusive start (node or int)
|
|
516
|
+
if k.stop is not None:
|
|
517
|
+
parts.append(ax.lt(k.stop)) # exclusive stop -> < stop (node or int)
|
|
518
|
+
elif isinstance(k, GNode) or (isinstance(k, int) and not isinstance(k, bool)) or isinstance(k, str):
|
|
519
|
+
parts.append(ax.eq(k)) # exact index
|
|
520
|
+
else:
|
|
521
|
+
raise TexError(f"unsupported cell index {k!r}: use an int, a node, a node id, or a slice")
|
|
522
|
+
if not parts:
|
|
523
|
+
raise TexError(
|
|
524
|
+
"cell region selects the whole matrix — constrain at least one axis"
|
|
525
|
+
)
|
|
526
|
+
sel: Selector = parts[0]
|
|
527
|
+
for p in parts[1:]:
|
|
528
|
+
sel = sel.and_(p)
|
|
529
|
+
return _Region(self.id, sel._json())
|
|
530
|
+
|
|
531
|
+
def diag(self, k: int = 0) -> _Region:
|
|
532
|
+
"""The `k`-th diagonal (`col - row == k`; `k=0` is the main diagonal)."""
|
|
533
|
+
return _Region(self.id, cols.sub(rows).eq(k)._json())
|
|
534
|
+
|
|
535
|
+
def triu(self, k: int = 0) -> _Region:
|
|
536
|
+
"""Upper triangle from the `k`-th diagonal up (`col - row >= k`)."""
|
|
537
|
+
return _Region(self.id, cols.sub(rows).ge(k)._json())
|
|
538
|
+
|
|
539
|
+
def tril(self, k: int = 0) -> _Region:
|
|
540
|
+
"""Lower triangle from the `k`-th diagonal down (`col - row <= k`)."""
|
|
541
|
+
return _Region(self.id, cols.sub(rows).le(k)._json())
|
|
542
|
+
|
|
346
543
|
# -- slot families ------------------------------------------------------
|
|
347
544
|
|
|
348
545
|
def __getattr__(self, name: str) -> Union[_Family, _FamilyList]:
|
|
@@ -360,7 +557,7 @@ class Tex:
|
|
|
360
557
|
|
|
361
558
|
|
|
362
559
|
def tex(tex_id: str) -> Tex:
|
|
363
|
-
"""A handle to the
|
|
560
|
+
"""A handle to the `$$…$$` formula (by its `%id:` id) in the current article, for binding
|
|
364
561
|
values and highlights to it. Its bindings record on the active store's
|
|
365
562
|
texatlas channel, harvested (not emitted) into the compiled article."""
|
|
366
563
|
if not isinstance(tex_id, str) or not tex_id:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/annotation_functions.py
RENAMED
|
File without changes
|
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/autograd_functions.py
RENAMED
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/basic_figures.py
RENAMED
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/boolean_functions.py
RENAMED
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/complex_functions.py
RENAMED
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/curve_functions.py
RENAMED
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/intersections.py
RENAMED
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/ode_functions.py
RENAMED
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/planar_geometry.py
RENAMED
|
File without changes
|
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/property_functions.py
RENAMED
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/rotation_functions.py
RENAMED
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/scalar_functions.py
RENAMED
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/special_functions.py
RENAMED
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/tangent_functions.py
RENAMED
|
File without changes
|
{pygeomatic-0.2 → pygeomatic-0.2.1}/src/pygeomatic/functions/implementations/tensor_functions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|