Source code for yapcad.dsl.runtime.builtins

"""
Built-in function registry for DSL interpreter.

Maps DSL function names to yapCAD implementations.

TODO: Add missing 2D curve primitives:
  - ellipse(center, major, minor, rotation?) -> ellipse
  - parabola(vertex, focus) -> parabola
  - hyperbola(center, a, b) -> hyperbola
  Types are defined in types.py but constructors not yet implemented.
  See yapCAD core: yapcad.geom.Ellipse, yapcad.geom.conic_arc

TODO: Move involute_gear to yapcad.stdlib.gears package
  High-level parametric constructions (gears, fasteners) should be
  DSL modules in a standard library, not hardcoded builtins. This allows:
  - Multiple implementations (figgear vs other algorithms)
  - User customization and extension
  - Proper namespace management (use gears.involute)

TODO: Add fastener primitives via yapcad.stdlib.fasteners package
  - hex_bolt(standard, size, length) using existing fastener catalog
  - hex_nut(standard, size)
  - washer(standard, size)
  - threaded_hole(standard, size, depth, tapped?)
  See: yapcad.contrib.fasteners for existing implementation
"""

from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple
import math

from .values import (
    Value, int_val, float_val, bool_val, string_val, list_val,
    point_val, vector_val, transform_val, solid_val, region2d_val,
    surface_val, shell_val, path3d_val, wrap_value, unwrap_value, unwrap_values,
)
from ..types import (
    Type, ListType,
    INT, FLOAT, BOOL, STRING, UNKNOWN,
    POINT, POINT2D, POINT3D, VECTOR, VECTOR2D, VECTOR3D, TRANSFORM,
    SOLID, REGION2D, SURFACE, SHELL,
    LINE_SEGMENT, ARC, CIRCLE, ELLIPSE, BEZIER, NURBS, CATMULLROM,
    PATH2D, PATH3D, PROFILE2D,
)
from ..symbols import FunctionSignature


def _make_sig(name: str, param_types: List[Type], return_type: Type,
              is_variadic: bool = False) -> FunctionSignature:
    """
    Helper to create FunctionSignature from a simple list of parameter types.

    Generates synthetic parameter names (arg0, arg1, ...) for builtins.
    """
    params = [(f"arg{i}", t, None) for i, t in enumerate(param_types)]
    return FunctionSignature(
        name=name,
        params=params,
        return_type=return_type,
        is_variadic=is_variadic,
    )


