simit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simit/__init__.py +39 -0
- simit/api.py +258 -0
- simit/backends/__init__.py +112 -0
- simit/backends/bagel.py +359 -0
- simit/backends/base.py +114 -0
- simit/backends/hf.py +336 -0
- simit/backends/imagegen.py +136 -0
- simit/backends/lance.py +338 -0
- simit/backends/mot/engine.py +844 -0
- simit/backends/mot/kernels.py +130 -0
- simit/backends/mot/modeling.py +514 -0
- simit/backends/mot/wan_vae.py +872 -0
- simit/backends/vllm.py +204 -0
- simit/config.py +112 -0
- simit/metrics.py +113 -0
- simit/pipeline/core.py +586 -0
- simit/pipeline/prompts.py +167 -0
- simit/skills/__init__.py +6 -0
- simit/skills/assets/MERMAID_LICENSE +21 -0
- simit/skills/assets/mermaid.min.js +3587 -0
- simit/skills/base.py +172 -0
- simit/skills/builtin.py +614 -0
- simit/skills/helpers.py +197 -0
- simit/skills/pool.py +262 -0
- simit/skills/prompts.py +2374 -0
- simit/skills/renderers.py +1292 -0
- simit/skills/routing.py +89 -0
- simit/skills/sandbox.py +188 -0
- simit/skills/web.py +253 -0
- simit/tune.py +323 -0
- simit/types.py +96 -0
- simit/utils.py +67 -0
- simit-0.1.0.dist-info/METADATA +388 -0
- simit-0.1.0.dist-info/RECORD +38 -0
- simit-0.1.0.dist-info/WHEEL +5 -0
- simit-0.1.0.dist-info/licenses/LICENSE +202 -0
- simit-0.1.0.dist-info/licenses/src/simit/skills/assets/MERMAID_LICENSE +21 -0
- simit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1292 @@
|
|
|
1
|
+
"""Deterministic DSL parsers and renderers, one per category -- each takes
|
|
2
|
+
the model's narrow extracted structure (never its layout code) and renders
|
|
3
|
+
it with a real plotting/drawing library."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import textwrap
|
|
7
|
+
|
|
8
|
+
import matplotlib
|
|
9
|
+
|
|
10
|
+
matplotlib.use("Agg")
|
|
11
|
+
import matplotlib.pyplot as plt # noqa: E402
|
|
12
|
+
import networkx as nx
|
|
13
|
+
import numpy as np
|
|
14
|
+
import schemdraw
|
|
15
|
+
import schemdraw.elements as elm
|
|
16
|
+
from matplotlib.patches import Arc, Circle, FancyArrowPatch, FancyBboxPatch, Polygon, Rectangle, RegularPolygon
|
|
17
|
+
from matplotlib.path import Path as MplPath
|
|
18
|
+
from matplotlib.ticker import MaxNLocator
|
|
19
|
+
from matplotlib.transforms import Affine2D
|
|
20
|
+
|
|
21
|
+
from .helpers import autofix_text_overlaps
|
|
22
|
+
|
|
23
|
+
# Bounded by a lookahead for the next NODE:/EDGE: marker (or end of string)
|
|
24
|
+
# rather than end-of-line: the model sometimes puts more than one on a
|
|
25
|
+
# single line, and a line-anchored ($) regex would swallow the rest of the
|
|
26
|
+
# line into the first match's captured text instead of finding the next one.
|
|
27
|
+
NODE_RE = re.compile(r"NODE:\s*(.+?)\s*(?=NODE:|EDGE:|$)", re.IGNORECASE | re.DOTALL)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
EDGE_RE = re.compile(
|
|
31
|
+
r"EDGE:\s*(.+?)\s*->\s*(.+?)\s*(?::\s*(.+?))?\s*(?=NODE:|EDGE:|$)",
|
|
32
|
+
re.IGNORECASE | re.DOTALL,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_dsl(text: str) -> tuple[list[str], list[tuple[str, str, str]]]:
|
|
37
|
+
"""Parse NODE:/EDGE: lines into (node_order, edges). Edges referencing an
|
|
38
|
+
undeclared node append it defensively so a slightly malformed response
|
|
39
|
+
still renders something reasonable instead of silently dropping content."""
|
|
40
|
+
nodes: list[str] = []
|
|
41
|
+
seen = set()
|
|
42
|
+
for m in NODE_RE.finditer(text):
|
|
43
|
+
name = m.group(1).strip()
|
|
44
|
+
if name and name not in seen:
|
|
45
|
+
nodes.append(name)
|
|
46
|
+
seen.add(name)
|
|
47
|
+
|
|
48
|
+
raw_edges: list[tuple[str, str, str]] = []
|
|
49
|
+
for m in EDGE_RE.finditer(text):
|
|
50
|
+
src, dst, label = m.group(1).strip(), m.group(2).strip(), (m.group(3) or "").strip()
|
|
51
|
+
for name in (src, dst):
|
|
52
|
+
if name not in seen:
|
|
53
|
+
nodes.append(name)
|
|
54
|
+
seen.add(name)
|
|
55
|
+
raw_edges.append((src, dst, label))
|
|
56
|
+
|
|
57
|
+
# The model occasionally emits the same (src, dst) connection twice with
|
|
58
|
+
# conflicting labels (e.g. both ": yes" and ": no" on a step that isn't
|
|
59
|
+
# actually the branch point) -- collapse duplicates into one edge and
|
|
60
|
+
# drop the label entirely when it's ambiguous, rather than rendering two
|
|
61
|
+
# overlapping arrows or guessing which label is "right".
|
|
62
|
+
by_pair: dict[tuple[str, str], list[str]] = {}
|
|
63
|
+
pair_order: list[tuple[str, str]] = []
|
|
64
|
+
for src, dst, label in raw_edges:
|
|
65
|
+
key = (src, dst)
|
|
66
|
+
if key not in by_pair:
|
|
67
|
+
by_pair[key] = []
|
|
68
|
+
pair_order.append(key)
|
|
69
|
+
if label:
|
|
70
|
+
by_pair[key].append(label)
|
|
71
|
+
|
|
72
|
+
edges: list[tuple[str, str, str]] = []
|
|
73
|
+
for src, dst in pair_order:
|
|
74
|
+
labels = by_pair[(src, dst)]
|
|
75
|
+
label = labels[0] if len(set(labels)) == 1 and labels else ""
|
|
76
|
+
edges.append((src, dst, label))
|
|
77
|
+
return nodes, edges
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def render_flowchart(
|
|
81
|
+
nodes: list[str], edges: list[tuple[str, str, str]], out_path: str,
|
|
82
|
+
spacing: float = 2.2, box_h: float = 0.9,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Deterministic auto-layout: nodes stack top-to-bottom in declaration
|
|
85
|
+
order; any edge that doesn't connect immediately-adjacent nodes (a
|
|
86
|
+
loop-back, or a forward edge that skips over other nodes -- e.g. one node
|
|
87
|
+
fanning out to two non-adjacent destinations) is drawn as a curved arrow
|
|
88
|
+
around the side instead of straight through intervening boxes. No LLM
|
|
89
|
+
involvement -- this always produces a structurally correct diagram for
|
|
90
|
+
whatever node/edge list it's given."""
|
|
91
|
+
order = {name: i for i, name in enumerate(nodes)}
|
|
92
|
+
positions = {
|
|
93
|
+
name: (0.0, -i * spacing, max(2.6, 0.13 * len(name) + 0.8))
|
|
94
|
+
for i, name in enumerate(nodes)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fig, ax = plt.subplots(figsize=(7.0, max(4.0, 1.3 * len(nodes))))
|
|
98
|
+
for name, (cx, cy, w) in positions.items():
|
|
99
|
+
patch = FancyBboxPatch(
|
|
100
|
+
(cx - w / 2, cy - box_h / 2), w, box_h, boxstyle="round,pad=0.08",
|
|
101
|
+
edgecolor="black", facecolor="#cfe8ff",
|
|
102
|
+
)
|
|
103
|
+
ax.add_patch(patch)
|
|
104
|
+
ax.text(cx, cy, name, ha="center", va="center", fontsize=10)
|
|
105
|
+
|
|
106
|
+
side_edge_count = 0
|
|
107
|
+
max_w = max((w for _, _, w in positions.values()), default=2.6)
|
|
108
|
+
max_extent = max_w / 2 # widest point reached by any box, curve, or label so far
|
|
109
|
+
for src, dst, label in edges:
|
|
110
|
+
if src not in positions or dst not in positions:
|
|
111
|
+
continue
|
|
112
|
+
sx, sy, sw = positions[src]
|
|
113
|
+
dx, dy, dw = positions[dst]
|
|
114
|
+
if order[dst] == order[src] + 1: # adjacent: straight arrow down
|
|
115
|
+
arrow = FancyArrowPatch(
|
|
116
|
+
(sx, sy - box_h / 2), (dx, dy + box_h / 2),
|
|
117
|
+
arrowstyle="-|>", mutation_scale=15, color="black",
|
|
118
|
+
)
|
|
119
|
+
ax.add_patch(arrow)
|
|
120
|
+
if label:
|
|
121
|
+
ax.text((sx + dx) / 2 + 0.3, (sy + dy) / 2, label, fontsize=9)
|
|
122
|
+
else: # non-adjacent (loop-back or skip-ahead): route around the side
|
|
123
|
+
# with a right-angle (Manhattan) path rather than a curve -- a
|
|
124
|
+
# bezier's bulge can visually cross straight edges and ambiguously
|
|
125
|
+
# merge into a box's edge for short hops; a right-angle detour
|
|
126
|
+
# stays unambiguous regardless of how close src and dst are.
|
|
127
|
+
side_edge_count += 1
|
|
128
|
+
route_x = max_w / 2 + 0.8 + 0.7 * (side_edge_count - 1)
|
|
129
|
+
p1 = (sx + sw / 2, sy)
|
|
130
|
+
p2 = (dx + dw / 2, dy)
|
|
131
|
+
verts = [p1, (route_x, sy), (route_x, dy), p2]
|
|
132
|
+
codes = [MplPath.MOVETO, MplPath.LINETO, MplPath.LINETO, MplPath.LINETO]
|
|
133
|
+
arrow = FancyArrowPatch(
|
|
134
|
+
path=MplPath(verts, codes), arrowstyle="-|>",
|
|
135
|
+
mutation_scale=15, color="black", linewidth=1.3,
|
|
136
|
+
)
|
|
137
|
+
ax.add_patch(arrow)
|
|
138
|
+
if label:
|
|
139
|
+
ax.text(route_x + 0.15, (sy + dy) / 2, label, fontsize=9, va="center")
|
|
140
|
+
max_extent = max(max_extent, route_x + len(label) * 0.12 + 0.5)
|
|
141
|
+
else:
|
|
142
|
+
max_extent = max(max_extent, route_x + 0.3)
|
|
143
|
+
|
|
144
|
+
xs = [p[0] for p in positions.values()]
|
|
145
|
+
ys = [p[1] for p in positions.values()]
|
|
146
|
+
margin_x = max_extent + 0.8
|
|
147
|
+
ax.set_xlim(min(xs) - margin_x, max(xs) + margin_x)
|
|
148
|
+
ax.set_ylim(min(ys) - 1.2, max(ys) + 1.2)
|
|
149
|
+
ax.set_aspect("equal")
|
|
150
|
+
ax.axis("off")
|
|
151
|
+
autofix_text_overlaps(fig)
|
|
152
|
+
plt.savefig(out_path, dpi=150, bbox_inches="tight")
|
|
153
|
+
plt.close(fig)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
SMILES_RE = re.compile(r"SMILES:\s*(\S+)", re.IGNORECASE)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
TITLE_RE = re.compile(r"TITLE:\s*(.+)", re.IGNORECASE)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse_molecule(text: str) -> tuple[str | None, str]:
|
|
163
|
+
smiles_m = SMILES_RE.search(text)
|
|
164
|
+
title_m = TITLE_RE.search(text)
|
|
165
|
+
smiles = smiles_m.group(1).strip() if smiles_m else None
|
|
166
|
+
title = title_m.group(1).strip() if title_m else ""
|
|
167
|
+
return smiles, title
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Testing revealed the model's chemistry *knowledge* (not its SMILES syntax)
|
|
171
|
+
# is unreliable even for common molecules: asked for ethanol, benzene, and
|
|
172
|
+
# glucose, it confidently returned syntactically-valid SMILES for the wrong
|
|
173
|
+
# molecule every time (acetic acid, toluene, an unrelated branched alcohol).
|
|
174
|
+
# Prompting can't fix a model not knowing a fact. A small lookup table for
|
|
175
|
+
# the molecules anyone is actually likely to ask for is far more reliable
|
|
176
|
+
# than hoping a 7B general-purpose model recalls them correctly; the LLM
|
|
177
|
+
# path remains as a fallback for anything not in this list.
|
|
178
|
+
COMMON_MOLECULES = {
|
|
179
|
+
"water": "O", "methane": "C", "ethanol": "CCO", "benzene": "c1ccccc1",
|
|
180
|
+
"carbon dioxide": "O=C=O", "ammonia": "N", "acetic acid": "CC(=O)O",
|
|
181
|
+
"glucose": "C(C1C(C(C(C(O1)O)O)O)O)O", "aspirin": "CC(=O)Oc1ccccc1C(=O)O",
|
|
182
|
+
"caffeine": "CN1C=NC2=C1C(=O)N(C)C(=O)N2C", "methanol": "CO",
|
|
183
|
+
"propane": "CCC", "butane": "CCCC", "ethylene": "C=C", "acetylene": "C#C",
|
|
184
|
+
"toluene": "Cc1ccccc1", "phenol": "Oc1ccccc1", "glycine": "NCC(=O)O",
|
|
185
|
+
"sodium chloride": "[Na+].[Cl-]", "table salt": "[Na+].[Cl-]",
|
|
186
|
+
"hydrogen peroxide": "OO", "sulfuric acid": "OS(=O)(=O)O",
|
|
187
|
+
"acetone": "CC(=O)C", "formaldehyde": "C=O", "urea": "NC(=O)N",
|
|
188
|
+
"cholesterol": "CC(C)CCCC(C)C1CCC2C1(CCC3C2CC=C4C3(CCC(C4)O)C)C",
|
|
189
|
+
"nicotine": "CN1CCCC1c1cccnc1", "ibuprofen": "CC(C)Cc1ccc(cc1)C(C)C(=O)O",
|
|
190
|
+
"adrenaline": "CNCC(O)c1ccc(O)c(O)c1", "epinephrine": "CNCC(O)c1ccc(O)c(O)c1",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def lookup_common_molecule(request: str) -> tuple[str, str] | None:
|
|
195
|
+
lowered = request.lower()
|
|
196
|
+
for name, smiles in COMMON_MOLECULES.items():
|
|
197
|
+
if name in lowered:
|
|
198
|
+
return smiles, name.title()
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
CIRCUIT_LINE_SPLIT_RE = re.compile(r"(COMPONENT|PARALLEL):\s*", re.IGNORECASE)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
CIRCUIT_TYPE_MAP = {
|
|
206
|
+
"battery": elm.SourceV, "resistor": elm.Resistor, "capacitor": elm.Capacitor,
|
|
207
|
+
"inductor": elm.Inductor, "led": elm.LED, "switch": elm.Switch, "diode": elm.Diode,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# Each slot in the series chain is either ("single", type, label) or
|
|
212
|
+
# ("parallel", [(type, label), ...]) -- a parallel group counts as one slot.
|
|
213
|
+
CircuitSlot = tuple
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _parse_component_token(token: str) -> tuple[str, str] | None:
|
|
217
|
+
parts = token.strip().split(None, 1)
|
|
218
|
+
if not parts:
|
|
219
|
+
return None
|
|
220
|
+
comp_type = parts[0].strip().lower()
|
|
221
|
+
label = parts[1].strip() if len(parts) > 1 else comp_type
|
|
222
|
+
return (comp_type, label) if comp_type in CIRCUIT_TYPE_MAP else None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def parse_circuit_dsl(text: str) -> list[CircuitSlot]:
|
|
226
|
+
# Split on the COMPONENT:/PARALLEL: markers themselves rather than
|
|
227
|
+
# anchoring each match to a line -- the model sometimes puts several on
|
|
228
|
+
# one line, and a line-anchored regex would swallow the rest of the line
|
|
229
|
+
# into the first match's label instead of finding the next one.
|
|
230
|
+
slots: list[CircuitSlot] = []
|
|
231
|
+
pieces = CIRCUIT_LINE_SPLIT_RE.split(text)[1:] # alternating [kind, body, kind, body, ...]
|
|
232
|
+
for kind, body in zip(pieces[0::2], pieces[1::2]):
|
|
233
|
+
line = body.strip().splitlines()[0] if body.strip() else ""
|
|
234
|
+
if not line:
|
|
235
|
+
continue
|
|
236
|
+
if kind.upper() == "PARALLEL":
|
|
237
|
+
group = [c for tok in line.split("|") if (c := _parse_component_token(tok))]
|
|
238
|
+
if group:
|
|
239
|
+
slots.append(("parallel", group))
|
|
240
|
+
else:
|
|
241
|
+
single = _parse_component_token(line)
|
|
242
|
+
if single:
|
|
243
|
+
slots.append(("single", single[0], single[1]))
|
|
244
|
+
return slots
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _draw_parallel_branch(d: "schemdraw.Drawing", group: list[tuple[str, str]]):
|
|
248
|
+
"""Draw `group` as parallel branches between the current point and a
|
|
249
|
+
common rejoin point, advancing the drawing past the rejoin. Generalizes
|
|
250
|
+
to any number of branches (verified with 2 and 3)."""
|
|
251
|
+
ends = []
|
|
252
|
+
for i, (comp_type, label) in enumerate(group):
|
|
253
|
+
if i > 0:
|
|
254
|
+
d.add(elm.Line().right(d.unit / 2))
|
|
255
|
+
if i < len(group) - 1:
|
|
256
|
+
d.push()
|
|
257
|
+
d.add(CIRCUIT_TYPE_MAP[comp_type]().down().label(label))
|
|
258
|
+
ends.append(d.here)
|
|
259
|
+
if i < len(group) - 1:
|
|
260
|
+
d.pop()
|
|
261
|
+
for e in ends[1:]:
|
|
262
|
+
d.add(elm.Line().endpoints(ends[0], e))
|
|
263
|
+
return ends[0]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def render_circuit(slots: list[CircuitSlot], out_path: str) -> None:
|
|
267
|
+
"""Deterministic layout: slots placed left-to-right (parallel slots as
|
|
268
|
+
branching groups), then the loop is closed back to the first slot's
|
|
269
|
+
start point -- works regardless of how many slots there are."""
|
|
270
|
+
d = schemdraw.Drawing(file=out_path)
|
|
271
|
+
first = None
|
|
272
|
+
for slot in slots:
|
|
273
|
+
if slot[0] == "single":
|
|
274
|
+
_, comp_type, label = slot
|
|
275
|
+
el = d.add(CIRCUIT_TYPE_MAP[comp_type]().right().label(label))
|
|
276
|
+
start_point = el.start
|
|
277
|
+
else:
|
|
278
|
+
_, group = slot
|
|
279
|
+
start_point = d.here
|
|
280
|
+
_draw_parallel_branch(d, group)
|
|
281
|
+
if first is None:
|
|
282
|
+
first = start_point
|
|
283
|
+
d.add(elm.Line().down())
|
|
284
|
+
d.add(elm.Line().left().tox(first[0]))
|
|
285
|
+
d.add(elm.Line().up().toy(first[1]))
|
|
286
|
+
d.save(out_path)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
GRAPH_EDGE_RE = re.compile(
|
|
290
|
+
r"EDGE:\s*(.+?)\s*(->|--)\s*(.+?)\s*(?::\s*(\S+))?\s*(?=NODE:|EDGE:|$)",
|
|
291
|
+
re.IGNORECASE | re.DOTALL,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def parse_graph_dsl(text: str) -> tuple[list[str], list[tuple[str, str, str, bool]]]:
|
|
296
|
+
"""Returns (nodes, edges) where each edge is (a, b, weight_label, directed)."""
|
|
297
|
+
nodes: list[str] = []
|
|
298
|
+
seen = set()
|
|
299
|
+
for m in NODE_RE.finditer(text):
|
|
300
|
+
name = m.group(1).strip()
|
|
301
|
+
if name and name not in seen:
|
|
302
|
+
nodes.append(name)
|
|
303
|
+
seen.add(name)
|
|
304
|
+
|
|
305
|
+
edges: list[tuple[str, str, str, bool]] = []
|
|
306
|
+
for m in GRAPH_EDGE_RE.finditer(text):
|
|
307
|
+
a, arrow, b, weight = m.group(1).strip(), m.group(2), m.group(3).strip(), (m.group(4) or "")
|
|
308
|
+
for name in (a, b):
|
|
309
|
+
if name not in seen:
|
|
310
|
+
nodes.append(name)
|
|
311
|
+
seen.add(name)
|
|
312
|
+
edges.append((a, b, weight, arrow == "->"))
|
|
313
|
+
return nodes, edges
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def render_graph(
|
|
317
|
+
nodes: list[str], edges: list[tuple[str, str, str, bool]], out_path: str,
|
|
318
|
+
) -> None:
|
|
319
|
+
G = nx.DiGraph() if any(directed for _, _, _, directed in edges) else nx.Graph()
|
|
320
|
+
G.add_nodes_from(nodes)
|
|
321
|
+
for a, b, weight, _ in edges:
|
|
322
|
+
# spring_layout uses the 'weight' edge attribute as a numeric spring
|
|
323
|
+
# force, so an empty/non-numeric string here breaks layout entirely
|
|
324
|
+
# -- only set it when there's a real number, never the raw label.
|
|
325
|
+
try:
|
|
326
|
+
G.add_edge(a, b, weight=float(weight)) if weight else G.add_edge(a, b)
|
|
327
|
+
except ValueError:
|
|
328
|
+
G.add_edge(a, b)
|
|
329
|
+
|
|
330
|
+
# Trees read far more clearly with the root at top and levels below it
|
|
331
|
+
# than with a generic spring layout (which scatters them with crossing
|
|
332
|
+
# edges and no sense of hierarchy) -- detect and use a BFS layout from
|
|
333
|
+
# the first-declared node when the structure actually is a tree.
|
|
334
|
+
if nx.is_tree(G):
|
|
335
|
+
pos = nx.bfs_layout(G, start=nodes[0], align="horizontal")
|
|
336
|
+
pos = {n: (x, -y) for n, (x, y) in pos.items()}
|
|
337
|
+
else:
|
|
338
|
+
pos = nx.spring_layout(G, seed=42, k=1.2)
|
|
339
|
+
fig, ax = plt.subplots(figsize=(6, 6))
|
|
340
|
+
nx.draw(
|
|
341
|
+
G, pos, ax=ax, with_labels=True, node_color="#cfe8ff", node_size=1200,
|
|
342
|
+
font_size=11, edgecolors="black", arrows=True, arrowsize=18,
|
|
343
|
+
)
|
|
344
|
+
weight_labels = {(a, b): w for a, b, w, _ in edges if w}
|
|
345
|
+
if weight_labels:
|
|
346
|
+
nx.draw_networkx_edge_labels(G, pos, edge_labels=weight_labels, ax=ax, font_size=10)
|
|
347
|
+
autofix_text_overlaps(fig)
|
|
348
|
+
plt.savefig(out_path, dpi=150, bbox_inches="tight")
|
|
349
|
+
plt.close(fig)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
BOX_RE = re.compile(r"BOX:\s*(yes|no)", re.IGNORECASE)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
VECTOR_RE = re.compile(
|
|
356
|
+
r"VECTOR:\s*(.+?)\s*\|\s*(-?[\d.]+)\s*\|\s*(-?[\d.]+)\s*(?=VECTOR:|BOX:|$)",
|
|
357
|
+
re.IGNORECASE | re.DOTALL,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def parse_vector_dsl(text: str) -> tuple[bool, list[tuple[str, float, float]]]:
|
|
362
|
+
box_m = BOX_RE.search(text)
|
|
363
|
+
origin_box = bool(box_m and box_m.group(1).lower() == "yes")
|
|
364
|
+
vectors = []
|
|
365
|
+
for m in VECTOR_RE.finditer(text):
|
|
366
|
+
label = m.group(1).strip()
|
|
367
|
+
try:
|
|
368
|
+
x, y = float(m.group(2)), float(m.group(3))
|
|
369
|
+
except ValueError:
|
|
370
|
+
continue
|
|
371
|
+
if label:
|
|
372
|
+
vectors.append((label, x, y))
|
|
373
|
+
return origin_box, vectors
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
_VECTOR_COLORS = ("#d62728", "#1f77b4", "#2ca02c", "#9467bd", "#ff7f0e", "#8c564b")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def render_vectors(origin_box: bool, vectors: list[tuple[str, float, float]], out_path: str) -> None:
|
|
380
|
+
"""Deterministic auto-layout: every vector is a real arrow (shaft +
|
|
381
|
+
arrowhead) from the origin to (x, y), with its label placed just beyond
|
|
382
|
+
the tip, radially outward. Labels that would land on top of each other
|
|
383
|
+
(near-parallel vectors) are nudged apart tangentially -- the model never
|
|
384
|
+
controls label placement, so this always avoids overlap."""
|
|
385
|
+
fig, ax = plt.subplots(figsize=(6.0, 6.0))
|
|
386
|
+
if origin_box:
|
|
387
|
+
ax.add_patch(Rectangle((-0.5, -0.5), 1.0, 1.0, facecolor="lightgray",
|
|
388
|
+
edgecolor="black", zorder=3))
|
|
389
|
+
|
|
390
|
+
max_r = max(((x ** 2 + y ** 2) ** 0.5 for _, x, y in vectors), default=1.0) or 1.0
|
|
391
|
+
placed: list[tuple[float, float]] = []
|
|
392
|
+
for i, (label, x, y) in enumerate(vectors):
|
|
393
|
+
color = _VECTOR_COLORS[i % len(_VECTOR_COLORS)]
|
|
394
|
+
ax.add_patch(FancyArrowPatch(
|
|
395
|
+
(0, 0), (x, y), arrowstyle="-|>", mutation_scale=18,
|
|
396
|
+
color=color, linewidth=2, zorder=2,
|
|
397
|
+
))
|
|
398
|
+
r = (x ** 2 + y ** 2) ** 0.5
|
|
399
|
+
ux, uy = (x / r, y / r) if r > 1e-6 else (0.0, 1.0)
|
|
400
|
+
lx, ly = x + ux * 0.45, y + uy * 0.45
|
|
401
|
+
for _ in range(8): # nudge away from any already-placed label that's too close
|
|
402
|
+
collided = False
|
|
403
|
+
for px, py in placed:
|
|
404
|
+
if ((lx - px) ** 2 + (ly - py) ** 2) ** 0.5 < 0.55:
|
|
405
|
+
collided = True
|
|
406
|
+
lx += -uy * 0.3
|
|
407
|
+
ly += ux * 0.3
|
|
408
|
+
if not collided:
|
|
409
|
+
break
|
|
410
|
+
placed.append((lx, ly))
|
|
411
|
+
ax.text(lx, ly, label, color=color, ha="center", va="center", fontsize=10,
|
|
412
|
+
zorder=4, bbox=dict(boxstyle="round,pad=0.15", facecolor="white",
|
|
413
|
+
edgecolor="none", alpha=0.85))
|
|
414
|
+
|
|
415
|
+
pad = max_r * 0.35 + 0.8
|
|
416
|
+
xs = [x for _, x, _ in vectors] + [p[0] for p in placed] + [0.0]
|
|
417
|
+
ys = [y for _, _, y in vectors] + [p[1] for p in placed] + [0.0]
|
|
418
|
+
ax.set_xlim(min(xs) - pad, max(xs) + pad)
|
|
419
|
+
ax.set_ylim(min(ys) - pad, max(ys) + pad)
|
|
420
|
+
ax.set_aspect("equal")
|
|
421
|
+
ax.axhline(0, color="gray", linewidth=0.5, zorder=1)
|
|
422
|
+
ax.axvline(0, color="gray", linewidth=0.5, zorder=1)
|
|
423
|
+
ax.grid(True, alpha=0.3)
|
|
424
|
+
autofix_text_overlaps(fig)
|
|
425
|
+
plt.savefig(out_path, dpi=150, bbox_inches="tight")
|
|
426
|
+
plt.close(fig)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
VENN_FIELD_RE = re.compile(
|
|
430
|
+
r"\b(SET_\w+|ONLY_\w+|OVERLAP)\s*:\s*(.*?)\s*"
|
|
431
|
+
r"(?=\b(?:SET_\w+|ONLY_\w+|OVERLAP)\s*:|$)",
|
|
432
|
+
re.IGNORECASE | re.DOTALL,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def parse_venn_dsl(text: str) -> dict[str, str]:
|
|
437
|
+
"""Matches by field-name *prefix* (SET_/ONLY_/OVERLAP) and assigns by
|
|
438
|
+
position rather than requiring the exact literal "SET_A"/"SET_B" tokens
|
|
439
|
+
-- the model sometimes mirrors the request's own set names into the
|
|
440
|
+
field name itself (e.g. "SET_P:"/"SET_Q:" for a request about "Set P
|
|
441
|
+
and Set Q"), which would otherwise be perfectly good data lost to a
|
|
442
|
+
literal-string mismatch."""
|
|
443
|
+
fields = {"SET_A": "", "SET_B": "", "ONLY_A": "", "ONLY_B": "", "OVERLAP": ""}
|
|
444
|
+
set_slots = ["SET_A", "SET_B"]
|
|
445
|
+
only_slots = ["ONLY_A", "ONLY_B"]
|
|
446
|
+
for m in VENN_FIELD_RE.finditer(text):
|
|
447
|
+
key, value = m.group(1).upper(), m.group(2).strip()
|
|
448
|
+
if key.startswith("SET_") and set_slots:
|
|
449
|
+
fields[set_slots.pop(0)] = value
|
|
450
|
+
elif key.startswith("ONLY_") and only_slots:
|
|
451
|
+
fields[only_slots.pop(0)] = value
|
|
452
|
+
elif key == "OVERLAP":
|
|
453
|
+
fields["OVERLAP"] = value
|
|
454
|
+
return fields
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def render_venn(fields: dict[str, str], out_path: str) -> None:
|
|
458
|
+
"""Always exactly two circles with fixed, tested geometry -- the model
|
|
459
|
+
only supplies the text for each region, so it can never invent extra
|
|
460
|
+
shapes or end up with mismatched/duplicate borders."""
|
|
461
|
+
fig, ax = plt.subplots(figsize=(6.5, 5.5))
|
|
462
|
+
r, sep = 2.0, 1.4
|
|
463
|
+
ax.add_patch(Circle((-sep, 0), r, alpha=0.35, facecolor="skyblue", edgecolor="black", linewidth=1.5))
|
|
464
|
+
ax.add_patch(Circle((sep, 0), r, alpha=0.35, facecolor="lightgreen", edgecolor="black", linewidth=1.5))
|
|
465
|
+
|
|
466
|
+
ax.text(-sep, r + 0.3, fields["SET_A"], ha="center", va="bottom", fontsize=13, fontweight="bold")
|
|
467
|
+
ax.text(sep, r + 0.3, fields["SET_B"], ha="center", va="bottom", fontsize=13, fontweight="bold")
|
|
468
|
+
|
|
469
|
+
def place(text: str, x: float, y: float, width: int) -> None:
|
|
470
|
+
if text:
|
|
471
|
+
ax.text(x, y, textwrap.fill(text, width=width), ha="center", va="center", fontsize=10)
|
|
472
|
+
|
|
473
|
+
place(fields["ONLY_A"], -sep - 0.7, 0, 14)
|
|
474
|
+
place(fields["ONLY_B"], sep + 0.7, 0, 14)
|
|
475
|
+
place(fields["OVERLAP"], 0, 0, 10)
|
|
476
|
+
|
|
477
|
+
ax.set_xlim(-sep - r - 0.8, sep + r + 0.8)
|
|
478
|
+
ax.set_ylim(-r - 0.6, r + 1.0)
|
|
479
|
+
ax.set_aspect("equal")
|
|
480
|
+
ax.axis("off")
|
|
481
|
+
autofix_text_overlaps(fig)
|
|
482
|
+
plt.savefig(out_path, dpi=150, bbox_inches="tight")
|
|
483
|
+
plt.close(fig)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
TABLE_TITLE_RE = re.compile(r"TITLE:\s*(.*?)\s*(?=COLUMNS:|$)", re.IGNORECASE | re.DOTALL)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
TABLE_COLUMNS_RE = re.compile(r"COLUMNS:\s*(.+?)\s*(?=ROW:|$)", re.IGNORECASE | re.DOTALL)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
TABLE_ROW_RE = re.compile(r"ROW:\s*(.+?)\s*(?=ROW:|$)", re.IGNORECASE | re.DOTALL)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def parse_table_dsl(text: str) -> tuple[str, list[str], list[list[str]]]:
|
|
496
|
+
title_m = TABLE_TITLE_RE.search(text)
|
|
497
|
+
title = title_m.group(1).strip() if title_m else ""
|
|
498
|
+
columns_m = TABLE_COLUMNS_RE.search(text)
|
|
499
|
+
columns = [c.strip() for c in columns_m.group(1).split("|")] if columns_m else []
|
|
500
|
+
rows = []
|
|
501
|
+
for m in TABLE_ROW_RE.finditer(text):
|
|
502
|
+
cells = [c.strip() for c in m.group(1).split("|")]
|
|
503
|
+
# Defensive: pad/truncate a malformed row to the column count rather
|
|
504
|
+
# than failing the whole table over one ragged row.
|
|
505
|
+
if columns:
|
|
506
|
+
cells = (cells + [""] * len(columns))[:len(columns)]
|
|
507
|
+
rows.append(cells)
|
|
508
|
+
return title, columns, rows
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def render_table(title: str, columns: list[str], rows: list[list[str]], out_path: str) -> None:
|
|
512
|
+
"""Always a real ax.table() grid with auto-scaled figure size and font
|
|
513
|
+
size -- never a chart or floating text standing in for a table."""
|
|
514
|
+
n_cols, n_rows = len(columns), len(rows)
|
|
515
|
+
fig_w = max(4.0, min(12.0, 1.3 * n_cols))
|
|
516
|
+
fig_h = max(1.5, min(10.0, 0.45 * (n_rows + 1) + 0.6))
|
|
517
|
+
fontsize = max(7, min(12, 16 - max(n_cols, n_rows)))
|
|
518
|
+
|
|
519
|
+
fig, ax = plt.subplots(figsize=(fig_w, fig_h))
|
|
520
|
+
ax.axis("off")
|
|
521
|
+
if title:
|
|
522
|
+
ax.set_title(title, fontsize=fontsize + 3, pad=12)
|
|
523
|
+
|
|
524
|
+
table = ax.table(cellText=rows, colLabels=columns, loc="center", cellLoc="center")
|
|
525
|
+
table.auto_set_font_size(False)
|
|
526
|
+
table.set_fontsize(fontsize)
|
|
527
|
+
table.scale(1, 1.6)
|
|
528
|
+
table.auto_set_column_width(col=list(range(n_cols)))
|
|
529
|
+
|
|
530
|
+
for (r, c), cell in table.get_celld().items():
|
|
531
|
+
cell.set_edgecolor("black")
|
|
532
|
+
if r == 0:
|
|
533
|
+
cell.set_facecolor("#cfe8ff")
|
|
534
|
+
cell.set_text_props(fontweight="bold")
|
|
535
|
+
else:
|
|
536
|
+
cell.set_facecolor("white" if r % 2 else "#f5f5f5")
|
|
537
|
+
|
|
538
|
+
plt.savefig(out_path, dpi=150, bbox_inches="tight")
|
|
539
|
+
plt.close(fig)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
SCENE_TITLE_RE = re.compile(r"TITLE:\s*(.*?)\s*(?=SHAPE:|$)", re.IGNORECASE | re.DOTALL)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
SCENE_SHAPE_RE = re.compile(
|
|
546
|
+
r"SHAPE:\s*(\w+)\s*\|\s*(\w+)\s*\|\s*(\d+)\s*\|\s*(yes|no)\s*(?=SHAPE:|$)",
|
|
547
|
+
re.IGNORECASE | re.DOTALL,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def parse_scene_dsl(text: str) -> tuple[str, list[tuple[str, str, int, bool]]]:
|
|
552
|
+
title_m = SCENE_TITLE_RE.search(text)
|
|
553
|
+
title = title_m.group(1).strip() if title_m else ""
|
|
554
|
+
items = []
|
|
555
|
+
for m in SCENE_SHAPE_RE.finditer(text):
|
|
556
|
+
shape, color, count, overlap = m.group(1).lower(), m.group(2).lower(), int(m.group(3)), m.group(4).lower() == "yes"
|
|
557
|
+
if shape in _SCENE_SHAPE_BUILDERS and count > 0:
|
|
558
|
+
items.append((shape, color, count, overlap))
|
|
559
|
+
return title, items
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
_SCENE_SHAPE_BUILDERS = {
|
|
563
|
+
"circle": lambda center, size, color: Circle(center, size, facecolor=color, edgecolor="black", linewidth=1.2),
|
|
564
|
+
"square": lambda center, size, color: Rectangle(
|
|
565
|
+
(center[0] - size, center[1] - size), 2 * size, 2 * size,
|
|
566
|
+
facecolor=color, edgecolor="black", linewidth=1.2,
|
|
567
|
+
),
|
|
568
|
+
"triangle": lambda center, size, color: RegularPolygon(
|
|
569
|
+
center, 3, radius=size * 1.3, facecolor=color, edgecolor="black", linewidth=1.2,
|
|
570
|
+
),
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def render_scene(title: str, items: list[tuple[str, str, int, bool]], out_path: str, seed: int = 42) -> None:
|
|
575
|
+
"""Deterministic rejection-sampling placement: every requested shape is
|
|
576
|
+
actually drawn (never silently dropped), shapes never overlap unless
|
|
577
|
+
their group explicitly asked for it, and the title is set exactly once
|
|
578
|
+
via ax.set_title (never duplicated)."""
|
|
579
|
+
rng = np.random.default_rng(seed)
|
|
580
|
+
fig, ax = plt.subplots(figsize=(6, 6))
|
|
581
|
+
if title:
|
|
582
|
+
ax.set_title(title, fontsize=13, pad=10)
|
|
583
|
+
|
|
584
|
+
size = 0.6
|
|
585
|
+
lo, hi = 1.5, 8.5
|
|
586
|
+
placed: list[tuple[float, float, float]] = []
|
|
587
|
+
for shape, color, count, overlap in items:
|
|
588
|
+
group_anchor = None
|
|
589
|
+
for _ in range(count):
|
|
590
|
+
if overlap and group_anchor is not None:
|
|
591
|
+
ax_, ay = group_anchor
|
|
592
|
+
ang = rng.uniform(0, 2 * np.pi)
|
|
593
|
+
dist = size * rng.uniform(0.4, 0.8)
|
|
594
|
+
cx, cy = ax_ + dist * np.cos(ang), ay + dist * np.sin(ang)
|
|
595
|
+
else:
|
|
596
|
+
cx, cy = rng.uniform(lo, hi), rng.uniform(lo, hi)
|
|
597
|
+
for _ in range(300):
|
|
598
|
+
if all((cx - px) ** 2 + (cy - py) ** 2 >= (size + ps) ** 2 * 0.9 for px, py, ps in placed):
|
|
599
|
+
break
|
|
600
|
+
cx, cy = rng.uniform(lo, hi), rng.uniform(lo, hi)
|
|
601
|
+
if overlap and group_anchor is None:
|
|
602
|
+
group_anchor = (cx, cy)
|
|
603
|
+
placed.append((cx, cy, size))
|
|
604
|
+
ax.add_patch(_SCENE_SHAPE_BUILDERS[shape]((cx, cy), size, color))
|
|
605
|
+
|
|
606
|
+
ax.set_xlim(0, 10)
|
|
607
|
+
ax.set_ylim(0, 10)
|
|
608
|
+
ax.set_aspect("equal")
|
|
609
|
+
ax.axis("off")
|
|
610
|
+
plt.savefig(out_path, dpi=150, bbox_inches="tight")
|
|
611
|
+
plt.close(fig)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
PUZZLE_LAYOUT_RE = re.compile(r"LAYOUT:\s*(\w+)", re.IGNORECASE)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
PUZZLE_PANEL_RE = re.compile(
|
|
618
|
+
r"PANEL:\s*(\w+)\s*\|\s*(\d+)\s*\|\s*(-?\d+)\s*(?:\|\s*(\w+)\s*)?(?=PANEL:|ANSWER:|ODD_INDEX:|TRANSFORM_DEGREES:|$)",
|
|
619
|
+
re.IGNORECASE | re.DOTALL,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
PUZZLE_ANSWER_RE = re.compile(
|
|
624
|
+
r"ANSWER:\s*(\w+)\s*\|\s*(\d+)\s*\|\s*(-?\d+)\s*(?:\|\s*(\w+))?", re.IGNORECASE,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
PUZZLE_ODD_INDEX_RE = re.compile(r"ODD_INDEX:\s*(\d+)", re.IGNORECASE)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
PUZZLE_TRANSFORM_RE = re.compile(r"TRANSFORM_DEGREES:\s*(-?\d+)", re.IGNORECASE)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
_PUZZLE_SHAPES = {"circle", "square", "triangle", "right_triangle"}
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
_PUZZLE_LAYOUTS = {"sequence", "symmetry", "rotation", "odd_one_out", "analogy", "row"}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def parse_puzzle_dsl(text: str) -> dict:
|
|
641
|
+
layout_m = PUZZLE_LAYOUT_RE.search(text)
|
|
642
|
+
layout = layout_m.group(1).lower() if layout_m else ""
|
|
643
|
+
if layout not in _PUZZLE_LAYOUTS:
|
|
644
|
+
layout = "" # an unrecognized layout must trigger a repair retry, not a silent fallback render
|
|
645
|
+
panels = []
|
|
646
|
+
for m in PUZZLE_PANEL_RE.finditer(text):
|
|
647
|
+
shape = m.group(1).lower()
|
|
648
|
+
if shape in _PUZZLE_SHAPES:
|
|
649
|
+
panels.append({
|
|
650
|
+
"shape": shape, "count": int(m.group(2)), "rotation": int(m.group(3)),
|
|
651
|
+
"color": (m.group(4) or "black").lower(),
|
|
652
|
+
})
|
|
653
|
+
answer_m = PUZZLE_ANSWER_RE.search(text)
|
|
654
|
+
answer = None
|
|
655
|
+
if answer_m and answer_m.group(1).lower() in _PUZZLE_SHAPES:
|
|
656
|
+
answer = {
|
|
657
|
+
"shape": answer_m.group(1).lower(), "count": int(answer_m.group(2)), "rotation": int(answer_m.group(3)),
|
|
658
|
+
"color": (answer_m.group(4) or "black").lower(),
|
|
659
|
+
}
|
|
660
|
+
odd_index_m = PUZZLE_ODD_INDEX_RE.search(text)
|
|
661
|
+
odd_index = int(odd_index_m.group(1)) - 1 if odd_index_m else None
|
|
662
|
+
transform_m = PUZZLE_TRANSFORM_RE.search(text)
|
|
663
|
+
transform_degrees = int(transform_m.group(1)) if transform_m else 90
|
|
664
|
+
return {
|
|
665
|
+
"layout": layout, "panels": panels, "answer": answer,
|
|
666
|
+
"odd_index": odd_index, "transform_degrees": transform_degrees,
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _right_triangle_patch(ax, center, half_leg, flip_x=False, rotation=0, color="black"):
|
|
671
|
+
cx, cy = center
|
|
672
|
+
sx = -1 if flip_x else 1
|
|
673
|
+
pts = [(cx - sx * half_leg, cy - half_leg), (cx - sx * half_leg, cy + half_leg), (cx + sx * half_leg, cy - half_leg)]
|
|
674
|
+
patch = Polygon(pts, closed=True, fill=False, edgecolor=color, linewidth=2)
|
|
675
|
+
ax.add_patch(patch)
|
|
676
|
+
if rotation:
|
|
677
|
+
patch.set_transform(Affine2D().rotate_deg_around(cx, cy, rotation) + ax.transData)
|
|
678
|
+
return patch
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
_PUZZLE_SHAPE_BUILDERS = {
|
|
682
|
+
"circle": lambda center, size, color: Circle(center, size, facecolor=color, edgecolor="black"),
|
|
683
|
+
"square": lambda center, size, color: Rectangle((center[0] - size, center[1] - size), 2 * size, 2 * size, facecolor=color, edgecolor="black"),
|
|
684
|
+
"triangle": lambda center, size, color: RegularPolygon(center, 3, radius=size * 1.3, facecolor=color, edgecolor="black"),
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _draw_puzzle_panel_content(ax, cx, cy, box_size, shape, count, rotation=0, flip_x=False, color="black"):
|
|
689
|
+
"""Draws `count` copies of `shape` inside a box of size `box_size`
|
|
690
|
+
centered at (cx, cy). The multi-item grid layout is sized so the full
|
|
691
|
+
grid (including each item's own radius) always stays within the box,
|
|
692
|
+
regardless of count -- it never overflows into a neighboring panel."""
|
|
693
|
+
if count <= 0:
|
|
694
|
+
return
|
|
695
|
+
if shape == "right_triangle":
|
|
696
|
+
half_leg = box_size * 0.30 # safety margin: sqrt(2)*0.30 < 0.45 (90% of half-box) at any rotation
|
|
697
|
+
_right_triangle_patch(ax, (cx, cy), half_leg, flip_x=flip_x, rotation=rotation, color=color)
|
|
698
|
+
return
|
|
699
|
+
cols = min(count, 3) if count != 4 else 2 # 4 items read as a clean 2x2 grid, not 3+1
|
|
700
|
+
rows = (count + cols - 1) // cols
|
|
701
|
+
usable = box_size * 0.65
|
|
702
|
+
spacing = usable / cols if cols > 1 else 0
|
|
703
|
+
item_size = min(box_size * 0.22, spacing * 0.38) if count > 1 else box_size * 0.26
|
|
704
|
+
placed = 0
|
|
705
|
+
for r in range(rows):
|
|
706
|
+
n_this_row = min(cols, count - placed)
|
|
707
|
+
row_w = (n_this_row - 1) * spacing
|
|
708
|
+
for c in range(n_this_row):
|
|
709
|
+
x = cx - row_w / 2 + c * spacing
|
|
710
|
+
y = cy + (rows - 1 - r) * spacing - (rows - 1) * spacing / 2
|
|
711
|
+
patch = _PUZZLE_SHAPE_BUILDERS[shape]((x, y), item_size, color)
|
|
712
|
+
ax.add_patch(patch)
|
|
713
|
+
if rotation:
|
|
714
|
+
patch.set_transform(Affine2D().rotate_deg_around(x, y, rotation) + ax.transData)
|
|
715
|
+
placed += 1
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def render_puzzle(spec: dict, out_path: str) -> None:
|
|
719
|
+
layout = spec["layout"]
|
|
720
|
+
box, gap = 1.6, 0.5
|
|
721
|
+
|
|
722
|
+
if layout in ("symmetry", "rotation"):
|
|
723
|
+
panel = spec["panels"][0]
|
|
724
|
+
big_box = 2.4
|
|
725
|
+
big_gap = 1.0
|
|
726
|
+
left_cx, right_cx = -big_box / 2 - big_gap / 2, big_box / 2 + big_gap / 2
|
|
727
|
+
fig, ax = plt.subplots(figsize=(7, 4))
|
|
728
|
+
for cx in (left_cx, right_cx):
|
|
729
|
+
ax.add_patch(FancyBboxPatch((cx - big_box / 2, -big_box / 2), big_box, big_box,
|
|
730
|
+
boxstyle="round,pad=0.02", facecolor="white", edgecolor="black", linewidth=1.5))
|
|
731
|
+
panel_color = panel.get("color", "black")
|
|
732
|
+
_draw_puzzle_panel_content(ax, left_cx, 0, big_box, panel["shape"], panel["count"], panel.get("rotation", 0), color=panel_color)
|
|
733
|
+
if layout == "symmetry":
|
|
734
|
+
ax.axvline(0, color="gray", linestyle="--", linewidth=1.5)
|
|
735
|
+
_draw_puzzle_panel_content(ax, right_cx, 0, big_box, panel["shape"], panel["count"], panel.get("rotation", 0), flip_x=True, color=panel_color)
|
|
736
|
+
else:
|
|
737
|
+
_draw_puzzle_panel_content(ax, right_cx, 0, big_box, panel["shape"], panel["count"], spec["transform_degrees"], color=panel_color)
|
|
738
|
+
ax.set_xlim(left_cx - big_box / 2 - 0.5, right_cx + big_box / 2 + 0.5)
|
|
739
|
+
ax.set_ylim(-big_box / 2 - 0.5, big_box / 2 + 0.5)
|
|
740
|
+
ax.set_aspect("equal")
|
|
741
|
+
ax.axis("off")
|
|
742
|
+
plt.savefig(out_path, dpi=150, bbox_inches="tight")
|
|
743
|
+
plt.close(fig)
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
# sequence / odd_one_out / analogy: a row of fixed-size panels, the
|
|
747
|
+
# last one either the revealed answer or a "?" mark, with optional
|
|
748
|
+
# separators (analogy) or a red highlight border (odd_one_out).
|
|
749
|
+
panels = list(spec["panels"])
|
|
750
|
+
if layout in ("sequence", "analogy"):
|
|
751
|
+
panels.append(spec["answer"] if spec["answer"] is not None else {"marker": "question"})
|
|
752
|
+
if layout == "odd_one_out" and spec["odd_index"] is not None and 0 <= spec["odd_index"] < len(panels):
|
|
753
|
+
panels[spec["odd_index"]] = {**panels[spec["odd_index"]], "marker": "odd"}
|
|
754
|
+
separators = [":", "::", ":"] if layout == "analogy" and len(panels) >= 4 else None
|
|
755
|
+
|
|
756
|
+
n = len(panels)
|
|
757
|
+
total_w = n * box + (n - 1) * gap
|
|
758
|
+
fig, ax = plt.subplots(figsize=(min(14, 1.0 + total_w), 3.2))
|
|
759
|
+
x0 = -total_w / 2 + box / 2
|
|
760
|
+
centers = []
|
|
761
|
+
for i, p in enumerate(panels):
|
|
762
|
+
cx = x0 + i * (box + gap)
|
|
763
|
+
centers.append(cx)
|
|
764
|
+
edgecolor = "red" if p.get("marker") == "odd" else "black"
|
|
765
|
+
lw = 2.5 if p.get("marker") == "odd" else 1.5
|
|
766
|
+
ax.add_patch(FancyBboxPatch((cx - box / 2, -box / 2), box, box, boxstyle="round,pad=0.02",
|
|
767
|
+
facecolor="white", edgecolor=edgecolor, linewidth=lw))
|
|
768
|
+
if p.get("marker") == "question":
|
|
769
|
+
ax.text(cx, 0, "?", ha="center", va="center", fontsize=40, fontweight="bold")
|
|
770
|
+
else:
|
|
771
|
+
_draw_puzzle_panel_content(ax, cx, 0, box, p["shape"], p["count"], p.get("rotation", 0), color=p.get("color", "black"))
|
|
772
|
+
|
|
773
|
+
if separators:
|
|
774
|
+
for i, sep in enumerate(separators[:n - 1]):
|
|
775
|
+
mid_x = (centers[i] + centers[i + 1]) / 2
|
|
776
|
+
ax.text(mid_x, 0, sep, ha="center", va="center", fontsize=18)
|
|
777
|
+
|
|
778
|
+
ax.set_xlim(-total_w / 2 - 0.6, total_w / 2 + 0.6)
|
|
779
|
+
ax.set_ylim(-box / 2 - 0.6, box / 2 + 0.9)
|
|
780
|
+
ax.set_aspect("equal")
|
|
781
|
+
ax.axis("off")
|
|
782
|
+
autofix_text_overlaps(fig)
|
|
783
|
+
plt.savefig(out_path, dpi=150, bbox_inches="tight")
|
|
784
|
+
plt.close(fig)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
GEOMETRY_LINE_RE = re.compile(
|
|
788
|
+
r"^[ \t]*[•\-]*\s*(POINT|ANGLE_POINT|REGULAR_POLYGON|RIGHT_TRIANGLE|TRAPEZOID|MIDPOINT|CIRCLE|"
|
|
789
|
+
r"POLYGON|SEGMENT|DIAGONAL|MARK_POINT|RIGHT_ANGLE|EQUAL_GROUP|ANGLE_LABEL|SIDE_LABEL|RADIUS_LABEL|"
|
|
790
|
+
r"SLOPE_LABEL|AREA_LABEL|CALLOUT|COORD_LABEL|GRID):\s*",
|
|
791
|
+
re.IGNORECASE | re.MULTILINE,
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
_MARKDOWN_DECORATION_RE = re.compile(r"[`*]")
|
|
796
|
+
|
|
797
|
+
# POLYGON/AREA_LABEL are inherently "one entry, a list of names" -- when the
|
|
798
|
+
# model spreads that list across several markdown-bulleted lines under one
|
|
799
|
+
# header, all those lines join back into a single comma list. Every other
|
|
800
|
+
# tag is "one entry per line": the model sometimes lists several POINTs (or
|
|
801
|
+
# SIDE_LABELs, etc.) under a single shared header instead of repeating the
|
|
802
|
+
# tag, so each non-empty line becomes its own separate command of that tag.
|
|
803
|
+
_LIST_TAGS = {"POLYGON", "AREA_LABEL"}
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _looks_like_data_line(line: str) -> bool:
|
|
807
|
+
"""A real continuation record (another POINT/SIDE_LABEL/... under a
|
|
808
|
+
shared header) is short and never ends in sentence punctuation. The
|
|
809
|
+
model also likes to narrate its reasoning between tag lines (e.g. "...so
|
|
810
|
+
we can use that point name:"), and without this check that commentary
|
|
811
|
+
gets swept up as if it were another instance of the *previous* tag."""
|
|
812
|
+
return bool(line) and line[-1] not in ".:?!;" and len(line.split()) <= 12
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def parse_geometry_dsl(text: str) -> list[tuple[str, str]]:
|
|
816
|
+
"""Parse into an ordered list of (TAG, body) commands -- order matters: a
|
|
817
|
+
SEGMENT/ANGLE_LABEL/etc. line can reference a point defined by an earlier
|
|
818
|
+
POINT/ANGLE_POINT/REGULAR_POLYGON/RIGHT_TRIANGLE line, so commands are
|
|
819
|
+
replayed in the order the model wrote them rather than grouped by type."""
|
|
820
|
+
text = _MARKDOWN_DECORATION_RE.sub("", text)
|
|
821
|
+
pieces = GEOMETRY_LINE_RE.split(text)[1:]
|
|
822
|
+
commands = []
|
|
823
|
+
for tag, body in zip(pieces[0::2], pieces[1::2]):
|
|
824
|
+
tag = tag.upper()
|
|
825
|
+
all_lines = [ln.strip() for ln in body.splitlines() if ln.strip()]
|
|
826
|
+
if not all_lines:
|
|
827
|
+
continue
|
|
828
|
+
# The first line is always this tag's primary value, no matter its
|
|
829
|
+
# shape; only *additional* lines need to look like real data before
|
|
830
|
+
# being accepted as further records (or, for list tags, folded in).
|
|
831
|
+
lines = all_lines[:1] + [ln for ln in all_lines[1:] if _looks_like_data_line(ln)]
|
|
832
|
+
if tag in _LIST_TAGS:
|
|
833
|
+
# A real polygon/area-label name list is short (a handful of
|
|
834
|
+
# vertices); a short prose phrase repeated dozens of times (a
|
|
835
|
+
# repetition-collapse artifact) can slip past the line filter
|
|
836
|
+
# above since it never ends in sentence punctuation, so cap how
|
|
837
|
+
# many continuation lines get folded in rather than trusting an
|
|
838
|
+
# unbounded join.
|
|
839
|
+
commands.append((tag, ",".join(lines[:12])))
|
|
840
|
+
else:
|
|
841
|
+
commands.extend((tag, line) for line in lines)
|
|
842
|
+
return commands
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _is_float(s: str) -> bool:
|
|
846
|
+
try:
|
|
847
|
+
float(s)
|
|
848
|
+
return True
|
|
849
|
+
except ValueError:
|
|
850
|
+
return False
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _fmt_num(v: float) -> str:
|
|
854
|
+
"""Round to an int when it's close to one (the common case for
|
|
855
|
+
constructed figures -- e.g. a pentagon's interior angle computed from
|
|
856
|
+
its actual vertices comes back as 107.999999...), else one decimal --
|
|
857
|
+
never the raw 53.130102354155598-style float a model previously left
|
|
858
|
+
unrounded."""
|
|
859
|
+
if abs(v - round(v)) < 0.05:
|
|
860
|
+
return str(int(round(v)))
|
|
861
|
+
return f"{v:.1f}"
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _polygon_area(pts: list[np.ndarray]) -> float:
|
|
865
|
+
x = np.array([p[0] for p in pts])
|
|
866
|
+
y = np.array([p[1] for p in pts])
|
|
867
|
+
return 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1)))
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _angle_between(vertex: np.ndarray, p1: np.ndarray, p2: np.ndarray) -> tuple[float, float, float]:
|
|
871
|
+
"""Returns (start_degrees, sweep_degrees, mid_degrees) for the interior
|
|
872
|
+
(<=180 deg) angle at `vertex` between rays to p1 and p2, oriented so a
|
|
873
|
+
matplotlib Arc(theta1=start, theta2=start+sweep) always sweeps the
|
|
874
|
+
correct (minor) arc regardless of which ray was listed first."""
|
|
875
|
+
a1 = np.degrees(np.arctan2(*(p1 - vertex)[::-1])) % 360
|
|
876
|
+
a2 = np.degrees(np.arctan2(*(p2 - vertex)[::-1])) % 360
|
|
877
|
+
sweep = (a2 - a1) % 360
|
|
878
|
+
if sweep > 180:
|
|
879
|
+
a1, sweep = a2, 360 - sweep
|
|
880
|
+
return a1, sweep, a1 + sweep / 2
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def render_geometry(commands: list[tuple[str, str]], out_path: str) -> None:
|
|
884
|
+
"""Deterministic geometry renderer: the model only supplies points and
|
|
885
|
+
references between them (see GEOMETRY_DSL_PROMPT); every angle, side
|
|
886
|
+
length, area, and slope is computed here from the actual coordinates, so
|
|
887
|
+
a model arithmetic mistake can produce a wrong picture (bad point
|
|
888
|
+
choice) but never a wrong *label* on an otherwise-correct picture, and
|
|
889
|
+
axis limits are always derived from the real geometry, never a
|
|
890
|
+
model-guessed window that could crop it."""
|
|
891
|
+
points: dict[str, np.ndarray] = {}
|
|
892
|
+
circles: dict[str, tuple[float, float, float]] = {}
|
|
893
|
+
aux_circles: set[str] = set() # REGULAR_POLYGON's circumscribing circle: drawn lighter, not user-requested
|
|
894
|
+
polygons: list[list[str]] = []
|
|
895
|
+
segments: list[tuple[str, str]] = []
|
|
896
|
+
marks: list[str] = []
|
|
897
|
+
right_angles: list[tuple[str, str, str]] = []
|
|
898
|
+
equal_groups: list[tuple[int, list[tuple[str, str]]]] = []
|
|
899
|
+
angle_labels: list[tuple[str, str, str, str]] = []
|
|
900
|
+
side_labels: list[tuple[str, str, str]] = []
|
|
901
|
+
radius_labels: list[tuple[str, str]] = []
|
|
902
|
+
slope_labels: list[tuple[str, str, str]] = []
|
|
903
|
+
area_labels: list[list[str]] = []
|
|
904
|
+
callouts: list[tuple[str, str]] = []
|
|
905
|
+
coord_labels: list[str] = []
|
|
906
|
+
show_grid = False
|
|
907
|
+
|
|
908
|
+
def need(name: str) -> np.ndarray:
|
|
909
|
+
if name not in points:
|
|
910
|
+
raise ValueError(f"reference to undefined point '{name}'")
|
|
911
|
+
return points[name]
|
|
912
|
+
|
|
913
|
+
def origin_of(name: str) -> np.ndarray:
|
|
914
|
+
# ANGLE_POINT's origin is usually a named POINT, but the model often
|
|
915
|
+
# skips re-declaring a circle's center as its own POINT line when it
|
|
916
|
+
# only needs it to place points on that circle -- accept the
|
|
917
|
+
# circle's own name as an origin too, using its center. And when it
|
|
918
|
+
# references some other invented name (e.g. a generic "O" that was
|
|
919
|
+
# never defined at all) while exactly one circle exists, that's
|
|
920
|
+
# almost always meant to be that circle's center too.
|
|
921
|
+
if name in points:
|
|
922
|
+
return points[name]
|
|
923
|
+
if name in circles:
|
|
924
|
+
cx, cy, _ = circles[name]
|
|
925
|
+
return np.array([cx, cy])
|
|
926
|
+
if len(circles) == 1:
|
|
927
|
+
cx, cy, _ = next(iter(circles.values()))
|
|
928
|
+
return np.array([cx, cy])
|
|
929
|
+
raise ValueError(f"reference to undefined point '{name}'")
|
|
930
|
+
|
|
931
|
+
# Point-defining commands always run before everything else, regardless
|
|
932
|
+
# of where the model put them in its response -- it sometimes writes
|
|
933
|
+
# POLYGON: A,B,C,D before the POINT: lines that define A-D, and there's
|
|
934
|
+
# no reason to fail a response just because of line order rather than
|
|
935
|
+
# content. A stable sort preserves relative order *within* each group, so
|
|
936
|
+
# a chained ANGLE_POINT that depends on an earlier ANGLE_POINT still
|
|
937
|
+
# resolves correctly.
|
|
938
|
+
# POLYGON is included here (not just the obvious point-producers) so that
|
|
939
|
+
# when it falls back to synthesizing default vertices (the model skipped
|
|
940
|
+
# POINT: lines entirely), those points already exist if a later-in-text
|
|
941
|
+
# but same-pass CIRCLE needs one for its own AUTO/POINT: radius.
|
|
942
|
+
_POINT_DEFINING = {"POINT", "ANGLE_POINT", "REGULAR_POLYGON", "RIGHT_TRIANGLE", "TRAPEZOID", "MIDPOINT", "CIRCLE", "POLYGON"}
|
|
943
|
+
commands = sorted(commands, key=lambda c: c[0] not in _POINT_DEFINING)
|
|
944
|
+
|
|
945
|
+
for tag, body in commands:
|
|
946
|
+
try:
|
|
947
|
+
# "|" is the documented separator, but a markdown-formatted
|
|
948
|
+
# response sometimes uses "," for every tag uniformly (the same
|
|
949
|
+
# habit that spreads POLYGON across bulleted lines) -- fall back
|
|
950
|
+
# to "," only when there's no "|" at all, never both at once.
|
|
951
|
+
p = [x.strip() for x in body.split("|" if "|" in body else ",")]
|
|
952
|
+
if tag == "POINT" and len(p) >= 3:
|
|
953
|
+
points[p[0]] = np.array([float(p[1]), float(p[2])])
|
|
954
|
+
elif tag == "ANGLE_POINT" and len(p) >= 4:
|
|
955
|
+
origin = origin_of(p[1])
|
|
956
|
+
ang, length = float(p[2]), float(p[3])
|
|
957
|
+
points[p[0]] = origin + length * np.array([np.cos(np.radians(ang)), np.sin(np.radians(ang))])
|
|
958
|
+
elif tag == "REGULAR_POLYGON" and len(p) >= 5:
|
|
959
|
+
n, cx, cy, r, prefix = int(p[0]), float(p[1]), float(p[2]), float(p[3]), p[4]
|
|
960
|
+
names = []
|
|
961
|
+
for i in range(n):
|
|
962
|
+
ang = 90 + i * 360.0 / n # first vertex points straight up -- a natural default orientation
|
|
963
|
+
points[f"{prefix}{i + 1}"] = np.array([cx + r * np.cos(np.radians(ang)), cy + r * np.sin(np.radians(ang))])
|
|
964
|
+
names.append(f"{prefix}{i + 1}")
|
|
965
|
+
polygons.append(names)
|
|
966
|
+
circles[prefix] = (cx, cy, r)
|
|
967
|
+
aux_circles.add(prefix)
|
|
968
|
+
elif tag == "RIGHT_TRIANGLE" and len(p) >= 7:
|
|
969
|
+
right_pt, leg1_pt, leg2_pt = p[0], p[1], p[2]
|
|
970
|
+
ox, oy, leg1 = float(p[3]), float(p[4]), float(p[5])
|
|
971
|
+
leg2_spec = p[6]
|
|
972
|
+
if leg2_spec.upper().startswith("ANGLE:"):
|
|
973
|
+
leg2 = leg1 * np.tan(np.radians(float(leg2_spec.split(":", 1)[1])))
|
|
974
|
+
else:
|
|
975
|
+
leg2 = float(leg2_spec)
|
|
976
|
+
points[right_pt] = np.array([ox, oy])
|
|
977
|
+
points[leg1_pt] = np.array([ox + leg1, oy])
|
|
978
|
+
points[leg2_pt] = np.array([ox, oy + leg2])
|
|
979
|
+
polygons.append([right_pt, leg1_pt, leg2_pt])
|
|
980
|
+
right_angles.append((right_pt, leg1_pt, leg2_pt))
|
|
981
|
+
elif tag == "TRAPEZOID" and len(p) >= 9:
|
|
982
|
+
bl, br, tr, tl = p[0], p[1], p[2], p[3]
|
|
983
|
+
ox, oy, bottom_len, top_len, height = (float(x) for x in p[4:9])
|
|
984
|
+
points[bl] = np.array([ox, oy])
|
|
985
|
+
points[br] = np.array([ox + bottom_len, oy])
|
|
986
|
+
points[tr] = np.array([ox + bottom_len / 2 + top_len / 2, oy + height])
|
|
987
|
+
points[tl] = np.array([ox + bottom_len / 2 - top_len / 2, oy + height])
|
|
988
|
+
polygons.append([bl, br, tr, tl])
|
|
989
|
+
elif tag == "CIRCLE" and len(p) >= 4:
|
|
990
|
+
cx, cy = float(p[1]), float(p[2])
|
|
991
|
+
r_spec = p[3].strip()
|
|
992
|
+
center = np.array([cx, cy])
|
|
993
|
+
if r_spec.upper().startswith("POINT:"):
|
|
994
|
+
# The radius is "the distance to this other point" --
|
|
995
|
+
# e.g. a circle through a square's corner -- computed
|
|
996
|
+
# here so the model never has to take a square root by
|
|
997
|
+
# hand (it reliably gets those wrong).
|
|
998
|
+
r = float(np.linalg.norm(need(r_spec.split(":", 1)[1].strip()) - center))
|
|
999
|
+
elif r_spec.upper() == "AUTO":
|
|
1000
|
+
# The model overgeneralizes the "use AUTO for anything
|
|
1001
|
+
# computed" rule to CIRCLE's radius too, even though the
|
|
1002
|
+
# documented way to say that is POINT:<name> -- default
|
|
1003
|
+
# to the distance to whichever other point is closest,
|
|
1004
|
+
# which is correct whenever (as is typical) this circle
|
|
1005
|
+
# is meant to pass through some already-defined point.
|
|
1006
|
+
others = [pt for pt in points.values() if np.linalg.norm(pt - center) > 1e-6]
|
|
1007
|
+
if not others:
|
|
1008
|
+
raise ValueError(f"CIRCLE '{p[0]}' has radius AUTO but no other point exists to measure to")
|
|
1009
|
+
r = float(min(np.linalg.norm(pt - center) for pt in others))
|
|
1010
|
+
else:
|
|
1011
|
+
r = float(r_spec)
|
|
1012
|
+
circles[p[0]] = (cx, cy, r)
|
|
1013
|
+
elif tag == "MIDPOINT" and len(p) >= 3:
|
|
1014
|
+
points[p[0]] = (need(p[1]) + need(p[2])) / 2
|
|
1015
|
+
elif tag == "POLYGON":
|
|
1016
|
+
# The model sometimes folds coordinates straight into the
|
|
1017
|
+
# POLYGON line itself instead of separate POINT: lines --
|
|
1018
|
+
# accept either shorthand it tends to invent: "A(0,0),B(4,0)"
|
|
1019
|
+
# or a flat "A,0,0,B,4,0" run of name,x,y triples.
|
|
1020
|
+
inline = re.findall(r"(\w+)\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)", body)
|
|
1021
|
+
raw_tokens = [t.strip() for t in body.split(",") if t.strip()]
|
|
1022
|
+
if inline:
|
|
1023
|
+
names = []
|
|
1024
|
+
for name, x, y in inline:
|
|
1025
|
+
points[name] = np.array([float(x), float(y)])
|
|
1026
|
+
names.append(name)
|
|
1027
|
+
elif (len(raw_tokens) >= 6 and len(raw_tokens) % 3 == 0
|
|
1028
|
+
and all(_is_float(raw_tokens[i]) for i in range(len(raw_tokens)) if i % 3 != 0)
|
|
1029
|
+
and not any(_is_float(raw_tokens[i]) for i in range(0, len(raw_tokens), 3))):
|
|
1030
|
+
names = []
|
|
1031
|
+
for i in range(0, len(raw_tokens), 3):
|
|
1032
|
+
name, x, y = raw_tokens[i], raw_tokens[i + 1], raw_tokens[i + 2]
|
|
1033
|
+
points[name] = np.array([float(x), float(y)])
|
|
1034
|
+
names.append(name)
|
|
1035
|
+
else:
|
|
1036
|
+
names = raw_tokens
|
|
1037
|
+
# A real point name is one token, never containing a space --
|
|
1038
|
+
# a stray prose fragment that slipped past the line-level
|
|
1039
|
+
# filter (e.g. "Right angle at diagonal intersection",
|
|
1040
|
+
# repeated) is unmistakable by this point and gets dropped
|
|
1041
|
+
# rather than treated as a pile of vertex names.
|
|
1042
|
+
names = [n for n in names if " " not in n]
|
|
1043
|
+
if len(names) >= 3:
|
|
1044
|
+
if all(n not in points for n in names):
|
|
1045
|
+
# The model skipped point definitions entirely (a
|
|
1046
|
+
# real, recurring failure) -- synthesize a symmetric
|
|
1047
|
+
# default polygon under the names it chose rather
|
|
1048
|
+
# than failing outright. EQUAL_GROUP/ANGLE_LABEL
|
|
1049
|
+
# results on a regular polygon are always
|
|
1050
|
+
# geometrically consistent, which matches the common
|
|
1051
|
+
# case of an under-specified isosceles/equilateral/
|
|
1052
|
+
# rhombus/kite request.
|
|
1053
|
+
n = len(names)
|
|
1054
|
+
for i, name in enumerate(names):
|
|
1055
|
+
ang = 90 + i * 360.0 / n
|
|
1056
|
+
points[name] = np.array([3 * np.cos(np.radians(ang)), 3 * np.sin(np.radians(ang))])
|
|
1057
|
+
polygons.append(names)
|
|
1058
|
+
elif tag in ("SEGMENT", "DIAGONAL") and len(p) >= 2:
|
|
1059
|
+
# A chord across a circle: the model sometimes jumps straight
|
|
1060
|
+
# to "SEGMENT: P | Q" without ever placing P/Q with
|
|
1061
|
+
# ANGLE_POINT first. When both ends are otherwise-undefined
|
|
1062
|
+
# and exactly one circle exists, treat it as a chord and
|
|
1063
|
+
# default its two endpoints onto that circle.
|
|
1064
|
+
if p[0] not in points and p[1] not in points and len(circles) == 1:
|
|
1065
|
+
cx, cy, r = next(iter(circles.values()))
|
|
1066
|
+
for name, ang in ((p[0], 35), (p[1], 145)):
|
|
1067
|
+
points[name] = np.array([cx + r * np.cos(np.radians(ang)), cy + r * np.sin(np.radians(ang))])
|
|
1068
|
+
segments.append((p[0], p[1]))
|
|
1069
|
+
elif tag == "MARK_POINT":
|
|
1070
|
+
marks.append(body.strip())
|
|
1071
|
+
elif tag == "RIGHT_ANGLE" and len(p) >= 3:
|
|
1072
|
+
right_angles.append((p[0], p[1], p[2]))
|
|
1073
|
+
elif tag == "EQUAL_GROUP" and len(p) >= 2:
|
|
1074
|
+
pairs = []
|
|
1075
|
+
for tok in p[1].split(","):
|
|
1076
|
+
if "-" in tok:
|
|
1077
|
+
a, b = tok.split("-", 1)
|
|
1078
|
+
pairs.append((a.strip(), b.strip()))
|
|
1079
|
+
if pairs:
|
|
1080
|
+
equal_groups.append((int(p[0]), pairs))
|
|
1081
|
+
elif tag == "ANGLE_LABEL" and len(p) >= 4:
|
|
1082
|
+
angle_labels.append((p[0], p[1], p[2], p[3]))
|
|
1083
|
+
elif tag == "SIDE_LABEL" and len(p) >= 3:
|
|
1084
|
+
side_labels.append((p[0], p[1], p[2]))
|
|
1085
|
+
elif tag == "RADIUS_LABEL" and len(p) >= 2:
|
|
1086
|
+
radius_labels.append((p[0], p[1]))
|
|
1087
|
+
elif tag == "SLOPE_LABEL" and len(p) >= 3:
|
|
1088
|
+
slope_labels.append((p[0], p[1], p[2]))
|
|
1089
|
+
elif tag == "AREA_LABEL":
|
|
1090
|
+
names = [n.strip() for n in body.split(",") if n.strip()]
|
|
1091
|
+
if len(names) >= 3:
|
|
1092
|
+
area_labels.append(names)
|
|
1093
|
+
elif tag == "CALLOUT" and len(p) >= 2:
|
|
1094
|
+
callouts.append((p[0], p[1]))
|
|
1095
|
+
elif tag == "COORD_LABEL":
|
|
1096
|
+
coord_labels.append(body.strip())
|
|
1097
|
+
elif tag == "GRID":
|
|
1098
|
+
show_grid = body.strip().lower().startswith("y")
|
|
1099
|
+
except (ValueError, IndexError):
|
|
1100
|
+
continue # one malformed line (bad number, garbled token) should not sink the whole figure
|
|
1101
|
+
|
|
1102
|
+
if not points and not circles:
|
|
1103
|
+
raise ValueError("no POINT/CIRCLE/REGULAR_POLYGON/RIGHT_TRIANGLE definitions found")
|
|
1104
|
+
if not polygons and not segments and not circles and not marks:
|
|
1105
|
+
# Points were defined but nothing actually visible references them
|
|
1106
|
+
# (e.g. every SEGMENT/POLYGON line was malformed and got dropped) --
|
|
1107
|
+
# that would render a blank canvas and silently report success,
|
|
1108
|
+
# which is worse than failing and letting the retry loop try again.
|
|
1109
|
+
raise ValueError("no polygon, segment, or circle ended up referencing any defined point")
|
|
1110
|
+
|
|
1111
|
+
# A RADIUS_LABEL sometimes targets a name the model only ever declared
|
|
1112
|
+
# with POINT (e.g. "PLANET") and never actually drew as a CIRCLE, even
|
|
1113
|
+
# though asking for "its radius" only makes sense if it is one -- that's
|
|
1114
|
+
# clearly meant to be a small circle there, so make one rather than
|
|
1115
|
+
# failing outright on an otherwise-clear request.
|
|
1116
|
+
for circle_name, _ in radius_labels:
|
|
1117
|
+
if circle_name not in circles and circle_name in points:
|
|
1118
|
+
px, py = points[circle_name]
|
|
1119
|
+
extent_guess = max(
|
|
1120
|
+
(max((pt[0] for pt in points.values()), default=0) - min((pt[0] for pt in points.values()), default=0)),
|
|
1121
|
+
(max((pt[1] for pt in points.values()), default=0) - min((pt[1] for pt in points.values()), default=0)),
|
|
1122
|
+
1.0,
|
|
1123
|
+
)
|
|
1124
|
+
circles[circle_name] = (px, py, extent_guess * 0.06)
|
|
1125
|
+
aux_circles.add(circle_name)
|
|
1126
|
+
|
|
1127
|
+
# Every size (right-angle square, tick marks, arc radius, label offset)
|
|
1128
|
+
# scales off the figure's own extent rather than a fixed constant -- a
|
|
1129
|
+
# hexagon of radius 4 and a triangle with legs 5/12 need very different
|
|
1130
|
+
# absolute marker sizes to look the same proportionally.
|
|
1131
|
+
xs = [pt[0] for pt in points.values()] + [cx - r for cx, cy, r in circles.values()] + [cx + r for cx, cy, r in circles.values()]
|
|
1132
|
+
ys = [pt[1] for pt in points.values()] + [cy - r for cx, cy, r in circles.values()] + [cy + r for cx, cy, r in circles.values()]
|
|
1133
|
+
extent = max(max(xs) - min(xs), max(ys) - min(ys), 1e-6)
|
|
1134
|
+
scale = extent if extent > 0 else 4.0
|
|
1135
|
+
|
|
1136
|
+
fig, ax = plt.subplots(figsize=(6.5, 6.5))
|
|
1137
|
+
|
|
1138
|
+
for name, (cx, cy, r) in circles.items():
|
|
1139
|
+
is_aux = name in aux_circles
|
|
1140
|
+
ax.add_patch(Circle((cx, cy), r, fill=False, edgecolor="gray" if is_aux else "black",
|
|
1141
|
+
linewidth=1.0 if is_aux else 1.6, linestyle="--" if is_aux else "-"))
|
|
1142
|
+
|
|
1143
|
+
for names in polygons:
|
|
1144
|
+
ax.add_patch(Polygon([need(n) for n in names], closed=True, fill=False, edgecolor="black", linewidth=1.8))
|
|
1145
|
+
|
|
1146
|
+
for p1, p2 in segments:
|
|
1147
|
+
a, b = need(p1), need(p2)
|
|
1148
|
+
ax.plot([a[0], b[0]], [a[1], b[1]], color="black", linewidth=1.6)
|
|
1149
|
+
|
|
1150
|
+
mark_off = scale * 0.07
|
|
1151
|
+
for name in marks:
|
|
1152
|
+
pt = need(name)
|
|
1153
|
+
ax.plot(pt[0], pt[1], "o", color="black", markersize=5, zorder=5)
|
|
1154
|
+
ax.text(pt[0] + mark_off, pt[1] + mark_off, name, fontsize=10, ha="left", va="bottom")
|
|
1155
|
+
|
|
1156
|
+
for name in coord_labels:
|
|
1157
|
+
pt = need(name)
|
|
1158
|
+
label = f"({_fmt_num(pt[0])}, {_fmt_num(pt[1])})"
|
|
1159
|
+
ax.text(pt[0] + mark_off, pt[1] - mark_off, label, fontsize=10, ha="left", va="top")
|
|
1160
|
+
|
|
1161
|
+
ra_size = scale * 0.09
|
|
1162
|
+
for vertex, p1, p2 in right_angles:
|
|
1163
|
+
v, a, b = need(vertex), need(p1), need(p2)
|
|
1164
|
+
u1, u2 = (a - v) / np.linalg.norm(a - v), (b - v) / np.linalg.norm(b - v)
|
|
1165
|
+
pts = [v, v + ra_size * u1, v + ra_size * u1 + ra_size * u2, v + ra_size * u2]
|
|
1166
|
+
ax.add_patch(Polygon(pts, closed=True, fill=False, edgecolor="black", linewidth=1.2))
|
|
1167
|
+
|
|
1168
|
+
tick_size, tick_gap = scale * 0.045, scale * 0.025
|
|
1169
|
+
for count, pairs in equal_groups:
|
|
1170
|
+
for p1, p2 in pairs:
|
|
1171
|
+
a, b = need(p1), need(p2)
|
|
1172
|
+
mid = (a + b) / 2
|
|
1173
|
+
along = (b - a) / np.linalg.norm(b - a)
|
|
1174
|
+
perp = np.array([-along[1], along[0]])
|
|
1175
|
+
for off in (np.arange(count) - (count - 1) / 2) * tick_gap:
|
|
1176
|
+
c = mid + along * off
|
|
1177
|
+
ax.plot([c[0] - perp[0] * tick_size, c[0] + perp[0] * tick_size],
|
|
1178
|
+
[c[1] - perp[1] * tick_size, c[1] + perp[1] * tick_size], color="black", linewidth=1.5)
|
|
1179
|
+
|
|
1180
|
+
arc_r = scale * 0.13
|
|
1181
|
+
# An angle arc with no visible rays connecting its vertex to the two
|
|
1182
|
+
# points it's between reads as a floating, disconnected mark (the
|
|
1183
|
+
# vertex is often a circle's center, not a polygon corner, so there's no
|
|
1184
|
+
# other line that happens to anchor it). Draw a thin ray for any side
|
|
1185
|
+
# the model didn't already cover with its own POLYGON/SEGMENT.
|
|
1186
|
+
drawn_pairs = {frozenset((s1, s2)) for s1, s2 in segments}
|
|
1187
|
+
for names in polygons:
|
|
1188
|
+
drawn_pairs.update(frozenset((names[i], names[(i + 1) % len(names)])) for i in range(len(names)))
|
|
1189
|
+
for vertex, p1, p2, text in angle_labels:
|
|
1190
|
+
v, a, b = need(vertex), need(p1), need(p2)
|
|
1191
|
+
for other, pt in ((p1, a), (p2, b)):
|
|
1192
|
+
if frozenset((vertex, other)) not in drawn_pairs:
|
|
1193
|
+
ax.plot([v[0], pt[0]], [v[1], pt[1]], color="black", linewidth=1.0)
|
|
1194
|
+
drawn_pairs.add(frozenset((vertex, other)))
|
|
1195
|
+
start, sweep, mid = _angle_between(v, a, b)
|
|
1196
|
+
ax.add_patch(Arc(v, 2 * arc_r, 2 * arc_r, angle=0, theta1=start, theta2=start + sweep, color="black", linewidth=1.2))
|
|
1197
|
+
label = f"{_fmt_num(sweep)}°" if text.strip().upper() == "AUTO" else text
|
|
1198
|
+
mid_rad = np.radians(mid)
|
|
1199
|
+
ax.text(v[0] + arc_r * 1.5 * np.cos(mid_rad), v[1] + arc_r * 1.5 * np.sin(mid_rad), label,
|
|
1200
|
+
ha="center", va="center", fontsize=10)
|
|
1201
|
+
|
|
1202
|
+
side_off = scale * 0.05
|
|
1203
|
+
for p1, p2, text in side_labels:
|
|
1204
|
+
a, b = need(p1), need(p2)
|
|
1205
|
+
# A label for a side that was never actually drawn (e.g. the model
|
|
1206
|
+
# listed all three SIDE_LABELs for a triangle but forgot the
|
|
1207
|
+
# POLYGON/SEGMENT line itself) would float with nothing to anchor
|
|
1208
|
+
# it -- same fix as the angle rays above.
|
|
1209
|
+
if frozenset((p1, p2)) not in drawn_pairs:
|
|
1210
|
+
ax.plot([a[0], b[0]], [a[1], b[1]], color="black", linewidth=1.6)
|
|
1211
|
+
drawn_pairs.add(frozenset((p1, p2)))
|
|
1212
|
+
length = np.linalg.norm(b - a)
|
|
1213
|
+
label = _fmt_num(length) if text.strip().upper() == "AUTO" else text
|
|
1214
|
+
mid = (a + b) / 2
|
|
1215
|
+
along = (b - a) / length
|
|
1216
|
+
perp = np.array([-along[1], along[0]])
|
|
1217
|
+
ax.text(*(mid + perp * side_off), label, ha="center", va="center", fontsize=10)
|
|
1218
|
+
|
|
1219
|
+
# 45/225 first so two concentric-circle radius labels fall on one clean
|
|
1220
|
+
# radial line (rather than visibly bending) before less-aligned angles
|
|
1221
|
+
# take over for any additional circles.
|
|
1222
|
+
_radius_angles = [45, 225, 135, 315, 100, 280]
|
|
1223
|
+
for i, (circle_name, text) in enumerate(radius_labels):
|
|
1224
|
+
if circle_name not in circles:
|
|
1225
|
+
raise ValueError(f"RADIUS_LABEL references undefined circle '{circle_name}'")
|
|
1226
|
+
cx, cy, r = circles[circle_name]
|
|
1227
|
+
ang = np.radians(_radius_angles[i % len(_radius_angles)])
|
|
1228
|
+
edge = np.array([cx + r * np.cos(ang), cy + r * np.sin(ang)])
|
|
1229
|
+
center = np.array([cx, cy])
|
|
1230
|
+
ax.plot([cx, edge[0]], [cy, edge[1]], color="black", linewidth=1.0, linestyle=":")
|
|
1231
|
+
label = f"r = {_fmt_num(r)}" if text.strip().upper() == "AUTO" else text
|
|
1232
|
+
mid = center + (edge - center) * 0.55
|
|
1233
|
+
perp = np.array([-(edge - center)[1], (edge - center)[0]])
|
|
1234
|
+
perp = perp / (np.linalg.norm(perp) or 1.0)
|
|
1235
|
+
ax.text(*(mid + perp * side_off), label, ha="center", va="center", fontsize=10)
|
|
1236
|
+
|
|
1237
|
+
for p1, p2, text in slope_labels:
|
|
1238
|
+
a, b = need(p1), need(p2)
|
|
1239
|
+
label = text
|
|
1240
|
+
if text.strip().upper() == "AUTO":
|
|
1241
|
+
label = "slope undefined" if abs(b[0] - a[0]) < 1e-9 else f"slope = {_fmt_num((b[1] - a[1]) / (b[0] - a[0]))}"
|
|
1242
|
+
mid = (a + b) / 2
|
|
1243
|
+
ax.text(mid[0] + side_off, mid[1] + side_off, label, ha="left", va="bottom", fontsize=10)
|
|
1244
|
+
|
|
1245
|
+
for names in area_labels:
|
|
1246
|
+
pts = [need(n) for n in names]
|
|
1247
|
+
centroid = np.mean(pts, axis=0)
|
|
1248
|
+
ax.text(*centroid, f"Area = {_fmt_num(_polygon_area(pts))}", ha="center", va="center", fontsize=10,
|
|
1249
|
+
bbox=dict(boxstyle="round,pad=0.2", facecolor="white", edgecolor="none", alpha=0.85))
|
|
1250
|
+
|
|
1251
|
+
if callouts:
|
|
1252
|
+
all_pts = list(points.values()) or [np.array([cx, cy]) for cx, cy, r in circles.values()]
|
|
1253
|
+
centroid = np.mean(all_pts, axis=0)
|
|
1254
|
+
for target, text in callouts:
|
|
1255
|
+
t = need(target)
|
|
1256
|
+
direction = t - centroid
|
|
1257
|
+
norm = np.linalg.norm(direction)
|
|
1258
|
+
direction = direction / norm if norm > 1e-6 else np.array([0.0, 1.0])
|
|
1259
|
+
label_pos = t + direction * scale * 0.45
|
|
1260
|
+
ax.annotate(text, xy=t, xytext=label_pos, fontsize=10, color="red", ha="center", va="center",
|
|
1261
|
+
arrowprops=dict(arrowstyle="-|>", color="red", linewidth=1.5))
|
|
1262
|
+
|
|
1263
|
+
# Axis limits always come from the actual rendered geometry (points,
|
|
1264
|
+
# circles, and how far callout labels reach) -- never a model-guessed
|
|
1265
|
+
# window, which is what cropped circles and clipped shapes in the old
|
|
1266
|
+
# free-form-code approach.
|
|
1267
|
+
all_x = list(xs)
|
|
1268
|
+
all_y = list(ys)
|
|
1269
|
+
for target, _ in callouts:
|
|
1270
|
+
t = need(target)
|
|
1271
|
+
all_x.append(t[0] + scale * 0.5)
|
|
1272
|
+
all_y.append(t[1] + scale * 0.5)
|
|
1273
|
+
margin = max(max(all_x) - min(all_x), max(all_y) - min(all_y), 1.0) * 0.18
|
|
1274
|
+
ax.set_xlim(min(all_x) - margin, max(all_x) + margin)
|
|
1275
|
+
ax.set_ylim(min(all_y) - margin, max(all_y) + margin)
|
|
1276
|
+
ax.set_aspect("equal")
|
|
1277
|
+
if show_grid:
|
|
1278
|
+
# A real coordinate plane (numbered axes + light grid) -- requested
|
|
1279
|
+
# explicitly via GRID: yes, not the default, since most geometry
|
|
1280
|
+
# figures (a plain triangle/circle) read better with no axes at all.
|
|
1281
|
+
ax.grid(True, color="#dddddd", linewidth=0.7, zorder=0)
|
|
1282
|
+
ax.axhline(0, color="black", linewidth=1.0, zorder=1)
|
|
1283
|
+
ax.axvline(0, color="black", linewidth=1.0, zorder=1)
|
|
1284
|
+
ax.xaxis.set_major_locator(MaxNLocator(integer=True))
|
|
1285
|
+
ax.yaxis.set_major_locator(MaxNLocator(integer=True))
|
|
1286
|
+
ax.spines["top"].set_visible(False)
|
|
1287
|
+
ax.spines["right"].set_visible(False)
|
|
1288
|
+
else:
|
|
1289
|
+
ax.axis("off")
|
|
1290
|
+
autofix_text_overlaps(fig)
|
|
1291
|
+
plt.savefig(out_path, dpi=150, bbox_inches="tight")
|
|
1292
|
+
plt.close(fig)
|