import re import logging from typing import List, Tuple, Dict, Any from .models import Point, Constraint logger = logging.getLogger(__name__) class DSLParser: def parse(self, text: str) -> Tuple[List[Point], List[Constraint], bool]: """Parse DSL text into points and constraints. Stateless per call.""" points: Dict[str, Point] = {} explicit_point_ids: List[str] = [] constraints: List[Constraint] = [] polygon_order: List[str] = [] circles: List[Dict[str, Any]] = [] segments: List[List[str]] = [] lines_ext: List[List[str]] = [] rays: List[List[str]] = [] is_3d = False logger.info("==[DSLParser] Parsing DSL input==") logger.debug(f"[DSLParser] Raw DSL:\n{text}") lines = text.strip().split('\n') for line in lines: line = line.strip() if not line or line.startswith('//') or line.startswith('#'): continue # POINT(A) or POINT(A, 0, 0, 5) m = re.match(r'POINT\((\w+)(?:,\s*([\d\.-]+),\s*([\d\.-]+)(?:,\s*([\d\.-]+))?)?\)', line) if m: name = m.group(1) x = float(m.group(2)) if m.group(2) else None y = float(m.group(3)) if m.group(3) else None z = float(m.group(4)) if m.group(4) else None # z=0 with x,y is still the xy-plane; only treat as 3D when z is meaningfully non-zero. # Otherwise POINT(A,0,0,0) incorrectly forced is_3d and broke 2D engine paths. if z is not None and abs(z) > 1e-9: is_3d = True points[name] = Point(id=name, x=x, y=y, z=z) if name not in explicit_point_ids: explicit_point_ids.append(name) logger.debug(f"[DSLParser] + POINT: {name} ({x}, {y}, {z})") continue # LENGTH(AB, 5) m = re.match(r'LENGTH\((\w+),\s*([\d\.]+)\)', line) if m: target, value = m.group(1), float(m.group(2)) pts = [target[i:i+1] for i in range(len(target))] constraints.append(Constraint(type='length', targets=pts, value=value)) logger.debug(f"[DSLParser] + LENGTH: {pts} = {value}") continue # ANGLE(A, 90) or ANGLE(A, 90deg) m = re.match(r'ANGLE\((\w+),\s*([\d\.]+)(?:deg)?\)', line) if m: target, value = m.group(1), float(m.group(2)) constraints.append(Constraint(type='angle', targets=[target], value=value)) logger.debug(f"[DSLParser] + ANGLE: vertex={target}, degrees={value}") continue # PARALLEL(AB, CD) m = re.match(r'PARALLEL\((\w+),\s*(\w+)\)', line) if m: seg1, seg2 = m.group(1), m.group(2) constraints.append(Constraint(type='parallel', targets=list(seg1) + list(seg2), value=0)) logger.debug(f"[DSLParser] + PARALLEL: {seg1} || {seg2}") continue # PERPENDICULAR(AB, CD) m = re.match(r'PERPENDICULAR\((\w+),\s*(\w+)\)', line) if m: seg1, seg2 = m.group(1), m.group(2) constraints.append(Constraint(type='perpendicular', targets=list(seg1) + list(seg2), value=0)) logger.debug(f"[DSLParser] + PERPENDICULAR: {seg1} _|_ {seg2}") continue # MIDPOINT(M, AB) — M is midpoint of AB m = re.match(r'MIDPOINT\((\w+),\s*(\w+)\)', line) if m: mid, seg = m.group(1), m.group(2) if mid not in points: points[mid] = Point(id=mid) pts = [mid] + [seg[i:i+1] for i in range(len(seg))] constraints.append(Constraint(type='midpoint', targets=pts, value=0)) logger.debug(f"[DSLParser] + MIDPOINT: {mid} = mid({seg})") continue # SECTION(E, A, C, 0.66) — E lies on AC s.t. AE = 0.66 * AC m = re.match(r'SECTION\((\w+),\s*(\w+),\s*(\w+),\s*([\d\.-]+)\)', line) if m: target, p1, p2, k = m.group(1), m.group(2), m.group(3), float(m.group(4)) if target not in points: points[target] = Point(id=target) constraints.append(Constraint(type='section', targets=[target, p1, p2], value=k)) logger.debug(f"[DSLParser] + SECTION: {target} = {p1} + {k}({p2}-{p1})") continue # CIRCLE(O, r) m = re.match(r'CIRCLE\((\w+),\s*([\d\.]+)\)', line) if m: center, radius = m.group(1), float(m.group(2)) if center not in points: points[center] = Point(id=center) constraints.append(Constraint(type='circle', targets=[center], value=radius)) circles.append({"center": center, "radius": radius}) logger.debug(f"[DSLParser] + CIRCLE: center={center}, r={radius}") continue # POLYGON_ORDER(A, B, C, D) — thứ tự nối điểm để vẽ đa giác m = re.match(r'POLYGON_ORDER\(([^)]+)\)', line) if m: polygon_order = [p.strip() for p in m.group(1).split(',')] logger.debug(f"[DSLParser] + POLYGON_ORDER: {polygon_order}") continue # SEGMENT(M, N) — đoạn thẳng phụ cần vẽ m = re.match(r'SEGMENT\((\w+),\s*(\w+)\)', line) if m: p1, p2 = m.group(1), m.group(2) segments.append([p1, p2]) constraints.append(Constraint(type='segment', targets=[p1, p2], value=0)) logger.debug(f"[DSLParser] + SEGMENT: {p1}—{p2}") continue # LINE(A, B) — infinite line m = re.match(r'LINE\((\w+),\s*(\w+)\)', line) if m: p1, p2 = m.group(1), m.group(2) lines_ext.append([p1, p2]) constraints.append(Constraint(type='line', targets=[p1, p2], value=0)) logger.debug(f"[DSLParser] + LINE: {p1}-{p2}") continue # RAY(A, B) — ray AB starting at A m = re.match(r'RAY\((\w+),\s*(\w+)\)', line) if m: p1, p2 = m.group(1), m.group(2) rays.append([p1, p2]) constraints.append(Constraint(type='ray', targets=[p1, p2], value=0)) logger.debug(f"[DSLParser] + RAY: {p1}->{p2}") continue # TRIANGLE(ABC) / PYRAMID(S_ABCD) / PRISM(ABC_DEF) m = re.match(r'(TRIANGLE|PYRAMID|PRISM)\(([^)]+)\)', line) if m: pt_type = m.group(1) targets = m.group(2) if pt_type in ["PYRAMID", "PRISM"]: is_3d = True if pt_type == "TRIANGLE": if not polygon_order: polygon_order = list(targets) elif pt_type == "PYRAMID": # S_ABCD -> S is apex, ABCD is base if "_" in targets: apex, base = targets.split("_") # Add segments from apex to all base points for p in base: segments.append([apex, p]) constraints.append(Constraint(type='segment', targets=[apex, p], value=0)) if not polygon_order: polygon_order = list(base) elif pt_type == "PRISM": # ABC_DEF -> two bases if "_" in targets: b1, b2 = targets.split("_") for p1, p2 in zip(b1, b2): segments.append([p1, p2]) constraints.append(Constraint(type='segment', targets=[p1, p2], value=0)) logger.debug(f"[DSLParser] + {pt_type}: {targets}") continue # SPHERE(O, r) m = re.match(r'SPHERE\((\w+),\s*([\d\.]+)\)', line) if m: is_3d = True center, radius = m.group(1), float(m.group(2)) if center not in points: points[center] = Point(id=center) constraints.append(Constraint(type='sphere', targets=[center], value=radius)) logger.debug(f"[DSLParser] + SPHERE: center={center}, r={radius}") continue logger.warning(f"[DSLParser] ? Unrecognized DSL line: '{line}'") logger.info( "[DSLParser] Parsed %d points, %d constraints, is_3d=%s.", len(points), len(constraints), is_3d, ) # Safety sweep: Ensure all points referenced in constraints actually exist in the points dictionary for c in constraints: for pid in c.targets: # Some targets might be values or comma-separated strings (handled elsewhere), # but most are single-character point IDs. if isinstance(pid, str) and len(pid) == 1 and pid not in points: points[pid] = Point(id=pid) logger.debug(f"[DSLParser] ! Auto-declared missing point from constraint: {pid}") # Attach metadata to a synthetic constraint for downstream use if polygon_order: constraints.append(Constraint(type='polygon_order', targets=polygon_order, value=0)) elif explicit_point_ids: # Re-use polygon_order as a carrier for explicit points IF no real order was specified constraints.append(Constraint(type='explicit_points', targets=explicit_point_ids, value=0)) # Add auxiliary metadata for lines and rays if lines_ext: constraints.append(Constraint(type='lines_metadata', targets=[",".join(l) for l in lines_ext], value=0)) if rays: constraints.append(Constraint(type='rays_metadata', targets=[",".join(l) for l in rays], value=0)) return list(points.values()), constraints, is_3d