[docs] @dataclass class BuiltinFunction: """ A built-in function with its implementation and type signature. """ name: str signature: FunctionSignature implementation: Callable[..., Value] doc: str = ""
[docs] class BuiltinRegistry: """ Registry of all built-in functions. Functions are registered by name and can be looked up for execution. """ def __init__(self): self._functions: Dict[str, BuiltinFunction] = {} self._methods: Dict[Tuple[str, str], BuiltinFunction] = {} # (type_name, method_name) self._register_all()
[docs] def get_function(self, name: str) -> Optional[BuiltinFunction]: """Look up a function by name.""" return self._functions.get(name)
[docs] def get_method(self, type_name: str, method_name: str) -> Optional[BuiltinFunction]: """Look up a method by type and method name.""" return self._methods.get((type_name, method_name))
[docs] def register(self, func: BuiltinFunction) -> None: """Register a function.""" self._functions[func.name] = func
[docs] def register_method(self, type_name: str, func: BuiltinFunction) -> None: """Register a method for a specific type.""" self._methods[(type_name, func.name)] = func
def _register_all(self) -> None: """Register all built-in functions.""" self._register_math_functions() self._register_point_vector_functions() self._register_transform_functions() self._register_curve_functions() self._register_region_functions() self._register_solid_functions() self._register_boolean_functions() self._register_query_functions() self._register_utility_functions() # --- Math Functions --- def _register_math_functions(self) -> None: """Register mathematical functions.""" def _sin(x: Value) -> Value: return float_val(math.sin(x.data)) def _cos(x: Value) -> Value: return float_val(math.cos(x.data)) def _tan(x: Value) -> Value: return float_val(math.tan(x.data)) def _asin(x: Value) -> Value: return float_val(math.asin(x.data)) def _acos(x: Value) -> Value: return float_val(math.acos(x.data)) def _atan(x: Value) -> Value: return float_val(math.atan(x.data)) def _atan2(y: Value, x: Value) -> Value: return float_val(math.atan2(y.data, x.data)) def _sqrt(x: Value) -> Value: return float_val(math.sqrt(x.data)) def _abs(x: Value) -> Value: if x.type == INT: return int_val(abs(x.data)) return float_val(abs(x.data)) def _floor(x: Value) -> Value: return int_val(math.floor(x.data)) def _ceil(x: Value) -> Value: return int_val(math.ceil(x.data)) def _round(x: Value) -> Value: return int_val(round(x.data)) def _min(*args: Value) -> Value: values = [a.data for a in args] result = min(values) if all(a.type == INT for a in args): return int_val(result) return float_val(result) def _max(*args: Value) -> Value: values = [a.data for a in args] result = max(values) if all(a.type == INT for a in args): return int_val(result) return float_val(result) def _pow(base: Value, exp: Value) -> Value: return float_val(math.pow(base.data, exp.data)) def _radians(deg: Value) -> Value: return float_val(math.radians(deg.data)) def _degrees(rad: Value) -> Value: return float_val(math.degrees(rad.data)) def _pi() -> Value: return float_val(math.pi) def _tau() -> Value: return float_val(math.tau) # Register math functions math_funcs = [ ("sin", [FLOAT], FLOAT, _sin), ("cos", [FLOAT], FLOAT, _cos), ("tan", [FLOAT], FLOAT, _tan), ("asin", [FLOAT], FLOAT, _asin), ("acos", [FLOAT], FLOAT, _acos), ("atan", [FLOAT], FLOAT, _atan), ("atan2", [FLOAT, FLOAT], FLOAT, _atan2), ("sqrt", [FLOAT], FLOAT, _sqrt), ("abs", [FLOAT], FLOAT, _abs), ("floor", [FLOAT], INT, _floor), ("ceil", [FLOAT], INT, _ceil), ("round", [FLOAT], INT, _round), ("pow", [FLOAT, FLOAT], FLOAT, _pow), ("radians", [FLOAT], FLOAT, _radians), ("degrees", [FLOAT], FLOAT, _degrees), ] for name, param_types, return_type, impl in math_funcs: sig = _make_sig(name, param_types, return_type) self.register(BuiltinFunction(name, sig, impl)) # Variadic min/max self.register(BuiltinFunction( "min", _make_sig("min", [FLOAT], FLOAT, is_variadic=True), _min, )) self.register(BuiltinFunction( "max", _make_sig("max", [FLOAT], FLOAT, is_variadic=True), _max, )) # Constants (zero-arg functions) self.register(BuiltinFunction( "pi", _make_sig("pi", [], FLOAT), _pi, )) self.register(BuiltinFunction( "tau", _make_sig("tau", [], FLOAT), _tau, )) # --- Point/Vector Functions --- def _register_point_vector_functions(self) -> None: """Register point and vector construction functions.""" def _point(*args: Value) -> Value: """Create a point (2D or 3D based on argument count).""" coords = [a.data for a in args] if len(coords) == 2: # 2D point - yapCAD uses [x, y, 0, 1] homogeneous coords from yapcad.geom import point return point_val(point(coords[0], coords[1]), is_2d=True) else: # 3D point from yapcad.geom import point return point_val(point(coords[0], coords[1], coords[2]), is_2d=False) def _vector(*args: Value) -> Value: """Create a vector (2D or 3D based on argument count).""" coords = [a.data for a in args] if len(coords) == 2: from yapcad.geom import vect return vector_val(vect(coords[0], coords[1]), is_2d=True) else: from yapcad.geom import vect return vector_val(vect(coords[0], coords[1], coords[2]), is_2d=False) def _point2d(x: Value, y: Value) -> Value: """Create a 2D point.""" from yapcad.geom import point return point_val(point(x.data, y.data), is_2d=True) def _vector2d(dx: Value, dy: Value) -> Value: """Create a 2D vector.""" from yapcad.geom import vect return vector_val(vect(dx.data, dy.data), is_2d=True) # Point constructors (variadic to allow 2D or 3D) self.register(BuiltinFunction( "point", _make_sig("point", [FLOAT, FLOAT, FLOAT], POINT3D, is_variadic=True), _point, )) # 2D point constructor (convenience) self.register(BuiltinFunction( "point2d", _make_sig("point2d", [FLOAT, FLOAT], POINT2D), _point2d, )) # Vector constructors (variadic to allow 2D or 3D) self.register(BuiltinFunction( "vector", _make_sig("vector", [FLOAT, FLOAT, FLOAT], VECTOR3D, is_variadic=True), _vector, )) # 2D vector constructor (convenience) self.register(BuiltinFunction( "vector2d", _make_sig("vector2d", [FLOAT, FLOAT], VECTOR2D), _vector2d, )) # --- Transform Functions --- def _register_transform_functions(self) -> None: """Register transform construction and solid transformation functions.""" def _translate_xform(v: Value) -> Value: """Create a translation transform.""" from yapcad.xform import Translation return transform_val(Translation(v.data)) def _rotate_xform(axis: Value, angle: Value) -> Value: """Create a rotation transform.""" from yapcad.xform import Rotation return transform_val(Rotation(axis.data, angle.data)) def _scale_xform(factors: Value) -> Value: """Create a scale transform.""" from yapcad.xform import Scale return transform_val(Scale(factors.data)) def _identity_transform() -> Value: """Create an identity transform.""" from yapcad.xform import Identity return transform_val(Identity()) def _translate(s: Value, x: Value, y: Value, z: Value) -> Value: """Translate a solid by (x, y, z).""" from yapcad.geom3d import translatesolid from yapcad.geom import point delta = point(x.data, y.data, z.data) return solid_val(translatesolid(s.data, delta)) def _rotate(s: Value, x: Value, y: Value, z: Value) -> Value: """Rotate a solid by angles (x, y, z) in degrees around respective axes.""" from yapcad.geom3d import rotatesolid from yapcad.geom import point result = s.data # Apply rotations around each axis in sequence # Note: rotatesolid expects angles in degrees (xform.Rotation converts internally) if abs(x.data) > 1e-10: result = rotatesolid(result, x.data, cent=point(0, 0, 0), axis=point(1, 0, 0)) if abs(y.data) > 1e-10: result = rotatesolid(result, y.data, cent=point(0, 0, 0), axis=point(0, 1, 0)) if abs(z.data) > 1e-10: result = rotatesolid(result, z.data, cent=point(0, 0, 0), axis=point(0, 0, 1)) return solid_val(result) def _scale(s: Value, x: Value, y: Value, z: Value) -> Value: """Scale a solid by factors (x, y, z).""" from yapcad.geom3d import scalesolid return solid_val(scalesolid(s.data, x.data, y.data, z.data)) # Solid transformation functions (most commonly used) self.register(BuiltinFunction( "translate", _make_sig("translate", [SOLID, FLOAT, FLOAT, FLOAT], SOLID), _translate, )) self.register(BuiltinFunction( "rotate", _make_sig("rotate", [SOLID, FLOAT, FLOAT, FLOAT], SOLID), _rotate, )) self.register(BuiltinFunction( "scale", _make_sig("scale", [SOLID, FLOAT, FLOAT, FLOAT], SOLID), _scale, )) # Transform constructors (for advanced use) self.register(BuiltinFunction( "translate_xform", _make_sig("translate_xform", [VECTOR], TRANSFORM), _translate_xform, )) self.register(BuiltinFunction( "rotate_xform", _make_sig("rotate_xform", [VECTOR3D, FLOAT], TRANSFORM), _rotate_xform, )) self.register(BuiltinFunction( "scale_xform", _make_sig("scale_xform", [VECTOR], TRANSFORM), _scale_xform, )) self.register(BuiltinFunction( "identity_transform", _make_sig("identity_transform", [], TRANSFORM), _identity_transform, )) def _apply(t: Value, shape: Value) -> Value: """Apply a transform to a shape (solid, surface, point, etc.).""" from yapcad.geom3d import issolid, issurface, rotatesolid, rotatesurface from yapcad.geom import ispoint, isvect, transform as geom_transform from yapcad.geom import point from copy import deepcopy mat = t.data data = shape.data shape_type = shape.type # Handle solids by applying matrix to each surface if shape_type == SOLID or issolid(data): # Use rotatesolid with the pre-computed matrix # We pass a tiny non-zero angle to avoid early return, but the # actual transformation is done entirely by the provided matrix result = rotatesolid(data, 0.001, cent=point(0, 0, 0), axis=point(0, 0, 1.0), mat=mat) return solid_val(result) # Handle surfaces elif shape_type == SURFACE or issurface(data): result = rotatesurface(data, 0.001, cent=point(0, 0, 0), axis=point(0, 0, 1.0), mat=mat) return surface_val(result) # Handle points/vectors - use matrix multiplication directly elif shape_type in (POINT, POINT2D, POINT3D) or ispoint(data): result = mat.mul(data) return point_val(result) elif shape_type in (VECTOR, VECTOR2D, VECTOR3D) or isvect(data): result = mat.mul(data) return vector_val(result) # For other geometry types, use geom.transform else: try: result = geom_transform(data, mat) return wrap_value(result, shape_type) except (ValueError, NotImplementedError): raise ValueError( f"don't know how to apply transform to {shape_type}" ) # Register type-specific apply functions self.register(BuiltinFunction( "apply", _make_sig("apply", [TRANSFORM, SOLID], SOLID), _apply, )) self.register(BuiltinFunction( "apply_surface", _make_sig("apply_surface", [TRANSFORM, SURFACE], SURFACE), _apply, )) self.register(BuiltinFunction( "apply_point", _make_sig("apply_point", [TRANSFORM, POINT], POINT), _apply, )) self.register(BuiltinFunction( "apply_vector", _make_sig("apply_vector", [TRANSFORM, VECTOR3D], VECTOR3D), _apply, )) # --- Curve Functions --- def _register_curve_functions(self) -> None: """Register curve construction functions.""" def _line(start: Value, end: Value) -> Value: """Create a line segment.""" from yapcad.geom import line return wrap_value(line(start.data, end.data), LINE_SEGMENT) def _arc(center: Value, radius: Value, start_angle: Value, end_angle: Value) -> Value: """Create an arc.""" from yapcad.geom import arc return wrap_value( arc(center.data, radius.data, start_angle.data, end_angle.data), ARC ) def _circle(center: Value, radius: Value) -> Value: """Create a circle.""" from yapcad.geom import arc # Full circle is an arc from 0 to 360 return wrap_value(arc(center.data, radius.data, 0, 360), CIRCLE) # Curve constructors self.register(BuiltinFunction( "line", _make_sig("line", [POINT, POINT], LINE_SEGMENT), _line, )) self.register(BuiltinFunction( "arc", _make_sig("arc", [POINT, FLOAT, FLOAT, FLOAT], ARC), _arc, )) self.register(BuiltinFunction( "circle", _make_sig("circle", [POINT, FLOAT], CIRCLE), _circle, )) # Ellipse constructor def _ellipse(center: Value, semi_major: Value, semi_minor: Value, *args: Value) -> Value: """Create an ellipse curve. Args: center: Center point semi_major: Semi-major axis length semi_minor: Semi-minor axis length rotation: (optional) Rotation of major axis in degrees start: (optional) Start angle in degrees end: (optional) End angle in degrees Returns: An ellipse curve """ from yapcad.geom import ellipse c = center.data a = semi_major.data b = semi_minor.data # Optional parameters rotation = args[0].data if len(args) > 0 else 0.0 start = args[1].data if len(args) > 1 else 0.0 end = args[2].data if len(args) > 2 else 360.0 return wrap_value( ellipse(c, a, b, rotation=rotation, start=start, end=end), ELLIPSE ) self.register(BuiltinFunction( "ellipse", _make_sig("ellipse", [POINT, FLOAT, FLOAT], ELLIPSE, is_variadic=True), _ellipse, )) # Catmull-Rom spline constructor def _catmullrom(points: Value, *args: Value) -> Value: """Create a Catmull-Rom spline curve. Args: points: List of control points closed: (optional) Whether the spline is closed (default false) alpha: (optional) Parameterization factor 0-1 (default 0.5 centripetal) Returns: A Catmull-Rom spline curve """ # Extract control points as proper 4-element points [x, y, z, 1] pts = [] for p in points.data: if hasattr(p, 'data'): coords = list(p.data) else: coords = list(p) # Ensure 4-element point with w=1 if len(coords) == 3: coords.append(1) elif len(coords) < 3: coords.extend([0] * (3 - len(coords))) coords.append(1) pts.append(coords[:4]) # Optional parameters closed = args[0].data if len(args) > 0 else False alpha = args[1].data if len(args) > 1 else 0.5 # Create Catmull-Rom spline structure curve = ['catmullrom', pts, {'closed': closed, 'alpha': alpha}] return wrap_value(curve, CATMULLROM) self.register(BuiltinFunction( "catmullrom", _make_sig("catmullrom", [ListType(POINT)], CATMULLROM, is_variadic=True), _catmullrom, )) # NURBS curve constructor def _nurbs(points: Value, *args: Value) -> Value: """Create a NURBS curve. Args: points: List of control points weights: (optional) List of weights (default all 1.0) degree: (optional) Curve degree (default 3) Returns: A NURBS curve """ # Extract control points as proper 4-element points [x, y, z, 1] pts = [] for p in points.data: if hasattr(p, 'data'): coords = list(p.data) else: coords = list(p) # Ensure 4-element point with w=1 if len(coords) == 3: coords.append(1) elif len(coords) < 3: coords.extend([0] * (3 - len(coords))) coords.append(1) pts.append(coords[:4]) n = len(pts) # Optional parameters if len(args) > 0 and args[0].data is not None: weights = list(args[0].data) else: weights = [1.0] * n degree = int(args[1].data) if len(args) > 1 else 3 # Generate open uniform knot vector # For n control points and degree p, we need n + p + 1 knots num_knots = n + degree + 1 knots = [] for i in range(num_knots): if i <= degree: knots.append(0.0) elif i >= num_knots - degree - 1: knots.append(1.0) else: knots.append((i - degree) / (n - degree)) # Create NURBS structure curve = ['nurbs', pts, {'degree': degree, 'weights': weights, 'knots': knots}] return wrap_value(curve, NURBS) self.register(BuiltinFunction( "nurbs", _make_sig("nurbs", [ListType(POINT)], NURBS, is_variadic=True), _nurbs, )) # Curve sampling functions def _sample_curve(curve: Value, t: Value) -> Value: """Sample a curve at parameter t in [0, 1]. Works with line, arc, ellipse, catmullrom, and nurbs curves. """ from yapcad.geom import point, isline, isarc, isellipse from yapcad.geom import sample as geom_sample, ellipse_sample from yapcad.spline import is_catmullrom, evaluate_catmullrom from yapcad.spline import is_nurbs, evaluate_nurbs data = curve.data u = t.data if is_catmullrom(data): pt = evaluate_catmullrom(data, u) return point_val(pt, is_2d=False) elif is_nurbs(data): pt = evaluate_nurbs(data, u) return point_val(pt, is_2d=False) elif isellipse(data): pt = ellipse_sample(data, u) return point_val(pt, is_2d=False) elif isline(data) or isarc(data): pt = geom_sample(data, u) return point_val(pt, is_2d=False) else: raise ValueError(f"Cannot sample curve of type {type(data)}") def _sample_curve_n(curve: Value, n: Value) -> Value: """Sample a curve at n evenly spaced points. Returns a list of points along the curve. """ from yapcad.geom import point, isline, isarc, isellipse from yapcad.geom import sample as geom_sample, ellipse_sample from yapcad.spline import is_catmullrom, evaluate_catmullrom from yapcad.spline import is_nurbs, evaluate_nurbs data = curve.data num = int(n.data) if num < 2: raise ValueError("n must be at least 2") pts = [] for i in range(num): u = i / (num - 1) if is_catmullrom(data): pt = evaluate_catmullrom(data, u) elif is_nurbs(data): pt = evaluate_nurbs(data, u) elif isellipse(data): pt = ellipse_sample(data, u) elif isline(data) or isarc(data): pt = geom_sample(data, u) else: raise ValueError(f"Cannot sample curve of type {type(data)}") pts.append(point_val(pt, is_2d=False)) return list_val(pts, POINT) def _curve_length(curve: Value) -> Value: """Compute the approximate length of a curve.""" from yapcad.geom import length as geom_length, isline, isarc, isellipse from yapcad.geom import ellipse_length from yapcad.spline import is_catmullrom, sample_catmullrom from yapcad.spline import is_nurbs, sample_nurbs data = curve.data if is_catmullrom(data): # Sample and compute polyline length pts = sample_catmullrom(data, segments_per_span=20) total = 0.0 for i in range(1, len(pts)): dx = pts[i][0] - pts[i-1][0] dy = pts[i][1] - pts[i-1][1] dz = pts[i][2] - pts[i-1][2] total += math.sqrt(dx*dx + dy*dy + dz*dz) return float_val(total) elif is_nurbs(data): pts = sample_nurbs(data, samples=100) total = 0.0 for i in range(1, len(pts)): dx = pts[i][0] - pts[i-1][0] dy = pts[i][1] - pts[i-1][1] dz = pts[i][2] - pts[i-1][2] total += math.sqrt(dx*dx + dy*dy + dz*dz) return float_val(total) elif isellipse(data): return float_val(ellipse_length(data)) elif isline(data) or isarc(data): return float_val(geom_length(data)) else: raise ValueError(f"Cannot compute length for curve of type {type(data)}") self.register(BuiltinFunction( "sample_curve", _make_sig("sample_curve", [UNKNOWN, FLOAT], POINT), _sample_curve, )) self.register(BuiltinFunction( "sample_curve_n", _make_sig("sample_curve_n", [UNKNOWN, INT], ListType(POINT)), _sample_curve_n, )) self.register(BuiltinFunction( "curve_length", _make_sig("curve_length", [UNKNOWN], FLOAT), _curve_length, )) # Path3D constructors for sweep operations def _make_path3d(*segments: Value) -> Value: """Create a path3d from segment dicts. Each segment should be a dict with 'type' and coordinates. """ seg_list = [] for seg in segments: if isinstance(seg.type, ListType): # List of segments seg_list.extend(seg.data) else: seg_list.append(seg.data) return path3d_val({'type': 'path3d', 'segments': seg_list}) def _path3d_line(start: Value, end: Value) -> Value: """Create a line segment for path3d. Args: start: Start point [x, y, z] end: End point [x, y, z] """ s = start.data e = end.data return wrap_value({ 'type': 'line', 'start': [s[0], s[1], s[2] if len(s) > 2 else 0], 'end': [e[0], e[1], e[2] if len(e) > 2 else 0] }, PATH3D) def _path3d_arc(center: Value, start: Value, end: Value, normal: Value) -> Value: """Create an arc segment for path3d. Args: center: Arc center point [x, y, z] start: Arc start point [x, y, z] end: Arc end point [x, y, z] normal: Arc plane normal [nx, ny, nz] """ c = center.data s = start.data e = end.data n = normal.data return wrap_value({ 'type': 'arc', 'center': [c[0], c[1], c[2] if len(c) > 2 else 0], 'start': [s[0], s[1], s[2] if len(s) > 2 else 0], 'end': [e[0], e[1], e[2] if len(e) > 2 else 0], 'normal': [n[0], n[1], n[2] if len(n) > 2 else 0] }, PATH3D) self.register(BuiltinFunction( "make_path3d", _make_sig("make_path3d", [], PATH3D, is_variadic=True), _make_path3d, )) self.register(BuiltinFunction( "path3d_line", _make_sig("path3d_line", [POINT3D, POINT3D], PATH3D), _path3d_line, )) self.register(BuiltinFunction( "path3d_arc", _make_sig("path3d_arc", [POINT3D, POINT3D, POINT3D, VECTOR3D], PATH3D), _path3d_arc, )) # --- Region Functions --- def _register_region_functions(self) -> None: """Register 2D region construction functions.""" def _rectangle(width: Value, height: Value, *args: Value) -> Value: """Create a rectangle region2d.""" w, h = width.data, height.data # Optional center point cx, cy = 0.0, 0.0 if args: center = args[0].data cx, cy = center[0], center[1] # Create rectangle as list of line segments half_w, half_h = w / 2, h / 2 corners = [ (cx - half_w, cy - half_h), (cx + half_w, cy - half_h), (cx + half_w, cy + half_h), (cx - half_w, cy + half_h), ] from yapcad.geom import point, line pts = [point(c[0], c[1]) for c in corners] region = [ line(pts[0], pts[1]), line(pts[1], pts[2]), line(pts[2], pts[3]), line(pts[3], pts[0]), ] return region2d_val(region) def _regular_polygon(n: Value, radius: Value, *args: Value) -> Value: """Create a regular polygon region2d.""" sides = int(n.data) r = radius.data cx, cy = 0.0, 0.0 if args: center = args[0].data cx, cy = center[0], center[1] from yapcad.geom import point, line import math pts = [] for i in range(sides): angle = 2 * math.pi * i / sides pts.append(point(cx + r * math.cos(angle), cy + r * math.sin(angle))) region = [] for i in range(sides): region.append(line(pts[i], pts[(i + 1) % sides])) return region2d_val(region) # Region constructors self.register(BuiltinFunction( "rectangle", _make_sig("rectangle", [FLOAT, FLOAT], REGION2D), _rectangle, )) self.register(BuiltinFunction( "regular_polygon", _make_sig("regular_polygon", [INT, FLOAT], REGION2D), _regular_polygon, )) # Polygon from points def _polygon(points: Value) -> Value: """Create a closed polygon region from a list of points. Args: points: List of 2D points defining the polygon vertices Returns: A closed region2d """ from yapcad.geom import point, line pts = [] for p in points.data: if hasattr(p, 'data'): pts.append(point(p.data[0], p.data[1])) else: pts.append(point(p[0], p[1])) if len(pts) < 3: raise ValueError("polygon requires at least 3 points") # Create closed polygon from line segments region = [] for i in range(len(pts)): region.append(line(pts[i], pts[(i + 1) % len(pts)])) return region2d_val(region) self.register(BuiltinFunction( "polygon", _make_sig("polygon", [ListType(POINT)], REGION2D), _polygon, )) # Disk (filled circle) region def _disk(center: Value, radius: Value, *args: Value) -> Value: """Create a filled circular region (disk). Args: center: Center point radius: Radius of the disk segments: (optional) Number of sides for polygon approximation (default 64) Returns: A region2d approximating a disk """ from yapcad.geom import point, line c = center.data r = radius.data cx, cy = c[0], c[1] segments = int(args[0].data) if args else 64 pts = [] for i in range(segments): angle = 2 * math.pi * i / segments pts.append(point(cx + r * math.cos(angle), cy + r * math.sin(angle))) region = [] for i in range(segments): region.append(line(pts[i], pts[(i + 1) % segments])) return region2d_val(region) self.register(BuiltinFunction( "disk", _make_sig("disk", [POINT, FLOAT], REGION2D, is_variadic=True), _disk, )) # 2D Boolean operations def _union2d(a: Value, b: Value) -> Value: """Boolean union of two 2D regions. Args: a: First region2d b: Second region2d Returns: Union of the two regions as a region2d """ from yapcad.geom_util import combineglist result = combineglist(a.data, b.data, "union") return region2d_val(result) def _difference2d(a: Value, b: Value) -> Value: """Boolean difference of two 2D regions (a minus b). Handles chained difference operations where a may already contain holes from a previous difference operation. Also works around a known issue in combineglist where isinsideXY can fail for points near corners. Args: a: Region to subtract from (may be region with existing holes) b: Region to subtract Returns: Difference (a - b) as a region2d """ from yapcad.geom import (isgeomlist, isline, isarc, bbox, isinsidebbox, sample, intersectXY) from yapcad.geom_util import combineglist a_data = a.data b_data = b.data # Detect if a_data is a "region with holes" structure: # [outer_boundary, hole1, hole2, ...] # where each element is itself a geomlist of primitives def is_region_with_holes(data): """Check if data is a list of geomlists (region with holes).""" if not isinstance(data, list) or len(data) < 2: return False first = data[0] if not isinstance(first, list) or len(first) == 0: return False first_elem = first[0] if isline(first_elem) or isarc(first_elem): return True return False def is_b_inside_a(outer, inner): """Check if inner is completely inside outer using robust method. Uses bounding box check + intersection check to avoid isinsideXY corner issues. """ bbox_outer = bbox(outer) bbox_inner = bbox(inner) if not bbox_outer or not bbox_inner: return False # Inner bbox must be inside outer bbox if not (isinsidebbox(bbox_outer, bbox_inner[0]) and isinsidebbox(bbox_outer, bbox_inner[1])): return False # Check for intersections - if none, inner is either fully inside or outside inter = intersectXY(outer, inner, params=True) if inter is False or (inter[0] == [] and inter[1] == []): # No intersections - inner is fully inside (already passed bbox check) return True return False def do_difference(outer, hole): """Perform difference, handling the case where hole is inside outer.""" result = combineglist(outer, hole, "difference") # Check if combineglist returned outer unchanged (missed the hole) if isgeomlist(result) and not is_region_with_holes(result): # Result is flat geomlist - check if hole was missed if is_b_inside_a(outer, hole): # Hole should have created [outer, hole] but didn't return [outer, hole] return result if is_region_with_holes(a_data): # a_data is [outer, hole1, hole2, ...] outer = a_data[0] existing_holes = a_data[1:] result = do_difference(outer, b_data) if is_region_with_holes(result): new_outer = result[0] new_holes = result[1:] return region2d_val([new_outer] + existing_holes + new_holes) else: if result: return region2d_val([result] + existing_holes) else: return region2d_val([]) else: result = do_difference(a_data, b_data) return region2d_val(result) def _intersection2d(a: Value, b: Value) -> Value: """Boolean intersection of two 2D regions. Args: a: First region2d b: Second region2d Returns: Intersection of the two regions as a region2d """ from yapcad.geom_util import combineglist result = combineglist(a.data, b.data, "intersection") return region2d_val(result) self.register(BuiltinFunction( "union2d", _make_sig("union2d", [REGION2D, REGION2D], REGION2D), _union2d, )) self.register(BuiltinFunction( "difference2d", _make_sig("difference2d", [REGION2D, REGION2D], REGION2D), _difference2d, )) self.register(BuiltinFunction( "intersection2d", _make_sig("intersection2d", [REGION2D, REGION2D], REGION2D), _intersection2d, )) # Path2D and region from curves def _make_path2d(curves: Value) -> Value: """Create a 2D path from a list of curves. Args: curves: List of curves (line, arc, etc.) Returns: A path2d (open path of curves) """ # Path2D is just a geometry list curve_list = [] for c in curves.data: if hasattr(c, 'data'): curve_list.append(c.data) else: curve_list.append(c) return wrap_value(curve_list, PATH2D) def _close_path(path: Value) -> Value: """Close an open path to create a region. Args: path: An open path2d Returns: A closed region2d """ from yapcad.geom import line, point curves = path.data if isinstance(path.data, list) else [path.data] if len(curves) == 0: raise ValueError("Cannot close empty path") # Check if already closed first_curve = curves[0] last_curve = curves[-1] # Get start of first curve and end of last curve from yapcad.geom import isline, isarc def get_endpoints(curve): if isline(curve): return curve[0], curve[1] elif isarc(curve): from yapcad.geom import sample return sample(curve, 0), sample(curve, 1) else: # For other curves, sample endpoints from yapcad.geom import sample return sample(curve, 0), sample(curve, 1) first_start, _ = get_endpoints(first_curve) _, last_end = get_endpoints(last_curve) # Check if endpoints are close enough from yapcad.geom import dist if dist(first_start, last_end) > 1e-6: # Add closing segment closing_line = line(last_end, first_start) curves = list(curves) + [closing_line] return region2d_val(curves) self.register(BuiltinFunction( "make_path2d", _make_sig("make_path2d", [ListType(UNKNOWN)], PATH2D), _make_path2d, )) self.register(BuiltinFunction( "close_path", _make_sig("close_path", [PATH2D], REGION2D), _close_path, )) # Region from sampled spline def _region_from_spline(spline: Value, *args: Value) -> Value: """Convert a spline curve to a closed polygon region. Samples the spline and creates a polygon from the sample points. Useful for creating regions from Catmull-Rom or NURBS curves. Args: spline: A catmullrom or nurbs curve (must be closed or will be auto-closed) segments: (optional) Number of sample points (default 64) Returns: A region2d polygon approximating the spline """ from yapcad.geom import point, line from yapcad.spline import is_catmullrom, sample_catmullrom from yapcad.spline import is_nurbs, sample_nurbs data = spline.data segments = int(args[0].data) if args else 64 if is_catmullrom(data): pts = sample_catmullrom(data, segments_per_span=segments // max(1, len(data[1]) - 1)) elif is_nurbs(data): pts = sample_nurbs(data, samples=segments) else: raise ValueError("region_from_spline requires a catmullrom or nurbs curve") if len(pts) < 3: raise ValueError("Spline produced too few sample points") # Create polygon from sample points region = [] for i in range(len(pts)): p1 = point(pts[i][0], pts[i][1]) p2 = point(pts[(i + 1) % len(pts)][0], pts[(i + 1) % len(pts)][1]) region.append(line(p1, p2)) return region2d_val(region) self.register(BuiltinFunction( "region_from_spline", _make_sig("region_from_spline", [UNKNOWN], REGION2D, is_variadic=True), _region_from_spline, )) # --- Solid Functions --- def _register_solid_functions(self) -> None: """Register 3D solid construction functions.""" def _box(width: Value, depth: Value, height: Value) -> Value: """Create a box solid (rectangular prism).""" from yapcad.geom3d_util import prism return solid_val(prism(width.data, depth.data, height.data)) def _cylinder(radius: Value, height: Value) -> Value: """Create a solid cylinder using conic with equal radii.""" from yapcad.geom3d_util import conic r = radius.data h = height.data # conic(base_radius, top_radius, height) - equal radii makes a cylinder return solid_val(conic(r, r, h)) def _sphere(radius: Value) -> Value: """Create a sphere solid.""" from yapcad.geom3d_util import sphere return solid_val(sphere(radius.data)) def _cone(radius1: Value, radius2: Value, height: Value) -> Value: """Create a cone/frustum solid using conic.""" from yapcad.geom3d_util import conic # conic(base_radius, top_radius, height) # radius2=0 makes a true cone, radius1!=radius2 makes a frustum return solid_val(conic(radius1.data, radius2.data, height.data)) def _extrude(profile: Value, height: Value, *args: Value) -> Value: """Extrude a 2D region to create a solid.""" from yapcad.geom3d_util import extrude # extrude takes surface, height return solid_val(extrude(profile.data, height.data)) def _revolve(profile: Value, axis: Value, angle: Value) -> Value: """Revolve a 2D region around an axis.""" from yapcad.geom3d_util import makeRevolutionSolid return solid_val(makeRevolutionSolid(profile.data, axis.data, angle.data)) def _sweep(profile: Value, spine: Value) -> Value: """Sweep a 2D profile along a 3D path to create a solid. Args: profile: A region2d (closed 2D shape) to sweep spine: A path3d (3D wire/curve) defining the sweep path Returns: A solid created by sweeping the profile along the spine """ from yapcad.geom3d_util import sweep_profile_along_path return solid_val(sweep_profile_along_path(profile.data, spine.data)) def _sweep_hollow(outer_profile: Value, inner_profile: Value, spine: Value) -> Value: """Sweep a hollow 2D profile along a 3D path to create a solid. Creates a hollow tube-like solid by sweeping a profile with a hole. The outer_profile defines the outer boundary, inner_profile the hole. Args: outer_profile: A region2d for the outer boundary inner_profile: A region2d for the inner boundary (hole) spine: A path3d (3D wire/curve) defining the sweep path Returns: A hollow solid created by sweeping the profile along the spine """ from yapcad.geom3d_util import sweep_profile_along_path return solid_val(sweep_profile_along_path( outer_profile.data, spine.data, inner_profile=inner_profile.data )) # Solid constructors self.register(BuiltinFunction( "box", _make_sig("box", [FLOAT, FLOAT, FLOAT], SOLID), _box, )) self.register(BuiltinFunction( "cylinder", _make_sig("cylinder", [FLOAT, FLOAT], SOLID), _cylinder, )) self.register(BuiltinFunction( "sphere", _make_sig("sphere", [FLOAT], SOLID), _sphere, )) self.register(BuiltinFunction( "cone", _make_sig("cone", [FLOAT, FLOAT, FLOAT], SOLID), _cone, )) self.register(BuiltinFunction( "extrude", _make_sig("extrude", [REGION2D, FLOAT], SOLID), _extrude, )) self.register(BuiltinFunction( "revolve", _make_sig("revolve", [REGION2D, VECTOR3D, FLOAT], SOLID), _revolve, )) self.register(BuiltinFunction( "sweep", _make_sig("sweep", [REGION2D, PATH3D], SOLID), _sweep, )) self.register(BuiltinFunction( "sweep_hollow", _make_sig("sweep_hollow", [REGION2D, REGION2D, PATH3D], SOLID), _sweep_hollow, )) def _sweep_adaptive(profile: Value, spine: Value, threshold: Value) -> Value: """Sweep a profile along a path with adaptive tangent tracking. The profile normal tracks the path tangent. New profile sections are generated whenever the tangent direction changes by more than the threshold angle. Args: profile: A region2d (closed 2D shape) to sweep spine: A path3d (3D wire/curve) defining the sweep path threshold: Angle in degrees that triggers new section (e.g., 5.0) Returns: A solid created by lofting between adapted sections """ from yapcad.geom3d_util import sweep_adaptive return solid_val(sweep_adaptive( profile.data, spine.data, angle_threshold_deg=threshold.data )) def _sweep_adaptive_hollow(outer_profile: Value, inner_profiles: Value, spine: Value, threshold: Value) -> Value: """Sweep a hollow profile along a path with adaptive tangent tracking. Args: outer_profile: A region2d for the outer boundary inner_profiles: A region2d or list of region2d for inner void(s) spine: A path3d defining the sweep path threshold: Angle in degrees that triggers new section Returns: A hollow solid created by lofting between adapted sections """ from yapcad.geom3d_util import sweep_adaptive # Handle single region2d or list inners = inner_profiles.data return solid_val(sweep_adaptive( outer_profile.data, spine.data, inner_profiles=inners, angle_threshold_deg=threshold.data )) self.register(BuiltinFunction( "sweep_adaptive", _make_sig("sweep_adaptive", [REGION2D, PATH3D, FLOAT], SOLID), _sweep_adaptive, )) self.register(BuiltinFunction( "sweep_adaptive_hollow", _make_sig("sweep_adaptive_hollow", [REGION2D, REGION2D, PATH3D, FLOAT], SOLID), _sweep_adaptive_hollow, )) def _involute_gear(teeth: Value, module_mm: Value, pressure_angle: Value, face_width: Value) -> Value: """Create an involute spur gear solid. Args: teeth: Number of teeth (int) module_mm: Module in mm (metric gear sizing) pressure_angle: Pressure angle in degrees (typically 20) face_width: Thickness of the gear (extrusion height) Returns: A solid representing the gear """ from yapcad.contrib.figgear import make_gear_figure from yapcad.geom3d import poly2surfaceXY from yapcad.geom3d_util import extrude from yapcad.geom import point # Generate 2D gear profile profile_points, blueprints = make_gear_figure( m=module_mm.data, z=int(teeth.data), alpha_deg=pressure_angle.data, bottom_type='spline', ) # Convert to yapCAD points (z=0 for XY plane) pts = [point(x, y, 0.0) for x, y in profile_points] # Create surface from polygon surface, _ = poly2surfaceXY(pts) # Extrude to create solid gear_solid = extrude(surface, face_width.data) return solid_val(gear_solid) self.register(BuiltinFunction( "involute_gear", _make_sig("involute_gear", [INT, FLOAT, FLOAT, FLOAT], SOLID), _involute_gear, )) # --- Boolean Functions --- def _register_boolean_functions(self) -> None: """Register boolean operations.""" def _union(*args: Value) -> Value: """Union of solids.""" from yapcad.geom3d import solid_boolean if len(args) == 1 and isinstance(args[0].type, ListType): operands = args[0].data else: operands = [a.data for a in args] # Perform pairwise unions if len(operands) == 0: raise ValueError("union requires at least one solid") result = operands[0] for i in range(1, len(operands)): result = solid_boolean(result, operands[i], 'union') return solid_val(result) def _difference(a: Value, *rest: Value) -> Value: """Difference: subtract rest from a.""" from yapcad.geom3d import solid_boolean if len(rest) == 1 and isinstance(rest[0].type, ListType): tools = rest[0].data else: tools = [r.data for r in rest] # Perform pairwise differences result = a.data for tool in tools: result = solid_boolean(result, tool, 'difference') return solid_val(result) def _intersection(*args: Value) -> Value: """Intersection of solids.""" from yapcad.geom3d import solid_boolean if len(args) == 1 and isinstance(args[0].type, ListType): operands = args[0].data else: operands = [a.data for a in args] # Perform pairwise intersections if len(operands) == 0: raise ValueError("intersection requires at least one solid") result = operands[0] for i in range(1, len(operands)): result = solid_boolean(result, operands[i], 'intersection') return solid_val(result) # Boolean operations self.register(BuiltinFunction( "union", _make_sig("union", [SOLID], SOLID, is_variadic=True), _union, )) self.register(BuiltinFunction( "difference", _make_sig("difference", [SOLID, SOLID], SOLID, is_variadic=True), _difference, )) self.register(BuiltinFunction( "intersection", _make_sig("intersection", [SOLID], SOLID, is_variadic=True), _intersection, )) # --- Query Functions --- def _register_query_functions(self) -> None: """Register geometric query functions.""" def _volume(s: Value) -> Value: """Calculate volume of a solid.""" from yapcad.geom3d import volume return float_val(volume(s.data)) def _surface_area(s: Value) -> Value: """Calculate surface area of a solid.""" from yapcad.geom3d import surface_area return float_val(surface_area(s.data)) def _area(r: Value) -> Value: """Calculate area of a region2d using shoelace formula.""" from yapcad.geom import isgeomlist, isline, ispoint data = r.data if not isgeomlist(data): return float_val(0.0) # Sample all segments into points points = [] for seg in data: if isline(seg): p = seg[0] # Start point of line segment if ispoint(p): points.append(p) elif ispoint(seg): points.append(seg) if len(points) < 3: return float_val(0.0) # Shoelace formula for polygon area n = len(points) area = 0.0 for i in range(n): j = (i + 1) % n area += points[i][0] * points[j][1] area -= points[j][0] * points[i][1] area = abs(area) / 2.0 return float_val(area) def _perimeter(r: Value) -> Value: """Calculate perimeter of a region2d as sum of segment lengths.""" from yapcad.geom import isgeomlist, isline, ispoint, length import math data = r.data if not isgeomlist(data): return float_val(0.0) total_length = 0.0 for seg in data: if isline(seg): # Line segment - compute length directly p1, p2 = seg[0], seg[1] dx = p2[0] - p1[0] dy = p2[1] - p1[1] total_length += math.sqrt(dx*dx + dy*dy) else: # For arcs or other curves, use the length function try: total_length += length(seg) except: pass return float_val(total_length) # Query functions self.register(BuiltinFunction( "volume", _make_sig("volume", [SOLID], FLOAT), _volume, )) self.register(BuiltinFunction( "surface_area", _make_sig("surface_area", [SOLID], FLOAT), _surface_area, )) self.register(BuiltinFunction( "area", _make_sig("area", [REGION2D], FLOAT), _area, )) self.register(BuiltinFunction( "perimeter", _make_sig("perimeter", [REGION2D], FLOAT), _perimeter, )) # --- Utility Functions --- def _register_utility_functions(self) -> None: """Register utility functions.""" def _len(lst: Value) -> Value: """Get length of a list.""" return int_val(len(lst.data)) def _range_func(*args: Value) -> Value: """Create a range as a list.""" if len(args) == 1: # range(end) return list_val([int_val(i) for i in range(int(args[0].data))], INT) elif len(args) == 2: # range(start, end) return list_val([int_val(i) for i in range(int(args[0].data), int(args[1].data))], INT) else: # range(start, end, step) return list_val([int_val(i) for i in range(int(args[0].data), int(args[1].data), int(args[2].data))], INT) def _print_val(*args: Value) -> Value: """Print values (for debugging).""" print(" ".join(str(a.data) for a in args)) return bool_val(True) def _concat(list1: Value, list2: Value) -> Value: """Concatenate two lists.""" combined = list1.data + list2.data # Get element type from the list type info, not from data elem_type = list1.type.element_type # Create Value directly since combined contains raw data return Value(combined, ListType(elem_type)) def _reverse(lst: Value) -> Value: """Reverse a list.""" reversed_data = list(reversed(lst.data)) # Get element type from the list type info elem_type = lst.type.element_type return Value(reversed_data, ListType(elem_type)) def _flatten(lst: Value) -> Value: """Flatten a nested list.""" result = [] # For list<list<T>>, element type is list<T>, and we want T outer_elem_type = lst.type.element_type if isinstance(outer_elem_type, ListType): inner_elem_type = outer_elem_type.element_type else: inner_elem_type = UNKNOWN for item in lst.data: if isinstance(item, list): result.extend(item) else: result.append(item) return Value(result, ListType(inner_elem_type)) # Utility functions self.register(BuiltinFunction( "len", _make_sig("len", [ListType(INT)], INT), # Generic list _len, )) self.register(BuiltinFunction( "range", _make_sig("range", [INT], ListType(INT), is_variadic=True), _range_func, )) self.register(BuiltinFunction( "concat", _make_sig("concat", [ListType(UNKNOWN), ListType(UNKNOWN)], ListType(UNKNOWN)), _concat, )) self.register(BuiltinFunction( "reverse", _make_sig("reverse", [ListType(UNKNOWN)], ListType(UNKNOWN)), _reverse, )) self.register(BuiltinFunction( "flatten", _make_sig("flatten", [ListType(ListType(UNKNOWN))], ListType(UNKNOWN)), _flatten, )) self.register(BuiltinFunction( "print", _make_sig("print", [STRING], BOOL, is_variadic=True), _print_val, ))
# Global singleton registry _registry: Optional[BuiltinRegistry] = None
[docs] def get_builtin_registry() -> BuiltinRegistry: """Get the global built-in function registry.""" global _registry if _registry is None: _registry = BuiltinRegistry() return _registry
[docs] def call_builtin(name: str, args: List[Value]) -> Value: """ Call a built-in function by name. Raises RuntimeError if function not found. """ registry = get_builtin_registry() func = registry.get_function(name) if func is None: raise RuntimeError(f"Unknown built-in function: {name}") return func.implementation(*args)
[docs] def call_method(type_name: str, method_name: str, receiver: Value, args: List[Value]) -> Value: """ Call a method on a value. Raises RuntimeError if method not found. """ registry = get_builtin_registry() method = registry.get_method(type_name, method_name) if method is None: raise RuntimeError(f"Unknown method: {type_name}.{method_name}") return method.implementation(receiver, *args)