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.
@@ -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)