Source code for yapcad.dsl.runtime.builtins

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

Maps DSL function names to yapCAD implementations.

Implementation status (v1.0):
  - 2D curve primitives: DONE (ellipse, parabola, hyperbola implemented)
  - involute_gear: DONE (using yapcad.contrib.figgear)
  - Functional combinators: DONE (union_all, difference_all, sum, etc.)
  - Adaptive sweeps: DONE (sweep_adaptive, sweep_adaptive_hollow)

TODO for 1.0: Add DSL builtins for fasteners (Python API exists):
  - hex_bolt(standard, size, length) -> solid
    Uses yapcad.fasteners.metric_hex_cap_screw / unified_hex_cap_screw
  - hex_nut(standard, size) -> solid
    Uses yapcad.fasteners.metric_hex_nut / unified_hex_nut
  See: yapcad.fasteners, yapcad.threadgen for existing implementation

Future enhancements (v1.1+):
  - Move involute_gear to yapcad.stdlib.gears package for better namespace
  - Add tube/conic_tube/spherical_shell builtins from geom3d_util
  - Add optional 'centered' parameter to cylinder/cone primitives
"""

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, edge_list_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, EDGE,
    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_fastener_functions() self._register_boolean_functions() self._register_query_functions() self._register_utility_functions() self._register_fillet_chamfer_functions() self._register_phase2_geometry_functions() self._register_phase3_text_functions() self._register_phase4_path_functions() self._register_edge_selection_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) def _exp(x: Value) -> Value: # Clamp to avoid overflow val = x.data if val > 700: val = 700 elif val < -700: val = -700 return float_val(math.exp(val)) def _log(x: Value) -> Value: return float_val(math.log(x.data)) def _log10(x: Value) -> Value: return float_val(math.log10(x.data)) # 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), ("exp", [FLOAT], FLOAT, _exp), ("log", [FLOAT], FLOAT, _log), ("log10", [FLOAT], FLOAT, _log10), ] 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, )) # --- New transform functions (Phase 1) --- def _scale_uniform(factor: Value) -> Value: """Create a uniform scale transform.""" from yapcad.xform import Scale f = factor.data return transform_val(Scale(f, f, f)) def _rotate_2d(angle: Value) -> Value: """Create a 2D rotation (around Z axis).""" from yapcad.xform import Rotation from yapcad.geom import point return transform_val(Rotation(point(0, 0, 1), angle.data)) def _mirror_2d(axis: Value) -> Value: """Create a 2D mirror transform (reflection across axis through origin).""" from yapcad.xform import Matrix import math ax, ay = axis.data[0], axis.data[1] # Normalize mag = math.sqrt(ax*ax + ay*ay) if mag < 1e-10: raise ValueError("mirror_2d axis must be non-zero") ax, ay = ax/mag, ay/mag # Reflection matrix: [[ax²-ay², 2*ax*ay], [2*ax*ay, ay²-ax²]] m = [[ax*ax - ay*ay, 2*ax*ay, 0, 0], [2*ax*ay, ay*ay - ax*ax, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] return transform_val(Matrix(m)) def _mirror_y() -> Value: """Mirror across Y axis (negate X).""" from yapcad.xform import Scale return transform_val(Scale(-1, 1, 1)) def _mirror_solid(s: Value, plane_normal: Value) -> Value: """Mirror a solid across a plane defined by its normal vector.""" from yapcad.geom3d import mirror as geom3d_mirror n = plane_normal.data nx, ny, nz = abs(n[0]), abs(n[1]), abs(n[2]) # Map normal to plane string if nx > ny and nx > nz: plane = 'yz' elif ny > nx and ny > nz: plane = 'xz' else: plane = 'xy' return solid_val(geom3d_mirror(s.data, plane)) self.register(BuiltinFunction( "scale_uniform", _make_sig("scale_uniform", [FLOAT], TRANSFORM), _scale_uniform, )) self.register(BuiltinFunction( "rotate_2d", _make_sig("rotate_2d", [FLOAT], TRANSFORM), _rotate_2d, )) self.register(BuiltinFunction( "mirror_2d", _make_sig("mirror_2d", [VECTOR2D], TRANSFORM), _mirror_2d, )) self.register(BuiltinFunction( "mirror_y", _make_sig("mirror_y", [], TRANSFORM), _mirror_y, )) self.register(BuiltinFunction( "mirror", _make_sig("mirror", [SOLID, VECTOR3D], SOLID), _mirror_solid, )) # 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) # Apply the matrix transformation to BREP data as well # (rotatesolid only applies the rotation from ang/axis, not mat) try: from yapcad.brep import apply_matrix_to_brep_solid apply_matrix_to_brep_solid(result, mat) except ImportError: pass 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, )) # Bezier curve constructor def _bezier(points: Value) -> Value: """Create a Bezier curve from control points. A Bezier curve is a special case of NURBS with degree = n-1 and all weights equal to 1. Args: points: List of control points Returns: A bezier curve (stored as NURBS internally) """ pts = [] for p in points.data: if hasattr(p, 'data'): coords = list(p.data) else: coords = list(p) 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) if n < 2: raise ValueError("bezier requires at least 2 control points") degree = n - 1 weights = [1.0] * n # Bezier knot vector: degree+1 zeros, then degree+1 ones knots = [0.0] * (degree + 1) + [1.0] * (degree + 1) curve = ['bezier', pts, {'degree': degree, 'weights': weights, 'knots': knots}] return wrap_value(curve, BEZIER) self.register(BuiltinFunction( "bezier", _make_sig("bezier", [ListType(POINT)], BEZIER), _bezier, )) # 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 segments or other path3d objects. Each argument can be: - A path3d (dict with 'type': 'path3d' and 'segments' list) - A segment dict (dict with 'type': 'line' or 'arc') - A list of segments """ seg_list = [] for seg in segments: data = seg.data if isinstance(data, dict): if data.get('type') == 'path3d': # It's a path3d, extract its segments seg_list.extend(data.get('segments', [])) else: # It's a single segment dict seg_list.append(data) elif isinstance(data, list): # List of segments seg_list.extend(data) else: seg_list.append(data) return path3d_val({'type': 'path3d', 'segments': seg_list}) def _path3d_line(start: Value, end: Value) -> Value: """Create a path3d containing a single line segment. Args: start: Start point [x, y, z] end: End point [x, y, z] Returns: A path3d dict with a single line segment """ s = start.data e = end.data segment = { '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] } return wrap_value({ 'type': 'path3d', 'segments': [segment] }, PATH3D) def _path3d_arc(center: Value, start: Value, end: Value, normal: Value) -> Value: """Create a path3d containing a single arc segment. 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] Returns: A path3d dict with a single arc segment """ c = center.data s = start.data e = end.data n = normal.data segment = { '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] } return wrap_value({ 'type': 'path3d', 'segments': [segment] }, 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, )) def _path3d_arc_auto(center: Value, start: Value, end: Value, flip: Value) -> Value: """Create a path3d arc with auto-computed normal from geometry. The arc plane normal is computed from the cross product of (center->start) x (center->end). The flip parameter controls which of the two possible arc directions is used. Args: center: Arc center point [x, y, z] start: Arc start point [x, y, z] end: Arc end point [x, y, z] flip: If true, negate the computed normal (reverses arc direction) Returns: A path3d dict with a single arc segment """ import math c = center.data s = start.data e = end.data do_flip = flip.data # Extract coordinates cx, cy, cz = c[0], c[1], c[2] if len(c) > 2 else 0 sx, sy, sz = s[0], s[1], s[2] if len(s) > 2 else 0 ex, ey, ez = e[0], e[1], e[2] if len(e) > 2 else 0 # Vectors from center to start and end v1 = [sx - cx, sy - cy, sz - cz] v2 = [ex - cx, ey - cy, ez - cz] # Cross product: v1 x v2 nx = v1[1] * v2[2] - v1[2] * v2[1] ny = v1[2] * v2[0] - v1[0] * v2[2] nz = v1[0] * v2[1] - v1[1] * v2[0] # Normalize mag = math.sqrt(nx*nx + ny*ny + nz*nz) if mag < 1e-10: # Degenerate case - points are collinear, use Z-up default nx, ny, nz = 0.0, 0.0, 1.0 else: nx, ny, nz = nx/mag, ny/mag, nz/mag # Apply flip if requested if do_flip: nx, ny, nz = -nx, -ny, -nz segment = { 'type': 'arc', 'center': [cx, cy, cz], 'start': [sx, sy, sz], 'end': [ex, ey, ez], 'normal': [nx, ny, nz] } return wrap_value({ 'type': 'path3d', 'segments': [segment] }, PATH3D) self.register(BuiltinFunction( "path3d_arc_auto", _make_sig("path3d_arc_auto", [POINT3D, POINT3D, POINT3D, BOOL], PATH3D), _path3d_arc_auto, )) # --- 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, )) # Phase 3: 2D boolean aggregation functions def _union2d_all(regions: Value) -> Value: """Union all 2D regions in a list.""" from yapcad.geom_util import combineglist if not regions.data: raise ValueError("union2d_all requires non-empty list") result = regions.data[0] if hasattr(result, 'data'): result = result.data for r in regions.data[1:]: r_data = r.data if hasattr(r, 'data') else r result = combineglist(result, r_data, "union") return region2d_val(result) def _difference2d_all(base: Value, tools: Value) -> Value: """Subtract all 2D regions in tools list from base.""" result = base.data for tool in tools.data: tool_data = tool.data if hasattr(tool, 'data') else tool # Use the same robust difference logic as _difference2d result_val = _difference2d( Value(result, REGION2D), Value(tool_data, REGION2D) ) result = result_val.data return region2d_val(result) def _intersection2d_all(regions: Value) -> Value: """Intersect all 2D regions in a list.""" from yapcad.geom_util import combineglist if not regions.data: raise ValueError("intersection2d_all requires non-empty list") result = regions.data[0] if hasattr(result, 'data'): result = result.data for r in regions.data[1:]: r_data = r.data if hasattr(r, 'data') else r result = combineglist(result, r_data, "intersection") return region2d_val(result) self.register(BuiltinFunction( "union2d_all", _make_sig("union2d_all", [ListType(REGION2D)], REGION2D), _union2d_all, )) self.register(BuiltinFunction( "difference2d_all", _make_sig("difference2d_all", [REGION2D, ListType(REGION2D)], REGION2D), _difference2d_all, )) self.register(BuiltinFunction( "intersection2d_all", _make_sig("intersection2d_all", [ListType(REGION2D)], REGION2D), _intersection2d_all, )) # 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") # Check if the spline samples already form a closed loop # (first and last points are the same) def _points_equal(p1, p2, tol=1e-9): return (abs(p1[0] - p2[0]) < tol and abs(p1[1] - p2[1]) < tol and abs(p1[2] - p2[2]) < tol) is_closed = _points_equal(pts[0], pts[-1]) # Create polygon from sample points # If closed, skip the last degenerate edge (from pts[-1] back to pts[0]) region = [] num_edges = len(pts) - 1 if is_closed else len(pts) for i in range(num_edges): 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 _oblate_spheroid(equatorial_diameter: Value, oblateness: Value) -> Value: """Create an oblate spheroid (flattened sphere). An oblate spheroid has equal X and Y radii (equatorial) and a smaller Z radius (polar). The oblateness parameter controls the flattening: 0 = perfect sphere, higher values = more flattened. For reference: - Earth's oblateness: ~0.00335 - Mars' oblateness: ~0.00648 Args: equatorial_diameter: diameter at the equator (X and Y) oblateness: geometric flattening (0-1, typically small like 0.006) Returns: A solid oblate spheroid centered at origin with polar axis along Z """ from yapcad.geom3d_util import oblate_spheroid return solid_val(oblate_spheroid(equatorial_diameter.data, oblateness.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. Args: profile: A region2d (closed 2D shape) to extrude height: Extrusion height along Z axis Returns: A solid created by extruding the profile """ from yapcad.geom3d_util import extrude_region2d return solid_val(extrude_region2d(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( "oblate_spheroid", _make_sig("oblate_spheroid", [FLOAT, FLOAT], SOLID), _oblate_spheroid, )) 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 _sweep_adaptive_frenet(profile: Value, spine: Value, threshold: Value) -> Value: """Sweep a profile using Frenet frame (natural curvature-following). The profile orientation follows the natural Frenet frame of the path, where the profile 'up' direction aligns with the curve's normal (perpendicular to both tangent and binormal). This mode causes the profile to twist naturally with the path curvature, which is appropriate for paths like helices where you want the profile to follow the curve's intrinsic geometry. 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 Returns: A solid created by lofting with Frenet frame orientation """ from yapcad.geom3d_util import sweep_adaptive return solid_val(sweep_adaptive( profile.data, spine.data, angle_threshold_deg=threshold.data, frame_mode='frenet' )) def _sweep_adaptive_hollow_frenet(outer_profile: Value, inner_profiles: Value, spine: Value, threshold: Value) -> Value: """Sweep a hollow profile using Frenet frame orientation. 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 with Frenet frame orientation """ from yapcad.geom3d_util import sweep_adaptive inners = inner_profiles.data return solid_val(sweep_adaptive( outer_profile.data, spine.data, inner_profiles=inners, angle_threshold_deg=threshold.data, frame_mode='frenet' )) self.register(BuiltinFunction( "sweep_adaptive_frenet", _make_sig("sweep_adaptive_frenet", [REGION2D, PATH3D, FLOAT], SOLID), _sweep_adaptive_frenet, )) self.register(BuiltinFunction( "sweep_adaptive_hollow_frenet", _make_sig("sweep_adaptive_hollow_frenet", [REGION2D, REGION2D, PATH3D, FLOAT], SOLID), _sweep_adaptive_hollow_frenet, )) # Loft between profiles def _loft(profiles: Value) -> Value: """Loft between a list of 2D profiles to create a solid. Creates a solid by connecting successive profile cross-sections. Uses sequential extrusion/connection between adjacent profiles. Args: profiles: List of region2d profiles Returns: A solid created by lofting between the profiles """ from yapcad.geom3d_util import makeLoftSolid from yapcad.geom3d import solid_boolean from yapcad.geom import sample as geom_sample, isline, isarc profile_list = [] for p in profiles.data: if hasattr(p, 'data'): profile_list.append(p.data) else: profile_list.append(p) if len(profile_list) < 2: raise ValueError("loft requires at least 2 profiles") def profile_to_loop(profile): """Extract point loop from a region2d profile.""" from yapcad.geom import point as make_point pts = [] for seg in profile: if isline(seg): p = seg[0] pts.append(make_point(p[0], p[1], p[2] if len(p) > 2 else 0)) elif isarc(seg): p = geom_sample(seg, 0) pts.append(make_point(p[0], p[1], p[2] if len(p) > 2 else 0)) return pts loops = [profile_to_loop(p) for p in profile_list] # Loft between successive pairs and union result = makeLoftSolid(loops[0], loops[1]) for i in range(1, len(loops) - 1): section = makeLoftSolid(loops[i], loops[i + 1]) result = solid_boolean(result, section, 'union') return solid_val(result) self.register(BuiltinFunction( "loft", _make_sig("loft", [ListType(REGION2D)], SOLID), _loft, )) 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, )) def _herringbone_gear(teeth: Value, module_mm: Value, face_width: Value, helix_angle: Value) -> Value: """Create a herringbone (double-helix) gear solid. Uses helical_extrude for smooth, mathematically continuous tooth surfaces without stepping artifacts. Uses OCC fuse to properly combine halves, preserving BREP data for subsequent boolean operations. Args: teeth: Number of teeth (int) module_mm: Module in mm (metric gear sizing) face_width: Total face width of the gear helix_angle: Helix angle in degrees (typically 20-30) Returns: A solid representing the herringbone gear with BREP data """ import math from yapcad.contrib.figgear import make_gear_figure from yapcad.geom import point, line from yapcad.geom3d import solid from yapcad.geom3d_util import helical_extrude from yapcad.brep import brep_from_solid, attach_brep_to_solid, BrepSolid, occ_available if not occ_available(): raise RuntimeError("herringbone_gear requires pythonocc-core") from OCC.Core.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Vec, gp_Trsf from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Fuse from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform n_teeth = int(teeth.data) mod = float(module_mm.data) width = float(face_width.data) helix_deg = float(helix_angle.data) pitch_diameter = n_teeth * mod pitch_radius = pitch_diameter / 2.0 half_height = width / 2.0 # Generate 2D gear profile with optimized resolution profile_points, _ = make_gear_figure( m=mod, z=n_teeth, alpha_deg=20.0, bottom_type='spline', involute_step=0.8, spline_division_num=12 ) # Convert to region2d points = list(profile_points) if len(points) > 1: first, last = points[0], points[-1] if math.sqrt((first[0]-last[0])**2 + (first[1]-last[1])**2) > 1e-6: points.append(first) profile_region = [] for i in range(len(points) - 1): p1 = point(points[i][0], points[i][1], 0.0) p2 = point(points[i + 1][0], points[i + 1][1], 0.0) profile_region.append(line(p1, p2)) # Calculate twist parameters helix_tan = math.tan(math.radians(helix_deg)) total_twist_deg = math.degrees(half_height * helix_tan / pitch_radius) segments = max(24, int(abs(total_twist_deg) * 3)) # Create halves with helical_extrude (these have BREP data) half1 = helical_extrude(profile_region, half_height, total_twist_deg, segments=segments) half2 = helical_extrude(profile_region, half_height, -total_twist_deg, segments=segments) # Get BREP shapes brep1 = brep_from_solid(half1) brep2 = brep_from_solid(half2) if brep1 is None or brep2 is None: raise RuntimeError("helical_extrude failed to create BREP data") # Transform half2: rotate by total_twist_deg and translate up trsf_rot = gp_Trsf() trsf_rot.SetRotation(gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)).Axis(), math.radians(total_twist_deg)) brep2_rot = BRepBuilderAPI_Transform(brep2.shape, trsf_rot, True).Shape() trsf_trans = gp_Trsf() trsf_trans.SetTranslation(gp_Vec(0, 0, half_height)) brep2_final = BRepBuilderAPI_Transform(brep2_rot, trsf_trans, True).Shape() # Fuse the two halves using OCC boolean (proper solid, no internal artifacts) fuse = BRepAlgoAPI_Fuse(brep1.shape, brep2_final) if not fuse.IsDone(): raise RuntimeError("Failed to fuse gear halves") gear_shape = fuse.Shape() # Create yapCAD solid with BREP data brep = BrepSolid(gear_shape) surface = brep.tessellate() gear_solid = solid([surface], [], ['procedure', 'herringbone_gear_dsl']) attach_brep_to_solid(gear_solid, brep) return solid_val(gear_solid) self.register(BuiltinFunction( "herringbone_gear", _make_sig("herringbone_gear", [INT, FLOAT, FLOAT, FLOAT], SOLID), _herringbone_gear, )) def _sun_gear_with_hub(teeth: Value, module_mm: Value, face_width: Value, helix_angle: Value, hub_diameter: Value, hub_height: Value, bolt_circle: Value, num_bolts: Value, bolt_hole_diameter: Value) -> Value: """Create a sun gear with flanged hub for servo horn mounting. Uses OCC BREP booleans directly (staying in OCC-land) rather than mesh-based booleans, which is significantly faster. The hub design consists of: 1. A central boss connecting to the gear root (hub_diameter) 2. A mounting flange that expands to reach the bolt circle 3. A center counterbore to clear servo horn center protrusion This flanged design allows small-module gears (0.75mm) to still reach larger servo horn bolt circles (16-18mm). Args: teeth: Number of teeth module_mm: Module in mm face_width: Gear face width helix_angle: Helix angle in degrees hub_diameter: Inner hub/boss diameter (connects to gear root) hub_height: Total hub height below gear (extends down from z=0) bolt_circle: Bolt circle diameter for servo horn num_bolts: Number of bolt holes (typically 4) bolt_hole_diameter: Diameter of bolt holes Returns: Complete sun gear with flanged hub and bolt holes """ import math from yapcad.contrib.figgear import make_gear_figure from yapcad.geom import point, line from yapcad.geom3d import solid from yapcad.geom3d_util import helical_extrude from yapcad.brep import brep_from_solid, attach_brep_to_solid, BrepSolid, occ_available if not occ_available(): raise RuntimeError("sun_gear_with_hub requires pythonocc-core") from OCC.Core.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Vec, gp_Trsf from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Fuse, BRepAlgoAPI_Cut from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform n_teeth = int(teeth.data) mod = float(module_mm.data) width = float(face_width.data) helix_deg = float(helix_angle.data) hub_d = float(hub_diameter.data) hub_h = float(hub_height.data) bolt_circle_d = float(bolt_circle.data) bolt_r = bolt_circle_d / 2.0 n_bolts = int(num_bolts.data) hole_d = float(bolt_hole_diameter.data) pitch_diameter = n_teeth * mod pitch_radius = pitch_diameter / 2.0 half_height = width / 2.0 # Calculate root diameter (dedendum = 1.25 * module) root_diameter = n_teeth * mod - 2.5 * mod # Flange design parameters: # - Flange extends from hub_d to bolt_circle + clearance for bolt heads # - M2.5 bolt head is ~4.5mm, M2 is ~3.8mm - need ~2.5mm beyond bolt center flange_od = bolt_circle_d + hole_d + 3.0 # Bolt circle + hole radius + wall flange_thickness = 3.0 # 3mm thick flange is sufficient for M2.5 bolts # Boss (inner hub) extends from gear root down, with flange at bottom # If hub_d is larger than root_diameter, use hub_d; otherwise use root_d boss_diameter = max(hub_d, root_diameter - 1.0) # Slightly under root for clearance boss_height = hub_h - flange_thickness # Boss above flange # Servo horn center protrusion clearance: # - Typical XH430/XH540 horn has ~6mm dia center boss, ~2.5mm tall # - Add counterbore to clear this center_bore_diameter = 8.0 # 8mm clearance for horn center protrusion center_bore_depth = 3.0 # 3mm deep counterbore # Generate gear profile profile_points, _ = make_gear_figure( m=mod, z=n_teeth, alpha_deg=20.0, bottom_type='spline', involute_step=0.8, spline_division_num=12 ) points = list(profile_points) if len(points) > 1: first, last = points[0], points[-1] if math.sqrt((first[0]-last[0])**2 + (first[1]-last[1])**2) > 1e-6: points.append(first) profile_region = [] for i in range(len(points) - 1): p1 = point(points[i][0], points[i][1], 0.0) p2 = point(points[i + 1][0], points[i + 1][1], 0.0) profile_region.append(line(p1, p2)) helix_tan = math.tan(math.radians(helix_deg)) total_twist_deg = math.degrees(half_height * helix_tan / pitch_radius) segments = max(24, int(abs(total_twist_deg) * 3)) # Create herringbone gear halves (these have BREP data) half1 = helical_extrude(profile_region, half_height, total_twist_deg, segments=segments) half2 = helical_extrude(profile_region, half_height, -total_twist_deg, segments=segments) # Get BREP shapes brep1 = brep_from_solid(half1) brep2 = brep_from_solid(half2) if brep1 is None or brep2 is None: raise RuntimeError("helical_extrude failed to create BREP data") # Transform half2: rotate by total_twist_deg and translate up trsf_rot = gp_Trsf() trsf_rot.SetRotation(gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)).Axis(), math.radians(total_twist_deg)) brep2_rot = BRepBuilderAPI_Transform(brep2.shape, trsf_rot, True).Shape() trsf_trans = gp_Trsf() trsf_trans.SetTranslation(gp_Vec(0, 0, half_height)) brep2_final = BRepBuilderAPI_Transform(brep2_rot, trsf_trans, True).Shape() # Fuse the two gear halves (OCC boolean - fast!) fuse_halves = BRepAlgoAPI_Fuse(brep1.shape, brep2_final) if not fuse_halves.IsDone(): raise RuntimeError("Failed to fuse gear halves") gear_shape = fuse_halves.Shape() # Add integral hub within gear face to fill tooth root valleys # Hub radius = dedendum radius + margin to cover spline overshoot # The gear profile uses spline interpolation at tooth roots, which can # extend slightly beyond the theoretical dedendum circle. Add 0.2*module # margin to ensure complete coverage and eliminate all visible holes. dedendum_diameter = n_teeth * mod - 2.5 * mod dedendum_radius = dedendum_diameter / 2.0 hub_margin = 0.2 * mod # 20% of module to cover spline interpolation hub_radius = dedendum_radius + hub_margin gear_hub_axis = gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)) gear_hub_shape = BRepPrimAPI_MakeCylinder(gear_hub_axis, hub_radius, width).Shape() fuse_gear_hub = BRepAlgoAPI_Fuse(gear_shape, gear_hub_shape) if not fuse_gear_hub.IsDone(): raise RuntimeError(f"Failed to fuse gear hub (radius={hub_radius:.4f}, height={width})") gear_shape = fuse_gear_hub.Shape() # Create flanged hub: # 1. Inner boss cylinder from z=-boss_height to z=0 if boss_height > 0: boss_axis = gp_Ax2(gp_Pnt(0, 0, -boss_height), gp_Dir(0, 0, 1)) boss_shape = BRepPrimAPI_MakeCylinder(boss_axis, boss_diameter/2.0, boss_height).Shape() fuse_boss = BRepAlgoAPI_Fuse(gear_shape, boss_shape) if fuse_boss.IsDone(): gear_shape = fuse_boss.Shape() # 2. Mounting flange at bottom (z=-hub_h to z=-boss_height) flange_axis = gp_Ax2(gp_Pnt(0, 0, -hub_h), gp_Dir(0, 0, 1)) flange_shape = BRepPrimAPI_MakeCylinder(flange_axis, flange_od/2.0, flange_thickness).Shape() fuse_flange = BRepAlgoAPI_Fuse(gear_shape, flange_shape) if not fuse_flange.IsDone(): raise RuntimeError("Failed to fuse gear and flange") body_shape = fuse_flange.Shape() # Create and subtract bolt holes (through full hub height) hole_depth = width + hub_h + 2.0 for i in range(n_bolts): angle = math.radians(45.0 + i * (360.0 / n_bolts)) x = bolt_r * math.cos(angle) y = bolt_r * math.sin(angle) hole_axis = gp_Ax2(gp_Pnt(x, y, -hub_h - 1.0), gp_Dir(0, 0, 1)) hole = BRepPrimAPI_MakeCylinder(hole_axis, hole_d/2.0, hole_depth).Shape() cut = BRepAlgoAPI_Cut(body_shape, hole) if cut.IsDone(): body_shape = cut.Shape() # Center counterbore for servo horn protrusion clearance # This is a shallow pocket at the bottom of the hub counterbore_axis = gp_Ax2(gp_Pnt(0, 0, -hub_h - 0.1), gp_Dir(0, 0, 1)) counterbore = BRepPrimAPI_MakeCylinder(counterbore_axis, center_bore_diameter/2.0, center_bore_depth + 0.1).Shape() cut_counterbore = BRepAlgoAPI_Cut(body_shape, counterbore) if cut_counterbore.IsDone(): body_shape = cut_counterbore.Shape() # Tessellate and create yapCAD solid brep = BrepSolid(body_shape) surface = brep.tessellate() gear_solid = solid([surface], [], ['procedure', 'sun_gear_with_hub']) attach_brep_to_solid(gear_solid, brep) return solid_val(gear_solid) self.register(BuiltinFunction( "sun_gear_with_hub", _make_sig("sun_gear_with_hub", [INT, FLOAT, FLOAT, FLOAT, FLOAT, FLOAT, FLOAT, INT, FLOAT], SOLID), _sun_gear_with_hub, )) # --- Phase 2 Geometry Functions --- def _register_phase2_geometry_functions(self) -> None: """Register Phase 2 geometry primitives from geom3d_util.""" def _dodecahedron(diameter: Value) -> Value: """Create a regular dodecahedron solid.""" from yapcad.geom3d_util import dodecahedron from yapcad.geom import point return solid_val(dodecahedron(diameter.data, center=point(0, 0, 0))) def _tube(outer_diameter: Value, wall_thickness: Value, length: Value) -> Value: """Create a cylindrical tube (hollow cylinder).""" from yapcad.geom3d_util import tube return solid_val(tube(outer_diameter.data, wall_thickness.data, length.data)) def _conic_tube(bottom_od: Value, top_od: Value, wall_thickness: Value, length: Value) -> Value: """Create a conical tube (hollow cone/frustum).""" from yapcad.geom3d_util import conic_tube return solid_val(conic_tube(bottom_od.data, top_od.data, wall_thickness.data, length.data)) def _spherical_shell(outer_diameter: Value, wall_thickness: Value) -> Value: """Create a spherical shell (hollow sphere).""" from yapcad.geom3d_util import spherical_shell return solid_val(spherical_shell(outer_diameter.data, wall_thickness.data)) def _helical_extrude(profile: Value, height: Value, twist_angle: Value) -> Value: """Extrude a 2D profile with helical twist.""" from yapcad.geom3d_util import helical_extrude return solid_val(helical_extrude(profile.data, height.data, twist_angle.data)) self.register(BuiltinFunction( "dodecahedron", _make_sig("dodecahedron", [FLOAT], SOLID), _dodecahedron, )) self.register(BuiltinFunction( "tube", _make_sig("tube", [FLOAT, FLOAT, FLOAT], SOLID), _tube, )) self.register(BuiltinFunction( "conic_tube", _make_sig("conic_tube", [FLOAT, FLOAT, FLOAT, FLOAT], SOLID), _conic_tube, )) self.register(BuiltinFunction( "spherical_shell", _make_sig("spherical_shell", [FLOAT, FLOAT], SOLID), _spherical_shell, )) self.register(BuiltinFunction( "helical_extrude", _make_sig("helical_extrude", [REGION2D, FLOAT, FLOAT], SOLID), _helical_extrude, )) # --- Phase 3 Text Functions --- def _register_phase3_text_functions(self) -> None: """Register Phase 3 text support functions.""" def _text_solid(text: Value, height: Value, depth: Value, spacing: Value) -> Value: """Create extruded 3D text solid using block font.""" from yapcad.text3d import text_solid return solid_val(text_solid( text.data, height=height.data, depth=depth.data, spacing=spacing.data, font="block" )) def _engrave_text(target: Value, text: Value, position: Value, normal: Value, height: Value, depth: Value, spacing: Value) -> Value: """Engrave text into a solid surface.""" from yapcad.text3d import engrave_text return solid_val(engrave_text( target.data, text.data, position.data, normal.data, height=height.data, depth=depth.data, spacing=spacing.data, font="block" )) def _text_width(text: Value, height: Value, spacing: Value) -> Value: """Calculate total width of rendered text.""" from yapcad.text3d import text_width return float_val(text_width( text.data, height=height.data, spacing=spacing.data, font="block" )) self.register(BuiltinFunction( "text_solid", _make_sig("text_solid", [STRING, FLOAT, FLOAT, FLOAT], SOLID), _text_solid, )) self.register(BuiltinFunction( "engrave_text", _make_sig("engrave_text", [SOLID, STRING, VECTOR3D, VECTOR3D, FLOAT, FLOAT, FLOAT], SOLID), _engrave_text, )) self.register(BuiltinFunction( "text_width", _make_sig("text_width", [STRING, FLOAT, FLOAT], FLOAT), _text_width, )) # --- Phase 4 Path Utilities & Manufacturing --- def _register_phase4_path_functions(self) -> None: """Register Phase 4 path utility and manufacturing functions.""" def _path3d_eval(path: Value, t: Value) -> Value: """Evaluate a path3d at parameter t, returning the position point.""" from yapcad.manufacturing.path_utils import evaluate_path3d_at_t from yapcad.geom import point as _point pos, tangent = evaluate_path3d_at_t(path.data, t.data) return point_val(_point(pos[0], pos[1], pos[2])) def _path3d_length(path: Value) -> Value: """Compute total arc length of a path3d.""" from yapcad.manufacturing.path_utils import path_length return float_val(path_length(path.data)) def _split_solid(s: Value, plane_point: Value, plane_normal: Value) -> Value: """Split a solid at a plane. Returns the negative-side half. Requires OCC (pythonocc-core) at runtime. """ from yapcad.manufacturing.segmentation import split_solid_at_plane pp = plane_point.data pn = plane_normal.data solid_a, solid_b = split_solid_at_plane( s.data, [pp[0], pp[1], pp[2]], [pn[0], pn[1], pn[2]] ) # Return both halves as a list return list_val([solid_val(solid_a), solid_val(solid_b)]) self.register(BuiltinFunction( "path3d_eval", _make_sig("path3d_eval", [PATH3D, FLOAT], POINT3D), _path3d_eval, )) self.register(BuiltinFunction( "path3d_length", _make_sig("path3d_length", [PATH3D], FLOAT), _path3d_length, )) self.register(BuiltinFunction( "split_solid", _make_sig("split_solid", [SOLID, POINT3D, VECTOR3D], SOLID), _split_solid, )) # --- Text Solid Functions (from assembly branch) --- def _text_solid_fitted(text_str: Value, max_width: Value, depth: Value) -> Value: """Create 3D text that fits within max_width by auto-scaling height. This function automatically calculates the optimal character height to ensure the text fits within the specified maximum width. It will not go below 3mm height to maintain readability. Args: text_str: String to render max_width: Maximum allowed width in mm depth: Extrusion depth in mm Returns: yapCAD solid (auto-sized to fit within max_width) """ from yapcad.text3d import text_solid_fitted solid, actual_height = text_solid_fitted(text_str.data, max_width.data, depth.data) return solid_val(solid) self.register(BuiltinFunction( "text_solid_fitted", _make_sig("text_solid_fitted", [STRING, FLOAT, FLOAT], SOLID), _text_solid_fitted, )) # --- text_on_surface: Simple way to place text on any surface --- def _text_on_surface( text_str: Value, surface_center: Value, surface_normal: Value, up_direction: Value, max_width: Value, depth: Value, margin: Value = None, font: Value = None ) -> Value: """Place text on a surface, automatically handling all rotations. Creates 3D text positioned on an arbitrary surface. The text is automatically sized to fit within max_width and rotated to face outward along the surface normal. Args: text_str: String to render surface_center: (x, y, z) tuple - center point where text should be placed surface_normal: (x, y, z) tuple - outward-pointing normal of the surface up_direction: (x, y, z) tuple - "up" direction on surface (text baseline to top) max_width: Maximum text width in mm depth: How far text protrudes from surface in mm margin: Optional margin from surface (default 0.0) font: Optional font specification (path or name) Returns: yapCAD solid (text positioned and oriented on surface) Example for front face of battery cage (+Y face): text_on_surface( "DARK MATTER LAB", (0.0, 66.5, 100.0), # center of front face at Z=100 (0.0, 1.0, 0.0), # face outward (+Y) (0.0, 0.0, 1.0), # up is +Z 180.0, # max width 1.5, # depth 0.0, # margin "path/to/font.ttf" # font ) """ from yapcad.text3d import text_on_surface # Extract tuple data from Value objects center = surface_center.data normal = surface_normal.data up = up_direction.data # Build kwargs for optional parameters kwargs = {} if margin is not None: kwargs['margin'] = margin.data if font is not None: kwargs['font'] = font.data return solid_val(text_on_surface( text_str.data, center, normal, up, max_width.data, depth.data, **kwargs )) # Register with signature: text_on_surface(text, center, normal, up, max_width, depth, margin?, font?) # center, normal, up are POINT3D (tuples), max_width, depth, margin are FLOAT, font is STRING self.register(BuiltinFunction( "text_on_surface", _make_sig("text_on_surface", [STRING, POINT3D, POINT3D, POINT3D, FLOAT, FLOAT, FLOAT, STRING], SOLID), _text_on_surface, )) # --- Fastener Functions --- def _register_fastener_functions(self) -> None: """Register fastener generation builtins.""" def _metric_hex_bolt(size: Value, length: Value) -> Value: """Create a metric hex bolt (ISO 4014/4017). Args: size: Thread size designation (e.g., "M8", "M10") length: Total shank length in mm Returns: yapCAD solid representing the bolt """ from yapcad.fasteners import metric_hex_bolt return solid_val(metric_hex_bolt(size.data, length.data)) def _metric_hex_nut(size: Value) -> Value: """Create a metric hex nut (ISO 4032). Args: size: Thread size designation (e.g., "M8", "M10") Returns: yapCAD solid representing the nut """ from yapcad.fasteners import metric_hex_nut return solid_val(metric_hex_nut(size.data)) def _unified_hex_bolt(size: Value, length: Value) -> Value: """Create a unified (UNC/UNF) hex bolt (ASME B18.2.1). Args: size: Thread size designation (e.g., "1/4-20", "#10-24") length: Total shank length in inches Returns: yapCAD solid representing the bolt """ from yapcad.fasteners import unified_hex_bolt return solid_val(unified_hex_bolt(size.data, length.data)) def _unified_hex_nut(size: Value) -> Value: """Create a unified (UNC/UNF) hex nut (ASME B18.2.2). Args: size: Thread size designation (e.g., "1/4-20", "#10-24") Returns: yapCAD solid representing the nut """ from yapcad.fasteners import unified_hex_nut return solid_val(unified_hex_nut(size.data)) self.register(BuiltinFunction( "metric_hex_bolt", _make_sig("metric_hex_bolt", [STRING, FLOAT], SOLID), _metric_hex_bolt, )) self.register(BuiltinFunction( "metric_hex_nut", _make_sig("metric_hex_nut", [STRING], SOLID), _metric_hex_nut, )) self.register(BuiltinFunction( "unified_hex_bolt", _make_sig("unified_hex_bolt", [STRING, FLOAT], SOLID), _unified_hex_bolt, )) self.register(BuiltinFunction( "unified_hex_nut", _make_sig("unified_hex_nut", [STRING], SOLID), _unified_hex_nut, )) # --- 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) def _compound(*args: Value) -> Value: """Create a compound of multiple solids without boolean operations. Unlike union(), this does NOT merge the solids - they remain as separate bodies in a compound shape. Useful for assemblies where you want to export multiple parts together without merging them. Args: *args: One or more solids to combine into a compound Returns: A solid containing all input solids as separate bodies """ from yapcad.brep import occ_available, BrepSolid, attach_brep_to_solid, brep_from_solid from yapcad.geom3d import solid if len(args) == 1 and isinstance(args[0].type, ListType): operands = args[0].data else: operands = [a.data for a in args] if len(operands) == 0: raise ValueError("compound requires at least one solid") # Collect all surfaces from all solids for the mesh representation all_surfaces = [] for op in operands: if len(op) > 1 and op[1]: all_surfaces.extend(op[1]) # Create result solid with combined surfaces result = solid(all_surfaces, [], ['procedure', 'compound']) # If OCC available, create a proper compound shape if occ_available(): try: from OCC.Core.TopoDS import TopoDS_Compound from OCC.Core.BRep import BRep_Builder from OCC.Core.TopExp import TopExp_Explorer from OCC.Core.TopAbs import TopAbs_SOLID from OCC.Core.TopoDS import topods compound = TopoDS_Compound() builder = BRep_Builder() builder.MakeCompound(compound) # Add each solid's BREP to the compound for op in operands: brep = brep_from_solid(op) if brep is not None and brep.shape is not None: # Extract all solids from this operand exp = TopExp_Explorer(brep.shape, TopAbs_SOLID) while exp.More(): builder.Add(compound, exp.Current()) exp.Next() attach_brep_to_solid(result, BrepSolid(compound)) except Exception: pass 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, )) self.register(BuiltinFunction( "compound", _make_sig("compound", [SOLID], SOLID, is_variadic=True), _compound, )) # Phase 3: List-based aggregation functions # These are aliases - the existing functions already handle list arguments def _union_all(solids: Value) -> Value: """Union all solids in a list.""" return _union(solids) def _difference_all(base: Value, tools: Value) -> Value: """Subtract all solids in tools list from base.""" from yapcad.geom3d import solid_boolean result = base.data for tool in tools.data: tool_data = tool.data if hasattr(tool, 'data') else tool result = solid_boolean(result, tool_data, 'difference') return solid_val(result) def _intersection_all(solids: Value) -> Value: """Intersect all solids in a list.""" return _intersection(solids) self.register(BuiltinFunction( "union_all", _make_sig("union_all", [ListType(SOLID)], SOLID), _union_all, )) self.register(BuiltinFunction( "difference_all", _make_sig("difference_all", [SOLID, ListType(SOLID)], SOLID), _difference_all, )) self.register(BuiltinFunction( "intersection_all", _make_sig("intersection_all", [ListType(SOLID)], SOLID), _intersection_all, )) # Pattern operations def _radial_pattern(shape: Value, count: Value, axis: Value, center: Value) -> Value: """Create a radial/circular pattern of geometry copies. Args: shape: A 2D region or 3D solid to pattern count: Number of copies (including original) axis: Rotation axis vector (e.g., [0,0,1] for Z-axis) center: Center point for rotation Returns: List of geometry copies, each rotated by 360/count degrees """ from yapcad.geom3d import issolid, issurface from yapcad.geom import isgeomlist, isline, isarc data = shape.data n = int(count.data) ax = axis.data ct = center.data # Detect geometry type and dispatch to appropriate function if issolid(data): from yapcad.geom3d_util import radial_pattern_solid result = radial_pattern_solid(data, n, center=ct, axis=ax, angle=360.0) return list_val([solid_val(s) for s in result], SOLID) elif issurface(data): from yapcad.geom3d_util import radial_pattern_surface result = radial_pattern_surface(data, n, center=ct, axis=ax, angle=360.0) return list_val([surface_val(s) for s in result], SURFACE) else: # Assume 2D geometry (region, arc, line, geomlist) from yapcad.geom_util import radial_pattern result = radial_pattern(data, n, center=ct, axis=ax, angle=360.0) return list_val([region2d_val(g) for g in result], REGION2D) def _linear_pattern(shape: Value, count: Value, spacing: Value) -> Value: """Create a linear pattern of geometry copies. Args: shape: A 2D region or 3D solid to pattern count: Number of copies (including original) spacing: Vector defining direction and distance between copies Returns: List of geometry copies, each translated by spacing increments """ from yapcad.geom3d import issolid, issurface from yapcad.geom import isgeomlist, isline, isarc data = shape.data n = int(count.data) sp = spacing.data # Detect geometry type and dispatch to appropriate function if issolid(data): from yapcad.geom3d_util import linear_pattern_solid result = linear_pattern_solid(data, n, spacing=sp) return list_val([solid_val(s) for s in result], SOLID) elif issurface(data): from yapcad.geom3d_util import linear_pattern_surface result = linear_pattern_surface(data, n, spacing=sp) return list_val([surface_val(s) for s in result], SURFACE) else: # Assume 2D geometry (region, arc, line, geomlist) from yapcad.geom_util import linear_pattern result = linear_pattern(data, n, spacing=sp) return list_val([region2d_val(g) for g in result], REGION2D) self.register(BuiltinFunction( "radial_pattern", _make_sig("radial_pattern", [UNKNOWN, INT, VECTOR3D, POINT], ListType(UNKNOWN)), _radial_pattern, )) self.register(BuiltinFunction( "linear_pattern", _make_sig("linear_pattern", [UNKNOWN, INT, VECTOR], ListType(UNKNOWN)), _linear_pattern, )) # --- 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) def _centroid(s: Value) -> Value: """Compute centroid of a solid. Estimates centroid from mesh vertex averaging. """ from yapcad.geom3d import issolid from yapcad.geom import point data = s.data if not issolid(data): raise ValueError("centroid requires a solid") # solid structure: ['solid', [surfaces...], procedure, holes] # each surface: ['surface', [vertices...], [normals...], ...] surfaces = data[1] if len(data) > 1 and isinstance(data[1], list) else [] xs, ys, zs = [], [], [] for surf in surfaces: # surf = ['surface', vertices_list, normals_list, ...] if isinstance(surf, list) and len(surf) > 1 and surf[0] == 'surface': vertices = surf[1] for v in vertices: if isinstance(v, (list, tuple)) and len(v) >= 3: try: xs.append(float(v[0])) ys.append(float(v[1])) zs.append(float(v[2])) except (TypeError, ValueError): pass if not xs: return point_val(point(0, 0, 0), is_2d=False) cx = sum(xs) / len(xs) cy = sum(ys) / len(ys) cz = sum(zs) / len(zs) return point_val(point(cx, cy, cz), is_2d=False) def _distance(a: Value, b: Value, tolerance: Value) -> Value: """Compute Euclidean distance between two points.""" from yapcad.geom import dist return float_val(dist(a.data, b.data)) self.register(BuiltinFunction( "centroid", _make_sig("centroid", [SOLID], POINT3D), _centroid, )) self.register(BuiltinFunction( "distance", _make_sig("distance", [POINT, POINT, FLOAT], FLOAT), _distance, )) # 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, )) # Phase 3: Numeric and boolean aggregation functions def _sum_list(lst: Value) -> Value: """Sum all numeric values in a list.""" total = 0.0 for v in lst.data: val = v.data if hasattr(v, 'data') else v total += float(val) return float_val(total) def _product_list(lst: Value) -> Value: """Multiply all numeric values in a list.""" result = 1.0 for v in lst.data: val = v.data if hasattr(v, 'data') else v result *= float(val) return float_val(result) def _any_true(lst: Value) -> Value: """Return True if any element is truthy.""" for v in lst.data: val = v.data if hasattr(v, 'data') else v if val: return bool_val(True) return bool_val(False) def _all_true(lst: Value) -> Value: """Return True if all elements are truthy.""" for v in lst.data: val = v.data if hasattr(v, 'data') else v if not val: return bool_val(False) return bool_val(True) def _min_of(lst: Value) -> Value: """Return minimum value in list.""" if not lst.data: raise ValueError("min_of requires non-empty list") values = [v.data if hasattr(v, 'data') else v for v in lst.data] return float_val(min(values)) def _max_of(lst: Value) -> Value: """Return maximum value in list.""" if not lst.data: raise ValueError("max_of requires non-empty list") values = [v.data if hasattr(v, 'data') else v for v in lst.data] return float_val(max(values)) self.register(BuiltinFunction( "sum", _make_sig("sum", [ListType(FLOAT)], FLOAT), _sum_list, )) self.register(BuiltinFunction( "product", _make_sig("product", [ListType(FLOAT)], FLOAT), _product_list, )) self.register(BuiltinFunction( "any_true", _make_sig("any_true", [ListType(BOOL)], BOOL), _any_true, )) self.register(BuiltinFunction( "all_true", _make_sig("all_true", [ListType(BOOL)], BOOL), _all_true, )) self.register(BuiltinFunction( "min_of", _make_sig("min_of", [ListType(FLOAT)], FLOAT), _min_of, )) self.register(BuiltinFunction( "max_of", _make_sig("max_of", [ListType(FLOAT)], FLOAT), _max_of, )) # --- Fillet and Chamfer Functions --- def _register_fillet_chamfer_functions(self) -> None: """Register fillet and chamfer operations for solid edge finishing.""" def _fillet(s: Value, radius: Value) -> Value: """Apply fillet (rounded edge) to all edges of a solid. Requires pythonocc-core/OCC. Uses the BREP representation attached to the solid for precise edge operations. Args: s: A solid to fillet radius: Fillet radius in model units Returns: A new solid with filleted edges """ from yapcad.brep import ( brep_from_solid, attach_brep_to_solid, fillet_all_edges, occ_available, BrepSolid ) from yapcad.geom3d import solid if not occ_available(): raise RuntimeError("fillet requires pythonocc-core (OCC)") # Get BREP representation from solid brep = brep_from_solid(s.data) if brep is None: raise RuntimeError( "fillet requires a solid with BREP data. " "Use box(), cylinder(), or other BREP-enabled primitives." ) # Apply fillet to all edges r = float(radius.data) filleted_brep = fillet_all_edges(brep, r) # Tessellate and create new yapCAD solid surface = filleted_brep.tessellate() new_solid = solid([surface], [], ['procedure', 'fillet']) attach_brep_to_solid(new_solid, filleted_brep) return solid_val(new_solid) def _chamfer(s: Value, distance: Value) -> Value: """Apply chamfer (beveled edge) to all edges of a solid. Requires pythonocc-core/OCC. Uses the BREP representation attached to the solid for precise edge operations. Args: s: A solid to chamfer distance: Chamfer distance from edge in model units Returns: A new solid with chamfered edges """ from yapcad.brep import ( brep_from_solid, attach_brep_to_solid, chamfer_all_edges, occ_available, BrepSolid ) from yapcad.geom3d import solid if not occ_available(): raise RuntimeError("chamfer requires pythonocc-core (OCC)") # Get BREP representation from solid brep = brep_from_solid(s.data) if brep is None: raise RuntimeError( "chamfer requires a solid with BREP data. " "Use box(), cylinder(), or other BREP-enabled primitives." ) # Apply chamfer to all edges d = float(distance.data) chamfered_brep = chamfer_all_edges(brep, d) # Tessellate and create new yapCAD solid surface = chamfered_brep.tessellate() new_solid = solid([surface], [], ['procedure', 'chamfer']) attach_brep_to_solid(new_solid, chamfered_brep) return solid_val(new_solid) self.register(BuiltinFunction( "fillet", _make_sig("fillet", [SOLID, FLOAT], SOLID), _fillet, )) self.register(BuiltinFunction( "chamfer", _make_sig("chamfer", [SOLID, FLOAT], SOLID), _chamfer, )) # --- Edge Selection and Selective Fillet/Chamfer Functions --- def _register_edge_selection_functions(self) -> None: """Register BREP edge selection and selective fillet/chamfer operations.""" # Edge selection by direction def _select_vertical_edges(brep_solid: Value, tolerance_deg: Value = None) -> Value: """Select edges parallel to the Z axis (vertical). Args: brep_solid: A solid with BREP data tolerance_deg: Angular tolerance in degrees (default 1.0) Returns: List of vertical edges """ from yapcad.brep import brep_from_solid, occ_available from yapcad.brep_edge_select import select_vertical_edges if not occ_available(): raise RuntimeError("select_vertical_edges requires pythonocc-core") brep = brep_from_solid(brep_solid.data) if brep is None: raise RuntimeError("select_vertical_edges requires a solid with BREP data") tol = 1.0 if tolerance_deg is None else float(tolerance_deg.data) edges = select_vertical_edges(brep, tol) return edge_list_val(edges) def _select_horizontal_edges(brep_solid: Value, tolerance_deg: Value = None) -> Value: """Select edges perpendicular to the Z axis (horizontal). Args: brep_solid: A solid with BREP data tolerance_deg: Angular tolerance in degrees (default 1.0) Returns: List of horizontal edges """ from yapcad.brep import brep_from_solid, occ_available from yapcad.brep_edge_select import select_horizontal_edges if not occ_available(): raise RuntimeError("select_horizontal_edges requires pythonocc-core") brep = brep_from_solid(brep_solid.data) if brep is None: raise RuntimeError("select_horizontal_edges requires a solid with BREP data") tol = 1.0 if tolerance_deg is None else float(tolerance_deg.data) edges = select_horizontal_edges(brep, tol) return edge_list_val(edges) def _select_edges_by_direction(brep_solid: Value, direction: Value, tolerance_deg: Value = None) -> Value: """Select edges parallel to a given direction. Args: brep_solid: A solid with BREP data direction: Direction vector (3D) tolerance_deg: Angular tolerance in degrees (default 1.0) Returns: List of edges parallel to the direction """ from yapcad.brep import brep_from_solid, occ_available from yapcad.brep_edge_select import select_edges_by_direction if not occ_available(): raise RuntimeError("select_edges_by_direction requires pythonocc-core") brep = brep_from_solid(brep_solid.data) if brep is None: raise RuntimeError("select_edges_by_direction requires a solid with BREP data") dir_vec = direction.data tol = 1.0 if tolerance_deg is None else float(tolerance_deg.data) edges = select_edges_by_direction(brep, dir_vec, tol) return edge_list_val(edges) # Edge selection by length def _select_edges_by_length(brep_solid: Value, min_length: Value = None, max_length: Value = None) -> Value: """Select edges within a length range. Args: brep_solid: A solid with BREP data min_length: Minimum edge length (optional) max_length: Maximum edge length (optional) Returns: List of edges within the length range """ from yapcad.brep import brep_from_solid, occ_available from yapcad.brep_edge_select import select_edges_by_length if not occ_available(): raise RuntimeError("select_edges_by_length requires pythonocc-core") brep = brep_from_solid(brep_solid.data) if brep is None: raise RuntimeError("select_edges_by_length requires a solid with BREP data") min_len = None if min_length is None else float(min_length.data) max_len = None if max_length is None else float(max_length.data) edges = select_edges_by_length(brep, min_len, max_len) return edge_list_val(edges) # Edge selection by Z position def _select_edges_at_z(brep_solid: Value, z_value: Value, tolerance: Value = None) -> Value: """Select edges at a specific Z height. Args: brep_solid: A solid with BREP data z_value: The Z coordinate to match tolerance: Position tolerance (default 0.001) Returns: List of edges at the specified Z height """ from yapcad.brep import brep_from_solid, occ_available from yapcad.brep_edge_select import select_edges_at_z if not occ_available(): raise RuntimeError("select_edges_at_z requires pythonocc-core") brep = brep_from_solid(brep_solid.data) if brep is None: raise RuntimeError("select_edges_at_z requires a solid with BREP data") z = float(z_value.data) tol = 0.001 if tolerance is None else float(tolerance.data) edges = select_edges_at_z(brep, z, tol) return edge_list_val(edges) def _select_edges_in_z_range(brep_solid: Value, z_min: Value, z_max: Value, tolerance: Value = None) -> Value: """Select edges within a Z range. Args: brep_solid: A solid with BREP data z_min: Minimum Z coordinate z_max: Maximum Z coordinate tolerance: Position tolerance (default 0.001) Returns: List of edges within the Z range """ from yapcad.brep import brep_from_solid, occ_available from yapcad.brep_edge_select import select_edges_in_z_range if not occ_available(): raise RuntimeError("select_edges_in_z_range requires pythonocc-core") brep = brep_from_solid(brep_solid.data) if brep is None: raise RuntimeError("select_edges_in_z_range requires a solid with BREP data") z1 = float(z_min.data) z2 = float(z_max.data) tol = 0.001 if tolerance is None else float(tolerance.data) edges = select_edges_in_z_range(brep, z1, z2, tol) return edge_list_val(edges) def _select_top_edges(brep_solid: Value, tolerance: Value = None) -> Value: """Select edges at the maximum Z height of the solid. Args: brep_solid: A solid with BREP data tolerance: Position tolerance (default 0.001) Returns: List of edges at the top of the solid """ from yapcad.brep import brep_from_solid, occ_available from yapcad.brep_edge_select import select_top_edges if not occ_available(): raise RuntimeError("select_top_edges requires pythonocc-core") brep = brep_from_solid(brep_solid.data) if brep is None: raise RuntimeError("select_top_edges requires a solid with BREP data") tol = 0.001 if tolerance is None else float(tolerance.data) edges = select_top_edges(brep, tol) return edge_list_val(edges) def _select_bottom_edges(brep_solid: Value, tolerance: Value = None) -> Value: """Select edges at the minimum Z height of the solid. Args: brep_solid: A solid with BREP data tolerance: Position tolerance (default 0.001) Returns: List of edges at the bottom of the solid """ from yapcad.brep import brep_from_solid, occ_available from yapcad.brep_edge_select import select_bottom_edges if not occ_available(): raise RuntimeError("select_bottom_edges requires pythonocc-core") brep = brep_from_solid(brep_solid.data) if brep is None: raise RuntimeError("select_bottom_edges requires a solid with BREP data") tol = 0.001 if tolerance is None else float(tolerance.data) edges = select_bottom_edges(brep, tol) return edge_list_val(edges) # Edge set operations def _union_edges(edges1: Value, edges2: Value) -> Value: """Combine multiple edge lists, removing duplicates. Args: edges1: First list of edges edges2: Second list of edges Returns: Combined list of unique edges """ from yapcad.brep_edge_select import union_edges result = union_edges(edges1.data, edges2.data) return edge_list_val(result) def _intersect_edges(edges1: Value, edges2: Value) -> Value: """Find edges common to both lists. Args: edges1: First list of edges edges2: Second list of edges Returns: List of edges present in both input lists """ from yapcad.brep_edge_select import intersect_edges result = intersect_edges(edges1.data, edges2.data) return edge_list_val(result) def _subtract_edges(base_edges: Value, to_remove: Value) -> Value: """Remove edges from a list. Args: base_edges: The original list of edges to_remove: Edges to remove from the base list Returns: List of edges from base_edges not in to_remove """ from yapcad.brep_edge_select import subtract_edges result = subtract_edges(base_edges.data, to_remove.data) return edge_list_val(result) # Selective fillet and chamfer def _fillet_edges(brep_solid: Value, edges: Value, radius: Value) -> Value: """Apply fillet to selected edges only. Args: brep_solid: A solid with BREP data edges: List of edges to fillet radius: Fillet radius Returns: A new solid with filleted edges """ from yapcad.brep import ( brep_from_solid, attach_brep_to_solid, fillet_edges, occ_available ) from yapcad.geom3d import solid if not occ_available(): raise RuntimeError("fillet_edges requires pythonocc-core") brep = brep_from_solid(brep_solid.data) if brep is None: raise RuntimeError("fillet_edges requires a solid with BREP data") r = float(radius.data) edge_list = edges.data filleted_brep = fillet_edges(brep, edge_list, r) surface = filleted_brep.tessellate() new_solid = solid([surface], [], ['procedure', 'fillet_edges']) attach_brep_to_solid(new_solid, filleted_brep) return solid_val(new_solid) def _chamfer_edges(brep_solid: Value, edges: Value, distance: Value) -> Value: """Apply chamfer to selected edges only. Args: brep_solid: A solid with BREP data edges: List of edges to chamfer distance: Chamfer distance from edge Returns: A new solid with chamfered edges """ from yapcad.brep import ( brep_from_solid, attach_brep_to_solid, chamfer_edges, occ_available ) from yapcad.geom3d import solid if not occ_available(): raise RuntimeError("chamfer_edges requires pythonocc-core") brep = brep_from_solid(brep_solid.data) if brep is None: raise RuntimeError("chamfer_edges requires a solid with BREP data") d = float(distance.data) edge_list = edges.data chamfered_brep = chamfer_edges(brep, edge_list, d) surface = chamfered_brep.tessellate() new_solid = solid([surface], [], ['procedure', 'chamfer_edges']) attach_brep_to_solid(new_solid, chamfered_brep) return solid_val(new_solid) # Register all edge selection functions # Selection by direction self.register(BuiltinFunction( "select_vertical_edges", _make_sig("select_vertical_edges", [SOLID, FLOAT], ListType(EDGE)), _select_vertical_edges, )) self.register(BuiltinFunction( "select_horizontal_edges", _make_sig("select_horizontal_edges", [SOLID, FLOAT], ListType(EDGE)), _select_horizontal_edges, )) self.register(BuiltinFunction( "select_edges_by_direction", _make_sig("select_edges_by_direction", [SOLID, VECTOR3D, FLOAT], ListType(EDGE)), _select_edges_by_direction, )) # Selection by length self.register(BuiltinFunction( "select_edges_by_length", _make_sig("select_edges_by_length", [SOLID, FLOAT, FLOAT], ListType(EDGE)), _select_edges_by_length, )) # Selection by Z position self.register(BuiltinFunction( "select_edges_at_z", _make_sig("select_edges_at_z", [SOLID, FLOAT, FLOAT], ListType(EDGE)), _select_edges_at_z, )) self.register(BuiltinFunction( "select_edges_in_z_range", _make_sig("select_edges_in_z_range", [SOLID, FLOAT, FLOAT, FLOAT], ListType(EDGE)), _select_edges_in_z_range, )) self.register(BuiltinFunction( "select_top_edges", _make_sig("select_top_edges", [SOLID, FLOAT], ListType(EDGE)), _select_top_edges, )) self.register(BuiltinFunction( "select_bottom_edges", _make_sig("select_bottom_edges", [SOLID, FLOAT], ListType(EDGE)), _select_bottom_edges, )) # Edge set operations self.register(BuiltinFunction( "union_edges", _make_sig("union_edges", [ListType(EDGE), ListType(EDGE)], ListType(EDGE)), _union_edges, )) self.register(BuiltinFunction( "intersect_edges", _make_sig("intersect_edges", [ListType(EDGE), ListType(EDGE)], ListType(EDGE)), _intersect_edges, )) self.register(BuiltinFunction( "subtract_edges", _make_sig("subtract_edges", [ListType(EDGE), ListType(EDGE)], ListType(EDGE)), _subtract_edges, )) # Selective fillet and chamfer self.register(BuiltinFunction( "fillet_edges", _make_sig("fillet_edges", [SOLID, ListType(EDGE), FLOAT], SOLID), _fillet_edges, )) self.register(BuiltinFunction( "chamfer_edges", _make_sig("chamfer_edges", [SOLID, ListType(EDGE), FLOAT], SOLID), _chamfer_edges, ))
# 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)