"""Schematic layout fallback when the exact solver fails.""" from __future__ import annotations import math import numpy as np from statement_to_tikz.ir import ( Collinear, GeometryIR, Intersection, Midpoint, OnCircle, OnLine, SolveMode, SolvedScene, ) from statement_to_tikz.solve import ( _circle_geometry, evaluate_residuals, scene_from_coordinates, ) def _project_to_line( p: np.ndarray, a: np.ndarray, b: np.ndarray ) -> np.ndarray: ab = b - a L2 = float(np.dot(ab, ab)) if L2 < 1e-16: return a.copy() t = float(np.dot(p - a, ab) / L2) return a + t * ab def _initial_placement(ir: GeometryIR) -> dict[str, tuple[float, float]]: coords: dict[str, tuple[float, float]] = {} n = len(ir.points) for i, p in enumerate(ir.points): if p.hint is not None: coords[p.name] = (float(p.hint[0]), float(p.hint[1])) else: ang = 2 * math.pi * i / max(n, 1) - math.pi / 2 r = 1.0 + 0.05 * i coords[p.name] = (r * math.cos(ang), r * math.sin(ang)) return coords def _repair_once( ir: GeometryIR, coords: dict[str, tuple[float, float]] ) -> dict[str, tuple[float, float]]: out = {k: (float(v[0]), float(v[1])) for k, v in coords.items()} def setp(name: str, xy: np.ndarray) -> None: out[name] = (float(xy[0]), float(xy[1])) for c in ir.constraints: if isinstance(c, Midpoint) and c.a in out and c.b in out: a = np.asarray(out[c.a]) b = np.asarray(out[c.b]) setp(c.point, 0.5 * (a + b)) elif isinstance(c, OnLine) and c.a in out and c.b in out and c.point in out: p = np.asarray(out[c.point]) a = np.asarray(out[c.a]) b = np.asarray(out[c.b]) setp(c.point, _project_to_line(p, a, b)) elif isinstance(c, Collinear) and all(p in out for p in c.points): a = np.asarray(out[c.points[0]]) b = np.asarray(out[c.points[1]]) for name in c.points[2:]: p = np.asarray(out[name]) setp(name, _project_to_line(p, a, b)) elif isinstance(c, Intersection) and all( p in out for p in (c.a, c.b, c.c, c.d) ): # line-line intersection a = np.asarray(out[c.a], dtype=float) b = np.asarray(out[c.b], dtype=float) cc = np.asarray(out[c.c], dtype=float) d = np.asarray(out[c.d], dtype=float) ab = b - a cd = d - cc mat = np.array([ab, -cd]).T det = float(np.linalg.det(mat)) if abs(det) > 1e-12: t = float(np.linalg.solve(mat, cc - a)[0]) setp(c.point, a + t * ab) elif isinstance(c, OnCircle) and c.point in out: geom = _circle_geometry(ir, out, c.circle) if geom is not None: center, rad = geom p = np.asarray(out[c.point]) v = p - center n = float(np.linalg.norm(v)) if n < 1e-12: v = np.array([rad, 0.0]) else: v = v / n * rad setp(c.point, center + v) return out def layout_schematic( ir: GeometryIR, *, iterations: int = 40, base: dict[str, tuple[float, float]] | None = None, ) -> SolvedScene: """Produce a labeled schematic that soft-enforces incidences.""" coords = base if base is not None else _initial_placement(ir) for _ in range(iterations): coords = _repair_once(ir, coords) # Normalize: centroid to origin, scale median radius to 1 if coords: pts = np.array(list(coords.values())) center = pts.mean(axis=0) pts = pts - center radii = np.linalg.norm(pts, axis=1) scale = float(np.median(radii)) if len(radii) else 1.0 if scale < 1e-9: scale = 1.0 coords = { k: (float((np.asarray(v) - center)[0] / scale), float((np.asarray(v) - center)[1] / scale)) for k, v in coords.items() } res = evaluate_residuals(ir, coords) max_r = max(res) if res else 0.0 return scene_from_coordinates( ir, coords, mode=SolveMode.schematic, message=f"schematic layout (max residual {max_r:.4g})", )