"""Nonlinear least-squares geometry constraint solver.""" from __future__ import annotations import math from collections import defaultdict import numpy as np from scipy.optimize import least_squares from statement_to_tikz.ir import ( AngleMeasure, CircleDef, Collinear, EqualAngle, EqualLength, GeometryIR, Intersection, Length, Midpoint, OnCircle, OnLine, Parallel, Perpendicular, SolveMode, SolvedScene, ) DEFAULT_TOL = 1e-4 def _get(coords: dict[str, tuple[float, float]], name: str) -> np.ndarray: return np.asarray(coords[name], dtype=float) def _vec(a: np.ndarray, b: np.ndarray) -> np.ndarray: return b - a def _cross2(u: np.ndarray, v: np.ndarray) -> float: return float(u[0] * v[1] - u[1] * v[0]) def _dot(u: np.ndarray, v: np.ndarray) -> float: return float(np.dot(u, v)) def _norm(u: np.ndarray) -> float: return float(np.linalg.norm(u)) def _angle_cos(a: np.ndarray, v: np.ndarray, b: np.ndarray) -> float: u = a - v w = b - v nu, nw = _norm(u), _norm(w) if nu < 1e-12 or nw < 1e-12: return 1.0 return _dot(u, w) / (nu * nw) def _circle_geometry( ir: GeometryIR, coords: dict[str, tuple[float, float]], circle_id: str ) -> tuple[np.ndarray, float] | None: circ = next((c for c in ir.circles if c.id == circle_id), None) if circ is None: return None return _circle_center_radius_from_def(circ, coords) def _circle_center_radius_from_def( circ: CircleDef, coords: dict[str, tuple[float, float]] ) -> tuple[np.ndarray, float] | None: if circ.center is not None and circ.center in coords: c = _get(coords, circ.center) if circ.radius is not None: return c, float(circ.radius) if circ.through: for p in circ.through: if p in coords: return c, _norm(_get(coords, p) - c) return c, 1.0 if circ.through and len(circ.through) >= 3: pts = [p for p in circ.through[:3] if p in coords] if len(pts) < 3: return None a, b, c = (_get(coords, p) for p in pts) ax, ay = a bx, by = b cx, cy = c d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) if abs(d) < 1e-12: return None ux = ( (ax**2 + ay**2) * (by - cy) + (bx**2 + by**2) * (cy - ay) + (cx**2 + cy**2) * (ay - by) ) / d uy = ( (ax**2 + ay**2) * (cx - bx) + (bx**2 + by**2) * (ax - cx) + (cx**2 + cy**2) * (bx - ax) ) / d center = np.array([ux, uy]) return center, _norm(a - center) return None def constraint_residuals( ir: GeometryIR, coords: dict[str, tuple[float, float]] ) -> list[float]: """Return list of scalar residuals for each constraint (may be multi-valued flattened).""" r: list[float] = [] for c in ir.constraints: if isinstance(c, EqualLength): la = _norm(_get(coords, c.a2) - _get(coords, c.a1)) lb = _norm(_get(coords, c.b2) - _get(coords, c.b1)) r.append(la - lb) elif isinstance(c, Length): la = _norm(_get(coords, c.b) - _get(coords, c.a)) r.append(la - c.value) elif isinstance(c, EqualAngle): cos1 = _angle_cos( _get(coords, c.a1), _get(coords, c.v1), _get(coords, c.b1) ) cos2 = _angle_cos( _get(coords, c.a2), _get(coords, c.v2), _get(coords, c.b2) ) r.append(cos1 - cos2) elif isinstance(c, AngleMeasure): cos_t = math.cos(math.radians(c.degrees)) cos_m = _angle_cos( _get(coords, c.a), _get(coords, c.vertex), _get(coords, c.b) ) r.append(cos_m - cos_t) elif isinstance(c, Perpendicular): u = _vec(_get(coords, c.a1), _get(coords, c.a2)) v = _vec(_get(coords, c.b1), _get(coords, c.b2)) r.append(_dot(u, v)) elif isinstance(c, Parallel): u = _vec(_get(coords, c.a1), _get(coords, c.a2)) v = _vec(_get(coords, c.b1), _get(coords, c.b2)) r.append(_cross2(u, v)) elif isinstance(c, OnLine): a, b, p = _get(coords, c.a), _get(coords, c.b), _get(coords, c.point) r.append(_cross2(b - a, p - a)) elif isinstance(c, OnCircle): geom = _circle_geometry(ir, coords, c.circle) if geom is None: r.append(0.0) else: center, rad = geom r.append(_norm(_get(coords, c.point) - center) - rad) elif isinstance(c, Midpoint): m = _get(coords, c.point) mid = 0.5 * (_get(coords, c.a) + _get(coords, c.b)) r.extend([float(m[0] - mid[0]), float(m[1] - mid[1])]) elif isinstance(c, Collinear): pts = [_get(coords, p) for p in c.points] base = pts[1] - pts[0] for p in pts[2:]: r.append(_cross2(base, p - pts[0])) elif isinstance(c, Intersection): a, b = _get(coords, c.a), _get(coords, c.b) cc, d = _get(coords, c.c), _get(coords, c.d) p = _get(coords, c.point) r.append(_cross2(b - a, p - a)) r.append(_cross2(d - cc, p - cc)) else: raise TypeError(f"unknown constraint {type(c)}") for circ in ir.circles: if circ.center and circ.through and circ.center in coords: center = _get(coords, circ.center) if circ.radius is not None: rad = circ.radius else: refs = [p for p in circ.through if p in coords] if not refs: continue rad = _norm(_get(coords, refs[0]) - center) for p in circ.through: if p in coords: r.append(_norm(_get(coords, p) - center) - rad) elif circ.through and len(circ.through) >= 3: pts = [p for p in circ.through if p in coords] if len(pts) >= 3: geom = _circle_center_radius_from_def(circ, coords) if geom is not None: center, rad = geom for p in pts: r.append(_norm(_get(coords, p) - center) - rad) return r def evaluate_residuals( ir: GeometryIR, coords: dict[str, tuple[float, float]] ) -> list[float]: return [abs(x) for x in constraint_residuals(ir, coords)] def _initial_coords(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 coords[p.name] = (math.cos(ang), math.sin(ang)) return coords def _gauge_point_names(ir: GeometryIR) -> tuple[str | None, str | None]: """Choose two points to fix: prefer a length-constrained segment as base.""" names = ir.point_names() if not names: return None, None for c in ir.constraints: if isinstance(c, Length): return c.a, c.b for c in ir.constraints: if isinstance(c, Midpoint): return c.a, c.b if ir.segments: return ir.segments[0].a, ir.segments[0].b p0 = names[0] p1 = names[1] if len(names) > 1 else None return p0, p1 def _triangle_area_residuals( ir: GeometryIR, coords: dict[str, tuple[float, float]] ) -> list[float]: """Soft non-degeneracy: penalize near-zero area for 3-cycles of segments.""" adj: dict[str, set[str]] = defaultdict(set) for seg in ir.segments: adj[seg.a].add(seg.b) adj[seg.b].add(seg.a) residuals: list[float] = [] seen: set[tuple[str, str, str]] = set() for a, nbrs in adj.items(): for b in nbrs: for c in nbrs: if b >= c: continue if c not in adj[b]: continue key = tuple(sorted((a, b, c))) if key in seen: continue seen.add(key) if not all(p in coords for p in key): continue pa, pb, pc = (_get(coords, p) for p in key) area2 = abs(_cross2(pb - pa, pc - pa)) residuals.append(max(0.0, 0.5 - area2)) return residuals def _remap_init_to_gauge( init: dict[str, tuple[float, float]], p0: str, p1: str, gauged: dict[str, tuple[float, float]], ) -> dict[str, tuple[float, float]]: """Rigidly map hinted positions onto the gauged base segment.""" if p0 not in init or p1 not in init: return gauged o0 = np.asarray(init[p0], dtype=float) o1 = np.asarray(init[p1], dtype=float) n0 = np.asarray(gauged[p0], dtype=float) n1 = np.asarray(gauged[p1], dtype=float) o_len = _norm(o1 - o0) or 1.0 n_len = _norm(n1 - n0) or 1.0 scale = n_len / o_len od = (o1 - o0) / o_len nd = (n1 - n0) / n_len ang = math.atan2(nd[1], nd[0]) - math.atan2(od[1], od[0]) ca, sa = math.cos(ang), math.sin(ang) rot = np.array([[ca, -sa], [sa, ca]]) remapped = dict(gauged) for n, xy in init.items(): if n in (p0, p1): continue local = (np.asarray(xy, dtype=float) - o0) * scale remapped[n] = tuple(n0 + rot @ local) return remapped def _degrees_of_freedom(ir: GeometryIR) -> tuple[int, int]: """Rough DOF count: 2*|points| - 3 (gauge) vs number of scalar residuals.""" n_coords = 2 * len(ir.points) gauge = min(3, n_coords) probe = _initial_coords(ir) n_res = len(constraint_residuals(ir, probe)) return n_coords - gauge, n_res def solve_geometry( ir: GeometryIR, *, tol: float = DEFAULT_TOL, max_nfev: int = 2000, ) -> SolvedScene: """Solve for point coordinates. Returns exact mode if residuals within tol.""" names = ir.point_names() if not names: return SolvedScene( ir=ir, coordinates={}, mode=SolveMode.failed, max_residual=0.0, message="no points", ) init = _initial_coords(ir) dof, n_res = _degrees_of_freedom(ir) if n_res == 0 or n_res < max(1, dof - 1): res = evaluate_residuals(ir, init) return SolvedScene( ir=ir, coordinates=init, mode=SolveMode.failed, max_residual=max(res) if res else 0.0, residuals=res, message=f"underconstrained (dof≈{dof}, residuals={n_res})", ) p0, p1 = _gauge_point_names(ir) free_names: list[str] = [n for n in names if n != p0] def apply_gauge(partial: dict[str, tuple[float, float]]) -> dict[str, tuple[float, float]]: full = dict(partial) if p0: full[p0] = (0.0, 0.0) if p1 and p1 in full: x, _y = full[p1] if abs(x) < 1e-9: x = 1.0 full[p1] = (abs(x), 0.0) elif p1: full[p1] = (1.0, 0.0) return full def pack_free(coords: dict[str, tuple[float, float]]) -> np.ndarray: vals: list[float] = [] for n in free_names: x, y = coords[n] if n == p1: vals.append(x if abs(x) > 1e-9 else 1.0) else: vals.extend([x, y]) return np.asarray(vals, dtype=float) def unpack_free(v: np.ndarray) -> dict[str, tuple[float, float]]: partial: dict[str, tuple[float, float]] = {} idx = 0 for n in free_names: if n == p1: partial[n] = (float(v[idx]), 0.0) idx += 1 else: partial[n] = (float(v[idx]), float(v[idx + 1])) idx += 2 return apply_gauge(partial) def fun(v: np.ndarray) -> np.ndarray: coords = unpack_free(v) res = list(constraint_residuals(ir, coords)) res.extend(_triangle_area_residuals(ir, coords)) if not res: extras = [] if p1 and p1 in coords: extras.append(coords[p1][0] - 1.0) return np.asarray(extras or [0.0], dtype=float) return np.asarray(res, dtype=float) gauged_init = apply_gauge(init) if p0 and p1: gauged_init = apply_gauge(_remap_init_to_gauge(init, p0, p1, gauged_init)) x0 = pack_free(gauged_init) if x0.size == 0: coords = gauged_init res = evaluate_residuals(ir, coords) max_r = max(res) if res else 0.0 mode = SolveMode.exact if max_r <= tol else SolveMode.failed return SolvedScene( ir=ir, coordinates=coords, mode=mode, max_residual=max_r, residuals=res, message="only gauge points", ) try: result = least_squares( fun, x0, ftol=1e-12, xtol=1e-12, gtol=1e-12, max_nfev=max_nfev ) coords = unpack_free(result.x) except Exception as exc: # noqa: BLE001 coords = gauged_init res = evaluate_residuals(ir, coords) return SolvedScene( ir=ir, coordinates=coords, mode=SolveMode.failed, max_residual=max(res) if res else float("inf"), residuals=res, message=f"solver error: {exc}", ) res = evaluate_residuals(ir, coords) max_r = max(res) if res else 0.0 if max_r <= tol: mode = SolveMode.exact msg = "constraints satisfied" else: mode = SolveMode.failed msg = f"residuals too large ({max_r:.4g})" return SolvedScene( ir=ir, coordinates={k: (float(v[0]), float(v[1])) for k, v in coords.items()}, mode=mode, max_residual=float(max_r), residuals=res, message=msg, ) def scene_from_coordinates( ir: GeometryIR, coords: dict[str, tuple[float, float]], *, mode: SolveMode, message: str = "", ) -> SolvedScene: res = evaluate_residuals(ir, coords) max_r = max(res) if res else 0.0 return SolvedScene( ir=ir, coordinates=coords, mode=mode, max_residual=float(max_r), residuals=res, message=message, )