math-solver-worker / solver /dsl_parser.py
github-actions
Deploy render worker from GitHub Actions
06ca3b1
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