"""Canonical Standard Geometry Constructors (P1). Provides hierarchical, intuitive construction strategies for standard 2D/3D shapes and solids before falling back to generic numerical optimization. """ from __future__ import annotations import logging import math from typing import Any, Dict, List, Optional, Set, Tuple import numpy as np from .models import Point, Constraint from .validator import GeometryValidator logger = logging.getLogger(__name__) class StandardGeometryConstructor: """ Hierarchical and canonical geometry constructor for standard 2D and 3D shapes. Constructs well-formed, intuitive default representations on canonical planes (z=0 for 3D bases) while strictly preserving mathematical lengths and explicit user coordinates. """ def __init__(self): self.validator = GeometryValidator(tolerance=0.02) def try_construct( self, points: List[Point], constraints: List[Constraint], solids_meta: List[Dict[str, Any]], is_3d: bool = False, ) -> Optional[Dict[str, Any]]: """ Attempts hierarchical canonical construction. Returns engine result dict if successful and fully validated, else None. """ # If user gave explicit coordinates for multiple points, let the general solver handle it explicit_pts = {p.id: p for p in points if p.x is not None or p.y is not None or p.z is not None} if len(explicit_pts) >= 2: return None point_ids = [p.id for p in points] lengths: Dict[Tuple[str, str], float] = {} for c in constraints: if c.type == "length" and len(c.targets) == 2: p1, p2 = c.targets[0], c.targets[1] val = float(c.value) lengths[(p1, p2)] = val lengths[(p2, p1)] = val def get_len(p1: str, p2: str, default: float = 5.0) -> float: return lengths.get((p1, p2), default) # ========================================================================= # 1. 3D SOLIDS CANONICAL CONSTRUCTORS # ========================================================================= if is_3d: # --------------------------------------------------------------------- # A. PYRAMID (S_ABCD or S_ABC) # --------------------------------------------------------------------- pyramid_solid = next((s for s in solids_meta if s.get("type") == "pyramid"), None) if pyramid_solid: apex = pyramid_solid.get("apex") base = pyramid_solid.get("base", []) if apex and len(base) in (3, 4): coords: Dict[str, List[float]] = {} # 1. Construct Base on z=0 if len(base) == 4: # Quadrilateral base: Square / Rectangle pA, pB, pC, pD = base[0], base[1], base[2], base[3] side_ab = get_len(pA, pB, 6.0) side_bc = get_len(pB, pC, side_ab) coords[pA] = [0.0, 0.0, 0.0] coords[pB] = [side_ab, 0.0, 0.0] coords[pC] = [side_ab, side_bc, 0.0] coords[pD] = [0.0, side_bc, 0.0] else: # Triangular base pA, pB, pC = base[0], base[1], base[2] side_ab = get_len(pA, pB, 6.0) side_bc = get_len(pB, pC, side_ab) side_ca = get_len(pC, pA, side_ab) # Equilateral or general triangle in z=0 coords[pA] = [0.0, 0.0, 0.0] coords[pB] = [side_ab, 0.0, 0.0] # Solve C_x, C_y in z=0 cos_A = (side_ab**2 + side_ca**2 - side_bc**2) / (2 * side_ab * side_ca + 1e-9) cos_A = max(-1.0, min(1.0, cos_A)) sin_A = math.sqrt(max(0.0, 1.0 - cos_A**2)) coords[pC] = [side_ca * cos_A, side_ca * sin_A, 0.0] # 2. Determine Foot of Altitude O # Check if explicit center / foot constraint exists foot_id = None for c in constraints: if c.type in ("center", "centroid") and len(c.targets) >= 2: if c.targets[0] in point_ids and set(c.targets[1:]).issubset(set(base)): foot_id = c.targets[0] break elif c.type in ("perp_plane", "height", "altitude") and len(c.targets) >= 2: if c.targets[0] == apex and c.targets[1] in point_ids: foot_id = c.targets[1] break base_vecs = [np.array(coords[bp]) for bp in base] mean_center = np.mean(base_vecs, axis=0) if foot_id and foot_id not in coords: coords[foot_id] = [float(mean_center[0]), float(mean_center[1]), 0.0] # 3. Determine Height / Apex S height = None if foot_id: height = lengths.get((apex, foot_id)) if height is None: # Check lateral edge length lateral_len = get_len(apex, base[0], None) if lateral_len is not None: r_foot = float(np.linalg.norm(coords[base[0]] - mean_center)) if lateral_len > r_foot: height = math.sqrt(lateral_len**2 - r_foot**2) if height is None: height = 8.0 apex_x = coords[foot_id][0] if foot_id and foot_id in coords else mean_center[0] apex_y = coords[foot_id][1] if foot_id and foot_id in coords else mean_center[1] coords[apex] = [float(apex_x), float(apex_y), float(height)] # 4. Resolve any additional auxiliary points (midpoints, sections, point_on) self._resolve_auxiliary_points(coords, constraints, point_ids) # Validate construction engine_res = {"coordinates": coords, "solids": solids_meta, "drawing_phases": []} val = self.validator.validate(engine_res, constraints, is_3d=True) if val.is_valid: logger.info("[StandardGeometryConstructor] Canonical Pyramid construction SUCCESS.") return engine_res # --------------------------------------------------------------------- # B. PRISM / CUBE / CUBOID # --------------------------------------------------------------------- prism_solid = next((s for s in solids_meta if s.get("type") in ("prism", "cube", "cuboid")), None) if prism_solid: b1 = prism_solid.get("base1", []) b2 = prism_solid.get("base2", []) s_type = prism_solid.get("type") if len(b1) == len(b2) and len(b1) in (3, 4): coords: Dict[str, List[float]] = {} height = get_len(b1[0], b2[0], 6.0) if len(b1) == 4: side_a = get_len(b1[0], b1[1], 5.0) side_b = side_a if s_type == "cube" else get_len(b1[1], b1[2], 4.0) if s_type == "cube": height = side_a coords[b1[0]] = [0.0, 0.0, 0.0] coords[b1[1]] = [side_a, 0.0, 0.0] coords[b1[2]] = [side_a, side_b, 0.0] coords[b1[3]] = [0.0, side_b, 0.0] else: side_a = get_len(b1[0], b1[1], 5.0) coords[b1[0]] = [0.0, 0.0, 0.0] coords[b1[1]] = [side_a, 0.0, 0.0] coords[b1[2]] = [side_a / 2.0, side_a * math.sqrt(3) / 2.0, 0.0] # Translate Base 2 along +Z for p1, p2 in zip(b1, b2): coords[p2] = [coords[p1][0], coords[p1][1], float(height)] self._resolve_auxiliary_points(coords, constraints, point_ids) engine_res = {"coordinates": coords, "solids": solids_meta, "drawing_phases": []} val = self.validator.validate(engine_res, constraints, is_3d=True) if val.is_valid: logger.info(f"[StandardGeometryConstructor] Canonical {s_type} construction SUCCESS.") return engine_res # ========================================================================= # 2. 2D POLYGON CANONICAL CONSTRUCTORS # ========================================================================= else: poly_constraint = next( (c for c in constraints if c.type in ("square", "rectangle", "equilateral_triangle", "right_triangle")), None, ) if poly_constraint: c_type = poly_constraint.type targets = poly_constraint.targets coords: Dict[str, List[float]] = {} if c_type == "square" and len(targets) >= 4: pA, pB, pC, pD = targets[:4] side = get_len(pA, pB, 6.0) coords[pA] = [0.0, 0.0, 0.0] coords[pB] = [side, 0.0, 0.0] coords[pC] = [side, side, 0.0] coords[pD] = [0.0, side, 0.0] elif c_type == "rectangle" and len(targets) >= 4: pA, pB, pC, pD = targets[:4] side_a = get_len(pA, pB, 8.0) side_b = get_len(pB, pC, 6.0) coords[pA] = [0.0, 0.0, 0.0] coords[pB] = [side_a, 0.0, 0.0] coords[pC] = [side_a, side_b, 0.0] coords[pD] = [0.0, side_b, 0.0] elif c_type == "equilateral_triangle" and len(targets) >= 3: pA, pB, pC = targets[:3] side = get_len(pA, pB, 6.0) coords[pA] = [0.0, 0.0, 0.0] coords[pB] = [side, 0.0, 0.0] coords[pC] = [side / 2.0, side * math.sqrt(3) / 2.0, 0.0] elif c_type == "right_triangle" and len(targets) >= 3: pA, pB, pC = targets[:3] side_ab = get_len(pA, pB, 6.0) side_bc = get_len(pB, pC, 8.0) coords[pB] = [0.0, 0.0, 0.0] coords[pA] = [side_ab, 0.0, 0.0] coords[pC] = [0.0, side_bc, 0.0] if coords: self._resolve_auxiliary_points(coords, constraints, point_ids) engine_res = {"coordinates": coords, "solids": solids_meta, "drawing_phases": []} val = self.validator.validate(engine_res, constraints, is_3d=False) if val.is_valid: logger.info(f"[StandardGeometryConstructor] Canonical 2D {c_type} construction SUCCESS.") return engine_res return None def _resolve_auxiliary_points( self, coords: Dict[str, List[float]], constraints: List[Constraint], point_ids: List[str], ): """Resolves midpoint, section, center, and deterministic construction primitives iteratively.""" for _ in range(6): for c in constraints: c_type = c.type targets = c.targets val = c.value if c_type == "midpoint" and len(targets) == 3: pM, pA, pB = targets[0], targets[1], targets[2] if pM not in coords and pA in coords and pB in coords: vA = np.array(coords[pA]) vB = np.array(coords[pB]) coords[pM] = list((vA + vB) / 2.0) elif c_type == "section" and len(targets) == 3: pE, pA, pC = targets[0], targets[1], targets[2] if pE not in coords and pA in coords and pC in coords: vA = np.array(coords[pA]) vC = np.array(coords[pC]) k = float(val) coords[pE] = list(vA + k * (vC - vA)) elif c_type in ("center", "centroid") and len(targets) >= 3: pO = targets[0] poly_pts = targets[1:] if pO not in coords and all(p in coords for p in poly_pts): poly_vecs = [np.array(coords[p]) for p in poly_pts] coords[pO] = list(np.mean(poly_vecs, axis=0)) elif c_type == "incenter" and len(targets) >= 4: pI, pA, pB, pC = targets[0], targets[1], targets[2], targets[3] if pI not in coords and all(p in coords for p in [pA, pB, pC]): vA, vB, vC = np.array(coords[pA]), np.array(coords[pB]), np.array(coords[pC]) a = float(np.linalg.norm(vC - vB)) b = float(np.linalg.norm(vA - vC)) c_len = float(np.linalg.norm(vB - vA)) tot = a + b + c_len if tot > 1e-6: coords[pI] = list((a * vA + b * vB + c_len * vC) / tot) elif c_type == "circumcenter" and len(targets) >= 4: pO, pA, pB, pC = targets[0], targets[1], targets[2], targets[3] if pO not in coords and all(p in coords for p in [pA, pB, pC]): vA, vB, vC = np.array(coords[pA]), np.array(coords[pB]), np.array(coords[pC]) u = vB - vA v = vC - vA normal = np.cross(u, v) norm_n_sq = float(np.dot(normal, normal)) if norm_n_sq > 1e-8: coords[pO] = list(vA + (np.dot(u, u) * np.cross(normal, v) + np.dot(v, v) * np.cross(u, normal)) / (2.0 * norm_n_sq)) elif c_type == "orthocenter" and len(targets) >= 4: pH, pA, pB, pC = targets[0], targets[1], targets[2], targets[3] if pH not in coords and all(p in coords for p in [pA, pB, pC]): vA, vB, vC = np.array(coords[pA]), np.array(coords[pB]), np.array(coords[pC]) u = vB - vA v = vC - vA normal = np.cross(u, v) norm_n_sq = float(np.dot(normal, normal)) if norm_n_sq > 1e-8: vO = vA + (np.dot(u, u) * np.cross(normal, v) + np.dot(v, v) * np.cross(u, normal)) / (2.0 * norm_n_sq) coords[pH] = list(vA + vB + vC - 2.0 * vO) elif c_type == "angle_bisector" and len(targets) >= 4: pD, pA, pB, pC = targets[0], targets[1], targets[2], targets[3] if pD not in coords and all(p in coords for p in [pA, pB, pC]): vA, vB, vC = np.array(coords[pA]), np.array(coords[pB]), np.array(coords[pC]) b = float(np.linalg.norm(vC - vA)) c_len = float(np.linalg.norm(vB - vA)) if (b + c_len) > 1e-6: coords[pD] = list((b * vB + c_len * vC) / (b + c_len)) elif c_type in ("foot", "project_point_line") and len(targets) >= 4: pH, pP, pA, pB = targets[0], targets[1], targets[2], targets[3] if pH not in coords and all(p in coords for p in [pP, pA, pB]): vP, vA, vB = np.array(coords[pP]), np.array(coords[pA]), np.array(coords[pB]) ab = vB - vA norm_ab_sq = float(np.dot(ab, ab)) if norm_ab_sq > 1e-8: t = float(np.dot(vP - vA, ab) / norm_ab_sq) coords[pH] = list(vA + t * ab) elif c_type in ("foot_plane", "project_point_plane") and len(targets) >= 4: pH, pP = targets[0], targets[1] plane_pts = targets[2:] if pH not in coords and pP in coords and len(plane_pts) >= 3 and all(p in coords for p in plane_pts[:3]): vP = np.array(coords[pP]) p0, p1, p2 = np.array(coords[plane_pts[0]]), np.array(coords[plane_pts[1]]), np.array(coords[plane_pts[2]]) normal = np.cross(p1 - p0, p2 - p0) norm_n_sq = float(np.dot(normal, normal)) if norm_n_sq > 1e-8: dist = float(np.dot(vP - p0, normal) / norm_n_sq) coords[pH] = list(vP - dist * normal) elif c_type == "intersect_lines" and len(targets) >= 5: pP, pA, pB, pC, pD = targets[:5] if pP not in coords and all(p in coords for p in [pA, pB, pC, pD]): vA, vB, vC, vD = np.array(coords[pA]), np.array(coords[pB]), np.array(coords[pC]), np.array(coords[pD]) d1 = vB - vA d2 = vD - vC M = np.column_stack([d1, -d2]) rhs = vC - vA try: sol, residuals, rank, s_vals = np.linalg.lstsq(M, rhs, rcond=None) coords[pP] = list(vA + sol[0] * d1) except Exception: pass elif c_type == "ratio_segment" and len(targets) >= 3: pA, pM, pB = targets[:3] ratio = float(val) if val is not None else 1.0 if pM not in coords and pA in coords and pB in coords: vA, vB = np.array(coords[pA]), np.array(coords[pB]) if (1.0 + ratio) > 1e-6: coords[pM] = list((vA + ratio * vB) / (1.0 + ratio)) elif c_type == "point_on" and len(targets) == 3: pP, pA, pB = targets[0], targets[1], targets[2] if pP not in coords and pA in coords and pB in coords: len_ap = next( ( float(cc.value) for cc in constraints if cc.type == "length" and set(cc.targets[:2]) == {pP, pA} ), None, ) vA = np.array(coords[pA]) vB = np.array(coords[pB]) total_len = float(np.linalg.norm(vB - vA)) if len_ap is not None and total_len > 1e-4: t = len_ap / total_len coords[pP] = list(vA + t * (vB - vA))