| """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) |
| ): |
| |
| 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) |
|
|
| |
| 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})", |
| ) |
|
|