"""Visualization Planner & Topology Derivation Engine. Derives complete topological models (vertices, edges, faces, solids, auxiliary constructions, visibility, and drawing phases) from semantic geometry definitions and solved coordinates. """ 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 .vis_graph import ( EdgeStyle, EntityKind, ImportanceTier, VisAuxiliaryConstruction, VisEdge, VisFace, VisSolid, VisVertex, VisualizationGraph, ) logger = logging.getLogger(__name__) class VisualizationPlanner: """ Constructs a complete, minimal sufficient Visualization Graph from mathematical geometry results and semantic DSL constraints. """ def plan( self, coords: Dict[str, List[float]], constraints: List[Constraint], solids_meta: List[Dict[str, Any]], circles_meta: List[Dict[str, Any]], polygon_order: List[str], segments_meta: List[List[str]], lines_meta: List[List[str]], rays_meta: List[List[str]], pt_list: List[Point], is_3d: bool = False, ) -> VisualizationGraph: graph = VisualizationGraph(is_3d=is_3d) # --------------------------------------------------------------------- # 1. Register All Known Points as Vertices # --------------------------------------------------------------------- all_ids = [p.id for p in pt_list] for pid in all_ids: if pid in coords: graph.add_vertex( pid=pid, coords=coords[pid], role="vertex", tier=ImportanceTier.REQUIRED, kind=EntityKind.PRIMARY, ) # --------------------------------------------------------------------- # 2. Derive Standard 3D Solid Topologies (Vertices, Edges, Faces) # --------------------------------------------------------------------- for solid in solids_meta: s_type = solid.get("type", "solid") s_id = f"{s_type}_{'_'.join(solid.get('points', []))}" if solid.get("points") else f"{s_type}_{len(graph.solids)}" if s_type == "pyramid": apex = solid.get("apex") base = solid.get("base", []) if apex and len(base) >= 3: s_id = f"pyramid_{apex}_{''.join(base)}" # Mark apex role if apex in graph.vertices: graph.vertices[apex].role = "apex" pyramid_edges: List[str] = [] pyramid_faces: List[str] = [] # Base edges (cyclic) for i in range(len(base)): p1 = base[i] p2 = base[(i + 1) % len(base)] e = graph.add_edge( p1=p1, p2=p2, role="base_edge", tier=ImportanceTier.REQUIRED, kind=EntityKind.PRIMARY, parent_solid=s_id, ) pyramid_edges.append(e.id) # Lateral edges (apex -> base) for bp in base: e = graph.add_edge( p1=apex, p2=bp, role="lateral_edge", tier=ImportanceTier.REQUIRED, kind=EntityKind.PRIMARY, parent_solid=s_id, ) pyramid_edges.append(e.id) # Base face f_base = graph.add_face( vertices=base, role="base_face", tier=ImportanceTier.HELPFUL, parent_solid=s_id, opacity=0.15, ) pyramid_faces.append(f_base.id) # Lateral faces for i in range(len(base)): p1 = base[i] p2 = base[(i + 1) % len(base)] f_lat = graph.add_face( vertices=[apex, p1, p2], role="lateral_face", tier=ImportanceTier.HELPFUL, parent_solid=s_id, opacity=0.25, ) pyramid_faces.append(f_lat.id) graph.solids[s_id] = VisSolid( id=s_id, type="pyramid", vertices=base + [apex], edges=pyramid_edges, faces=pyramid_faces, apex=apex, base_vertices=base, ) elif s_type in ("prism", "cube", "cuboid", "frustum"): b1 = solid.get("base1", []) b2 = solid.get("base2", []) if len(b1) >= 3 and len(b2) >= 3 and len(b1) == len(b2): s_id = f"{s_type}_{''.join(b1)}_{''.join(b2)}" prism_edges: List[str] = [] prism_faces: List[str] = [] # Base 1 cyclic edges for i in range(len(b1)): e = graph.add_edge( p1=b1[i], p2=b1[(i + 1) % len(b1)], role="base_edge", tier=ImportanceTier.REQUIRED, parent_solid=s_id, ) prism_edges.append(e.id) # Base 2 cyclic edges for i in range(len(b2)): e = graph.add_edge( p1=b2[i], p2=b2[(i + 1) % len(b2)], role="top_edge", tier=ImportanceTier.REQUIRED, parent_solid=s_id, ) prism_edges.append(e.id) # Lateral edges for p1, p2 in zip(b1, b2): e = graph.add_edge( p1=p1, p2=p2, role="lateral_edge", tier=ImportanceTier.REQUIRED, parent_solid=s_id, ) prism_edges.append(e.id) # Base 1 face f1 = graph.add_face( vertices=b1, role="base_face", tier=ImportanceTier.HELPFUL, parent_solid=s_id, opacity=0.15, ) prism_faces.append(f1.id) # Base 2 face f2 = graph.add_face( vertices=b2, role="top_face", tier=ImportanceTier.HELPFUL, parent_solid=s_id, opacity=0.15, ) prism_faces.append(f2.id) # Lateral faces for i in range(len(b1)): i_next = (i + 1) % len(b1) f_lat = graph.add_face( vertices=[b1[i], b1[i_next], b2[i_next], b2[i]], role="lateral_face", tier=ImportanceTier.HELPFUL, parent_solid=s_id, opacity=0.25, ) prism_faces.append(f_lat.id) graph.solids[s_id] = VisSolid( id=s_id, type=s_type, vertices=b1 + b2, edges=prism_edges, faces=prism_faces, base_vertices=b1, top_vertices=b2, ) elif s_type == "tetrahedron": pts = solid.get("points", []) if len(pts) >= 4: s_id = f"tetrahedron_{''.join(pts[:4])}" tet_edges: List[str] = [] tet_faces: List[str] = [] # All 6 edges for i in range(4): for j in range(i + 1, 4): e = graph.add_edge( p1=pts[i], p2=pts[j], role="edge", tier=ImportanceTier.REQUIRED, parent_solid=s_id, ) tet_edges.append(e.id) # 4 faces f_defs = [ [pts[1], pts[2], pts[3]], [pts[0], pts[1], pts[2]], [pts[0], pts[2], pts[3]], [pts[0], pts[3], pts[1]], ] for fv in f_defs: f = graph.add_face( vertices=fv, role="lateral_face", tier=ImportanceTier.HELPFUL, parent_solid=s_id, opacity=0.2, ) tet_faces.append(f.id) graph.solids[s_id] = VisSolid( id=s_id, type="tetrahedron", vertices=pts[:4], edges=tet_edges, faces=tet_faces, base_vertices=pts[1:4], ) # --------------------------------------------------------------------- # 3. Derive 2D Polygon Perimeter Edges & Faces # --------------------------------------------------------------------- if not is_3d: poly_pts = polygon_order if polygon_order else all_ids[:4] if len(poly_pts) >= 3: for i in range(len(poly_pts)): p1 = poly_pts[i] p2 = poly_pts[(i + 1) % len(poly_pts)] graph.add_edge( p1=p1, p2=p2, role="polygon_edge", tier=ImportanceTier.REQUIRED, kind=EntityKind.PRIMARY, ) graph.add_face( vertices=poly_pts, role="polygon_face", tier=ImportanceTier.HELPFUL, kind=EntityKind.PRIMARY, opacity=0.1, ) # --------------------------------------------------------------------- # 4. Add Explicit Segments from DSL # --------------------------------------------------------------------- for seg in segments_meta: if len(seg) == 2: p1, p2 = seg[0], seg[1] graph.add_edge( p1=p1, p2=p2, role="segment", tier=ImportanceTier.REQUIRED, kind=EntityKind.PRIMARY, ) # --------------------------------------------------------------------- # 5. Derive Auxiliary Constructions & Solution Entities (P0 / P1) # --------------------------------------------------------------------- for c in constraints: c_type = c.type targets = [t.strip() for t in c.targets if isinstance(t, str)] # ------------------------------------------------------------- # HEIGHT / ALTITUDE: HEIGHT(S, O, ABCD) # ------------------------------------------------------------- if c_type in ("height", "altitude") and len(targets) >= 2: s_apex = targets[0] o_foot = targets[1] base_pts = targets[2:] if o_foot in graph.vertices: graph.vertices[o_foot].role = "foot" graph.vertices[o_foot].kind = EntityKind.AUXILIARY elif o_foot in coords: graph.add_vertex(o_foot, coords[o_foot], role="foot", kind=EntityKind.AUXILIARY) # Add Altitude Edge SO (Dashed in 3D interior) edge_so = graph.add_edge( p1=s_apex, p2=o_foot, role="altitude", tier=ImportanceTier.REQUIRED, kind=EntityKind.AUXILIARY, style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, ) # Ground the foot O: if base is square/rectangle, add diagonals AC & BD created_diags = [] if len(base_pts) >= 4: e_ac = graph.add_edge( p1=base_pts[0], p2=base_pts[2], role="diagonal", tier=ImportanceTier.HELPFUL, kind=EntityKind.AUXILIARY, style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, ) e_bd = graph.add_edge( p1=base_pts[1], p2=base_pts[3], role="diagonal", tier=ImportanceTier.HELPFUL, kind=EntityKind.AUXILIARY, style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, ) created_diags.extend([e_ac.id, e_bd.id]) elif len(base_pts) == 3: # Triangular base: add median / altitude on base e_base_aux = graph.add_edge( p1=base_pts[0], p2=o_foot, role="projection", tier=ImportanceTier.HELPFUL, kind=EntityKind.AUXILIARY, style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, ) created_diags.append(e_base_aux.id) graph.auxiliary.append( VisAuxiliaryConstruction( id=f"height_{s_apex}_{o_foot}", type="height", source_entity=s_apex, target_entity=o_foot, created_vertices=[o_foot], created_edges=[edge_so.id] + created_diags, perpendicular_marks=[{"vertex": o_foot, "lines": [s_apex, base_pts[0] if base_pts else o_foot]}], tier=ImportanceTier.REQUIRED, ) ) # ------------------------------------------------------------- # FOOT OF PERPENDICULAR: FOOT(H, P, AB) # ------------------------------------------------------------- elif c_type in ("foot", "foot_perp") and len(targets) >= 3: pH, pP = targets[0], targets[1] pA = targets[2] pB = targets[3] if len(targets) > 3 else "B" if pH in graph.vertices: graph.vertices[pH].role = "foot" graph.vertices[pH].kind = EntityKind.AUXILIARY elif pH in coords: graph.add_vertex(pH, coords[pH], role="foot", kind=EntityKind.AUXILIARY) e_ph = graph.add_edge( p1=pP, p2=pH, role="projection", tier=ImportanceTier.REQUIRED, kind=EntityKind.AUXILIARY, style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, ) graph.auxiliary.append( VisAuxiliaryConstruction( id=f"foot_{pH}_{pP}", type="foot", source_entity=pP, target_entity=pH, created_vertices=[pH], created_edges=[e_ph.id], perpendicular_marks=[{"vertex": pH, "lines": [pP, pA]}], tier=ImportanceTier.REQUIRED, ) ) # ------------------------------------------------------------- # MEDIAN: MEDIAN(A, M, BC) # ------------------------------------------------------------- elif c_type == "median" and len(targets) >= 2: pA = targets[0] pM = targets[1] if pM in graph.vertices: graph.vertices[pM].role = "midpoint" graph.vertices[pM].kind = EntityKind.AUXILIARY elif pM in coords: graph.add_vertex(pM, coords[pM], role="midpoint", kind=EntityKind.AUXILIARY) e_am = graph.add_edge( p1=pA, p2=pM, role="median", tier=ImportanceTier.REQUIRED, kind=EntityKind.AUXILIARY, style=EdgeStyle.SOLID, ) graph.auxiliary.append( VisAuxiliaryConstruction( id=f"median_{pA}_{pM}", type="median", source_entity=pA, target_entity=pM, created_vertices=[pM], created_edges=[e_am.id], tier=ImportanceTier.REQUIRED, ) ) # ------------------------------------------------------------- # BISECTOR: BISECTOR(A, D, BC) # ------------------------------------------------------------- elif c_type == "bisector" and len(targets) >= 2: pA = targets[0] pD = targets[1] if pD in graph.vertices: graph.vertices[pD].role = "bisector_point" graph.vertices[pD].kind = EntityKind.AUXILIARY elif pD in coords: graph.add_vertex(pD, coords[pD], role="bisector_point", kind=EntityKind.AUXILIARY) e_ad = graph.add_edge( p1=pA, p2=pD, role="bisector", tier=ImportanceTier.REQUIRED, kind=EntityKind.AUXILIARY, style=EdgeStyle.SOLID, ) graph.auxiliary.append( VisAuxiliaryConstruction( id=f"bisector_{pA}_{pD}", type="bisector", source_entity=pA, target_entity=pD, created_vertices=[pD], created_edges=[e_ad.id], tier=ImportanceTier.REQUIRED, ) ) # ------------------------------------------------------------- # MIDPOINT: MIDPOINT(M, AB) # ------------------------------------------------------------- elif c_type == "midpoint" and len(targets) == 3: pM, pA, pB = targets[0], targets[1], targets[2] if pM in graph.vertices: graph.vertices[pM].role = "midpoint" graph.vertices[pM].kind = EntityKind.AUXILIARY elif pM in coords: graph.add_vertex(pM, coords[pM], role="midpoint", kind=EntityKind.AUXILIARY) # ------------------------------------------------------------- # CENTER: CENTER(O, ABCD) # ------------------------------------------------------------- elif c_type in ("center", "centroid") and len(targets) >= 3: pO = targets[0] poly_pts = targets[1:] if pO in graph.vertices: graph.vertices[pO].role = "center" graph.vertices[pO].kind = EntityKind.AUXILIARY elif pO in coords: graph.add_vertex(pO, coords[pO], role="center", kind=EntityKind.AUXILIARY) # If 4 base points, draw diagonals to visually anchor center if len(poly_pts) >= 4: graph.add_edge( p1=poly_pts[0], p2=poly_pts[2], role="diagonal", tier=ImportanceTier.HELPFUL, kind=EntityKind.AUXILIARY, style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, ) graph.add_edge( p1=poly_pts[1], p2=poly_pts[3], role="diagonal", tier=ImportanceTier.HELPFUL, kind=EntityKind.AUXILIARY, style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, ) # ------------------------------------------------------------- # PERPENDICULAR TO PLANE: PERPENDICULAR_PLANE(SA, ABC) # ------------------------------------------------------------- elif c_type in ("perpendicular_plane", "perp_plane"): # E.g. targets = ["S", "A", "A", "B", "C"] or ["SA", "ABC"] line_pts: List[str] = [] plane_pts: List[str] = [] if len(targets) == 2: line_pts = list(targets[0].strip()) plane_pts = list(targets[1].strip()) elif len(targets) >= 4: line_pts = targets[:2] plane_pts = targets[2:] if len(line_pts) >= 2: p_apex, p_foot = line_pts[0], line_pts[1] # Check if p_foot is in plane if p_foot in graph.vertices: graph.vertices[p_foot].role = "foot" if p_apex in graph.vertices: graph.vertices[p_apex].role = "apex" # Edge from apex to foot is an altitude edge_alt = graph.add_edge( p1=p_apex, p2=p_foot, role="altitude", tier=ImportanceTier.REQUIRED, kind=EntityKind.PRIMARY, style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, is_hidden=is_3d, ) # Add perpendicular marks between (p_apex -> p_foot) and base edges incident to p_foot base_neighbors = [p for p in plane_pts if p != p_foot] for b_pt in base_neighbors[:2]: p_mark = {"vertex": p_foot, "lines": [p_apex, b_pt]} graph.perpendicular_marks.append(p_mark) graph.auxiliary.append( VisAuxiliaryConstruction( id=f"perp_plane_{p_apex}_{p_foot}", type="height", source_entity=p_apex, target_entity=p_foot, created_vertices=[p_foot], created_edges=[edge_alt.id], perpendicular_marks=[{"vertex": p_foot, "lines": [p_apex, b]} for b in base_neighbors[:2]], tier=ImportanceTier.REQUIRED, ) ) # ------------------------------------------------------------- # PERPENDICULAR / RIGHT ANGLE: PERPENDICULAR(AB, BC) or ANGLE(B, 90) # ------------------------------------------------------------- elif c_type in ("perpendicular", "perp", "right_angle"): if len(targets) == 4: p1, p2, p3, p4 = targets common = set([p1, p2]).intersection([p3, p4]) if common: v = common.pop() l1 = p2 if p1 == v else p1 l2 = p4 if p3 == v else p3 graph.perpendicular_marks.append({"vertex": v, "lines": [l1, l2]}) else: graph.perpendicular_marks.append({"vertex": p2, "lines": [p1, p4]}) elif len(targets) == 3: graph.perpendicular_marks.append({"vertex": targets[1], "lines": [targets[0], targets[2]]}) # ------------------------------------------------------------- # ANGLE: ANGLE(B, 90) or ANGLE(A, B, C, 60) or ANGLE(B, 60) # ------------------------------------------------------------- elif c_type == "angle": deg_val = getattr(c, "value", None) if len(targets) == 1: v_label = targets[0] # Find neighbors in edges adj = [] for e in graph.edges.values(): if e.source == v_label: adj.append(e.target) elif e.target == v_label: adj.append(e.source) if len(adj) >= 2: if deg_val == 90 or (deg_val is not None and abs(deg_val - 90) < 1e-2): graph.perpendicular_marks.append({"vertex": v_label, "lines": [adj[0], adj[1]]}) elif deg_val is not None and deg_val > 0: graph.angle_marks.append({ "vertex": v_label, "lines": [adj[0], adj[1]], "degrees": deg_val, "label": f"{int(deg_val) if deg_val == int(deg_val) else deg_val}°" }) elif len(targets) == 3: p1, v_label, p2 = targets[0], targets[1], targets[2] if deg_val == 90 or (deg_val is not None and abs(deg_val - 90) < 1e-2): graph.perpendicular_marks.append({"vertex": v_label, "lines": [p1, p2]}) elif deg_val is not None and deg_val > 0: graph.angle_marks.append({ "vertex": v_label, "lines": [p1, p2], "degrees": deg_val, "label": f"{int(deg_val) if deg_val == int(deg_val) else deg_val}°" }) # ------------------------------------------------------------- # EQUAL LENGTH / EQUILATERAL: LENGTH_EQUAL(AB, CD) # ------------------------------------------------------------- elif c_type in ("length_equal", "equal_length") and len(targets) >= 4: seg1 = [targets[0], targets[1]] seg2 = [targets[2], targets[3]] graph.equal_ticks.append({"segment": seg1, "ticks": 1}) graph.equal_ticks.append({"segment": seg2, "ticks": 1}) # ------------------------------------------------------------- # PARALLEL: PARALLEL(AB, CD) # ------------------------------------------------------------- elif c_type == "parallel" and len(targets) >= 4: seg1 = [targets[0], targets[1]] seg2 = [targets[2], targets[3]] graph.parallel_marks.append({"segments": [seg1, seg2], "arrows": 1}) # --------------------------------------------------------------------- # 6. Derive 3D Hidden vs Visible Edges (Canonical Perspective) # --------------------------------------------------------------------- if is_3d: # Edges in the rear/interior of 3D solids are classified as DASHED for e_id, edge in graph.edges.items(): v1 = graph.vertices.get(edge.source) v2 = graph.vertices.get(edge.target) if v1 and v2 and len(v1.coordinates) >= 3 and len(v2.coordinates) >= 3: # Interior altitude, projection, or diagonal if edge.role in ("altitude", "projection", "diagonal"): edge.style = EdgeStyle.DASHED edge.is_hidden = True # Rear vertices in standard 3D coordinate system (A or D near y=0, z=0) elif edge.role == "base_edge": # If edge connects to A (when SA is altitude or A is back-left corner) has_sa_alt = any(aux.type == "height" and (aux.target_entity == "A" or aux.source_entity == "A") for aux in graph.auxiliary) if has_sa_alt: if "A" in (v1.id, v2.id) and "S" not in (v1.id, v2.id): edge.style = EdgeStyle.DASHED edge.is_hidden = True else: # Standard rear-left edge (e.g. D connects to A and C in ABCD) if (v1.id == "D" and v2.id in ("A", "C")) or (v1.id == "A" and v2.id == "D"): edge.style = EdgeStyle.DASHED edge.is_hidden = True # --------------------------------------------------------------------- # 7. Construct Minimal Sufficient Drawing Phases # --------------------------------------------------------------------- # Phase 1: Base geometry and primary solid edges primary_pts = [vid for vid, v in graph.vertices.items() if v.kind == EntityKind.PRIMARY] primary_edges = [ [e.source, e.target] for e in graph.edges.values() if e.kind == EntityKind.PRIMARY and e.tier == ImportanceTier.REQUIRED ] graph.drawing_phases.append({ "phase": 1, "label": "Hình cơ bản", "points": primary_pts, "segments": primary_edges, }) # Phase 2: Auxiliary constructions (Heights, Medians, Projections, Diagonals) aux_pts = [vid for vid, v in graph.vertices.items() if v.kind != EntityKind.PRIMARY] aux_edges = [ [e.source, e.target] for e in graph.edges.values() if e.kind != EntityKind.PRIMARY or e.role in ("altitude", "projection", "median", "bisector", "diagonal") ] if aux_pts or aux_edges: graph.drawing_phases.append({ "phase": 2, "label": "Đường cao và yếu tố phụ", "points": aux_pts, "segments": aux_edges, }) logger.info( f"[VisualizationPlanner] Planned Visualization Graph: " f"{len(graph.vertices)} vertices, {len(graph.edges)} edges, " f"{len(graph.faces)} faces, {len(graph.solids)} solids, " f"{len(graph.auxiliary)} auxiliary constructions, " f"{len(graph.perpendicular_marks)} right-angle marks, " f"{len(graph.angle_marks)} angle arcs." ) return graph