File size: 4,386 Bytes
eab734a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | """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})",
)
|