| """Constraint residual checks and LaTeX compilation.""" |
|
|
| from __future__ import annotations |
|
|
| import shutil |
| import subprocess |
| from pathlib import Path |
|
|
| from statement_to_tikz.ir import GeometryIR, SolvedScene |
| from statement_to_tikz.solve import evaluate_residuals |
|
|
|
|
| def residual_report(scene: SolvedScene, tol: float = 1e-4) -> dict: |
| residuals = evaluate_residuals(scene.ir, scene.coordinates) |
| max_r = max(residuals) if residuals else 0.0 |
| return { |
| "max_residual": max_r, |
| "residuals": residuals, |
| "within_tol": max_r <= tol, |
| "tol": tol, |
| "mode": scene.mode.value, |
| "message": scene.message, |
| } |
|
|
|
|
| def compile_tex( |
| tex_path: Path, |
| *, |
| engine: str = "pdflatex", |
| timeout: float = 60.0, |
| ) -> dict: |
| """Compile a .tex file in its directory. Returns status dict.""" |
| tex_path = Path(tex_path) |
| if not tex_path.is_file(): |
| return {"ok": False, "error": f"missing file: {tex_path}"} |
|
|
| latexmk = shutil.which("latexmk") |
| pdflatex = shutil.which(engine) |
| cwd = tex_path.parent |
|
|
| if latexmk: |
| cmd = [ |
| latexmk, |
| "-pdf", |
| f"-{engine}", |
| "-interaction=nonstopmode", |
| "-halt-on-error", |
| tex_path.name, |
| ] |
| elif pdflatex: |
| cmd = [ |
| engine, |
| "-interaction=nonstopmode", |
| "-halt-on-error", |
| tex_path.name, |
| ] |
| else: |
| return { |
| "ok": False, |
| "error": f"neither latexmk nor {engine} found on PATH", |
| "skipped": True, |
| } |
|
|
| try: |
| proc = subprocess.run( |
| cmd, |
| cwd=cwd, |
| capture_output=True, |
| text=True, |
| timeout=timeout, |
| check=False, |
| ) |
| except subprocess.TimeoutExpired: |
| return {"ok": False, "error": "compile timeout"} |
|
|
| pdf = tex_path.with_suffix(".pdf") |
| ok = proc.returncode == 0 and pdf.is_file() |
| return { |
| "ok": ok, |
| "returncode": proc.returncode, |
| "pdf": str(pdf) if pdf.is_file() else None, |
| "stdout_tail": (proc.stdout or "")[-2000:], |
| "stderr_tail": (proc.stderr or "")[-1000:], |
| } |
|
|
|
|
| def verify_scene( |
| scene: SolvedScene, |
| tex_path: Path | None = None, |
| *, |
| tol: float = 1e-4, |
| compile: bool = True, |
| ) -> dict: |
| report = residual_report(scene, tol=tol) |
| report["compile"] = None |
| if compile and tex_path is not None: |
| report["compile"] = compile_tex(tex_path) |
| return report |
|
|
|
|
| def check_ir_consistency(ir: GeometryIR) -> list[str]: |
| """Return list of soft warnings about IR consistency.""" |
| names = set(ir.point_names()) |
| warnings: list[str] = [] |
| for seg in ir.segments: |
| for p in (seg.a, seg.b): |
| if p not in names: |
| warnings.append(f"segment references unknown point {p!r}") |
| for c in ir.constraints: |
| ctype = c.type |
| fields = c.model_dump() |
| for key, val in fields.items(): |
| if key in ("type", "value", "degrees"): |
| continue |
| if isinstance(val, str) and key not in ("circle",) and len(val) <= 8: |
| |
| if key in ( |
| "a", |
| "b", |
| "a1", |
| "a2", |
| "b1", |
| "b2", |
| "v1", |
| "v2", |
| "vertex", |
| "point", |
| "c", |
| "d", |
| ): |
| if val not in names: |
| warnings.append(f"constraint {ctype} unknown point {val!r}") |
| if key == "points" and isinstance(val, list): |
| for p in val: |
| if p not in names: |
| warnings.append(f"constraint {ctype} unknown point {p!r}") |
| if key == "circle" and isinstance(val, str): |
| circ_ids = {ci.id for ci in ir.circles} |
| if val not in circ_ids: |
| warnings.append(f"on_circle unknown circle {val!r}") |
| return warnings |
|
|