diff --git a/CHANGELOG.rst b/CHANGELOG.rst index be36506..3ca19b5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,48 @@ Changelog ========= +Version 1.0.1 (Development) +============================ + +what's new: +----------- + + - **Helical Extrusion**: New ``helical_extrude()`` function in ``yapcad.geom3d_util`` + creates smooth helical/twisted extrusions using high-resolution lofting. Ideal for + helical gears, twisted columns, and spiral features. Requires pythonocc-core. + + - **Pattern Functions**: New pattern generation functions for creating arrays of geometry: + + - ``radial_pattern()`` in ``yapcad.geom_util`` - Creates circular patterns of 2D geometry + - ``linear_pattern()`` in ``yapcad.geom_util`` - Creates linear arrays of 2D geometry + - ``radial_pattern_solid()`` in ``yapcad.geom3d_util`` - Creates circular patterns of 3D solids + - ``linear_pattern_solid()`` in ``yapcad.geom3d_util`` - Creates linear arrays of 3D solids + - ``radial_pattern_surface()`` in ``yapcad.geom3d_util`` - Creates circular patterns of surfaces + - ``linear_pattern_surface()`` in ``yapcad.geom3d_util`` - Creates linear arrays of surfaces + + - **OCC Helix Helper**: New ``make_occ_helix()`` function creates mathematically exact + helix curves using OpenCascade's 2D parametric curve on cylindrical surface technique. + Used internally by ``helical_extrude()`` but also available for advanced users. + + - **Edge Operations**: New fillet and chamfer functions in ``yapcad.brep``: + + - ``fillet_all_edges()`` - Apply rounded fillets to all edges of a BREP solid + - ``fillet_edges()`` - Apply fillets to specific selected edges + - ``chamfer_all_edges()`` - Apply beveled chamfers to all edges + - ``chamfer_edges()`` - Apply chamfers to specific selected edges + - DSL builtins: ``fillet(solid, radius)`` and ``chamfer(solid, distance)`` + + - **3D Text Support**: New ``yapcad.text3d`` module for creating 3D text geometry: + + - ``text_to_solid()`` - Generate extruded 3D text from strings + - ``text_to_surface()`` - Generate text as a flat surface + - TrueType font support via freetype-py (with block font fallback) + - System font discovery across macOS, Linux, and Windows + + - **Documentation Improvements**: All new functions include comprehensive docstrings + with parameter descriptions, return values, usage examples, and notes following + Sphinx documentation standards. + Version 1.0.0rc1 (2025-12-30) ============================= diff --git a/README.rst b/README.rst index 139545b..cc2ae2e 100644 --- a/README.rst +++ b/README.rst @@ -59,6 +59,10 @@ pipelines. Highlights from the 1.0 release cycle include: proper threading geometry. * **Adaptive Sweeps**: ``sweep_adaptive()`` with tangent-tracking profile orientation for complex path sweeps. +* **Helical Extrusion**: ``helical_extrude()`` creates smooth twisted extrusions with + true helical surfaces for gears, columns, and spiral features. +* **Pattern Generation**: ``radial_pattern()`` and ``linear_pattern()`` functions for + creating circular and linear arrays of 2D/3D geometry, solids, and surfaces. * ``.ycpkg`` project packaging with manifest, geometry JSON, exports, and metadata. * **Package signing** with GPG and SSH key support for cryptographic verification. * **Validation schemas** for test definitions and solver integration. diff --git a/docs/index.rst b/docs/index.rst index 64cc254..8bea681 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -62,8 +62,10 @@ package format enables provenance tracking and reproducible designs. helpers and ear-cut tessellation), ``yapcad.geometry_checks`` (mesh validation), ``yapcad.metadata`` (surface/solid provenance), ``yapcad.geom3d_util.stack_solids`` and ``cutaway_solid_x`` (layout and - section tools), ``yapcad.boolean.native`` (production-ready boolean - engine), and ``yapcad.io`` for validated STL/STEP export. + section tools), ``yapcad.geom3d_util.helical_extrude`` (smooth helical + extrusions), ``yapcad.geom_util.radial_pattern`` and ``linear_pattern`` + (geometry array generation), ``yapcad.boolean.native`` (production-ready + boolean engine), and ``yapcad.io`` for validated STL/STEP export. Contents ======== diff --git a/docs/yapCADone.rst b/docs/yapCADone.rst index e1992a4..ad5771a 100644 --- a/docs/yapCADone.rst +++ b/docs/yapCADone.rst @@ -37,6 +37,8 @@ This section reflects the **actual current state** of the codebase as of version - 2D boolean operations (union, difference, intersection) with proper hole accumulation - Curve types: ellipse, catmull-rom splines, NURBS, parabola, hyperbola - Adaptive sweep operations with tangent-tracking profile orientation +- Helical extrusion with smooth twisted surfaces (``helical_extrude()``) +- Pattern generation: radial and linear patterns for 2D geometry, 3D solids, and surfaces - 627 regression tests across geometry, DSL, import/export, packaging, and validation **Partially Implemented:** diff --git a/examples/fillet_chamfer_demo.dsl b/examples/fillet_chamfer_demo.dsl new file mode 100644 index 0000000..c5705b5 --- /dev/null +++ b/examples/fillet_chamfer_demo.dsl @@ -0,0 +1,34 @@ +# Fillet and Chamfer Demo +# Demonstrates the new fillet() and chamfer() DSL operations +# +# Run with: python -m yapcad.dsl.interpreter examples/fillet_chamfer_demo.dsl +# Or view in OpenGL: python -m yapcad.dsl.interpreter examples/fillet_chamfer_demo.dsl --view + +# Create a simple box +base_box: solid = box(30.0, 20.0, 10.0) + +# Apply fillet to round all edges +# The fillet radius determines how rounded the edges become +fillet_radius: float = 2.0 +filleted_box: solid = fillet(base_box, fillet_radius) + +# Move it for display +filleted_display: solid = translate(filleted_box, -20.0, 0.0, 0.0) + +# Create another box for chamfer demo +chamfer_box: solid = box(30.0, 20.0, 10.0) + +# Apply chamfer to bevel all edges +# The chamfer distance determines the size of the bevel +chamfer_distance: float = 1.5 +chamfered_box: solid = chamfer(chamfer_box, chamfer_distance) + +# Move it for display +chamfered_display: solid = translate(chamfered_box, 20.0, 0.0, 0.0) + +# Combine for output +# Left: filleted box, Right: chamfered box +result: solid = union(filleted_display, chamfered_display) + +# Export +export result diff --git a/src/yapcad/brep.py b/src/yapcad/brep.py index 5aa09e3..89c181a 100644 --- a/src/yapcad/brep.py +++ b/src/yapcad/brep.py @@ -272,6 +272,85 @@ def transform_brep_shape(brep: "BrepSolid", trsf) -> Optional["BrepSolid"]: except Exception: pass return BrepSolid(shape) + + +def apply_matrix_to_brep_solid(solid: list, matrix) -> None: + """Apply a yapCAD transformation matrix to the stored BREP shape. + + This function converts a yapCAD Matrix (4x4 homogeneous transformation) + to an OCC gp_Trsf and applies it to the BREP data attached to the solid. + + Args: + solid: A yapCAD solid with BREP metadata attached + matrix: A yapCAD Matrix instance (from yapcad.xform) + + Note: + The matrix must represent an affine transformation (rotation, translation, + uniform scale, or composition thereof). Non-uniform scaling will cause + the BREP to be dropped. + """ + if not occ_available() or gp_Trsf is None: + return + + brep = brep_from_solid(solid) + if brep is None: + return + + # Extract transformation parameters from the matrix + # yapCAD matrices are 4x4 homogeneous transformation matrices + # Row-major format: m[row][col] + try: + m = matrix.m if hasattr(matrix, 'm') else matrix + + # Check for non-uniform scale (approximate check) + # Extract the 3x3 rotation/scale submatrix + sx = math.sqrt(m[0][0]**2 + m[1][0]**2 + m[2][0]**2) + sy = math.sqrt(m[0][1]**2 + m[1][1]**2 + m[2][1]**2) + sz = math.sqrt(m[0][2]**2 + m[1][2]**2 + m[2][2]**2) + + # If non-uniform scale, we cannot preserve BREP with gp_Trsf + # (would need gp_GTrsf which changes topology) + eps = 1e-6 + if abs(sx - sy) > eps or abs(sy - sz) > eps or abs(sx - sz) > eps: + # Non-uniform scale detected - clear BREP data to avoid stale/inconsistent state + _clear_brep_data(solid) + return + + # Build OCC transformation matrix + # gp_Trsf uses column vectors, so we need to transpose + trsf = gp_Trsf() + + # Set the transformation values (1-indexed in OCC) + # OCC gp_Trsf expects: row, col (1-indexed), value + # The matrix format is: + # [R11 R12 R13 Tx] + # [R21 R22 R23 Ty] + # [R31 R32 R33 Tz] + # [0 0 0 1 ] + trsf.SetValues( + m[0][0], m[0][1], m[0][2], m[0][3], + m[1][0], m[1][1], m[1][2], m[1][3], + m[2][0], m[2][1], m[2][2], m[2][3] + ) + + _apply_trsf_to_brep(solid, trsf) + except Exception: + # If matrix conversion fails, BREP is not updated + pass + + +def _clear_brep_data(solid: list) -> None: + """Remove BREP data from a solid to avoid stale/inconsistent state.""" + meta = get_solid_metadata(solid, create=False) + if meta is None: + return + solid_id = meta.get('entityId') + if solid_id and solid_id in _BREP_SOLID_CACHE: + del _BREP_SOLID_CACHE[solid_id] + if 'brep' in meta: + del meta['brep'] + + class BrepVertex: """A wrapper for a TopoDS_Vertex.""" def __init__(self, shape: TopoDS_Vertex): @@ -337,7 +416,8 @@ def tessellate(self, deflection=0.5, _debug=False): _face_count = 0 while explorer.More(): _face_count += 1 - face = explorer.Current() + # Cast to TopoDS_Face for OCP compatibility (pythonocc may not need this) + face = topods.Face(explorer.Current()) loc = TopLoc_Location() triangulation = BRep_Tool.Triangulation(face, loc) @@ -423,6 +503,218 @@ def tessellate(self, deflection=0.5, _debug=False): return ['surface', all_vertices, all_normals, triangle_lists, [], []] +# ============================================================================= +# Fillet and Chamfer Operations +# ============================================================================= + +def _get_all_edges(shape) -> list: + """Extract all edges from a TopoDS_Shape. + + Returns a list of TopoDS_Edge objects. + """ + require_occ() + from OCC.Core.TopAbs import TopAbs_EDGE + + edges = [] + explorer = TopExp_Explorer(shape, TopAbs_EDGE) + while explorer.More(): + edge = explorer.Current() + edges.append(edge) + explorer.Next() + return edges + + +def fillet_all_edges(brep_solid: BrepSolid, radius: float) -> BrepSolid: + """Apply fillet (rounded edge) to all edges of a BREP solid. + + Args: + brep_solid: A BrepSolid object containing the shape to fillet + radius: Fillet radius in model units + + Returns: + A new BrepSolid with filleted edges + + Raises: + RuntimeError: If OCC is not available or fillet operation fails + """ + require_occ() + + from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeFillet + from OCC.Core.TopAbs import TopAbs_EDGE + + shape = brep_solid.shape + fillet_maker = BRepFilletAPI_MakeFillet(shape) + + # Add all edges with the specified radius + edges = _get_all_edges(shape) + if not edges: + # No edges to fillet, return original + return brep_solid + + for edge in edges: + # Cast to TopoDS_Edge if needed + if topods is not None: + try: + edge = topods.Edge(edge) + except Exception: + continue + try: + fillet_maker.Add(radius, edge) + except Exception: + # Skip edges that can't be filleted (e.g., too small) + continue + + # Build the filleted shape + try: + fillet_maker.Build() + if not fillet_maker.IsDone(): + raise RuntimeError("Fillet operation failed - IsDone() returned False") + result_shape = fillet_maker.Shape() + except Exception as e: + raise RuntimeError(f"Fillet operation failed: {e}") + + return BrepSolid(result_shape) + + +def chamfer_all_edges(brep_solid: BrepSolid, distance: float) -> BrepSolid: + """Apply chamfer (beveled edge) to all edges of a BREP solid. + + Creates symmetric 45-degree chamfers with equal distances on both faces. + + Args: + brep_solid: A BrepSolid object containing the shape to chamfer + distance: Chamfer distance from the edge in model units + + Returns: + A new BrepSolid with chamfered edges + + Raises: + RuntimeError: If OCC is not available or chamfer operation fails + """ + require_occ() + + from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer + + shape = brep_solid.shape + chamfer_maker = BRepFilletAPI_MakeChamfer(shape) + + edges = _get_all_edges(shape) + if not edges: + # No edges to chamfer, return original + return brep_solid + + for edge in edges: + # Cast to TopoDS_Edge if needed + if topods is not None: + try: + edge = topods.Edge(edge) + except Exception: + continue + + try: + # Add symmetric chamfer (single distance applies to both faces equally) + chamfer_maker.Add(distance, edge) + except Exception: + # Skip edges that can't be chamfered + continue + + # Build the chamfered shape + try: + chamfer_maker.Build() + if not chamfer_maker.IsDone(): + raise RuntimeError("Chamfer operation failed - IsDone() returned False") + result_shape = chamfer_maker.Shape() + except Exception as e: + raise RuntimeError(f"Chamfer operation failed: {e}") + + return BrepSolid(result_shape) + + +def fillet_edges(brep_solid: BrepSolid, edges: list, radius: float) -> BrepSolid: + """Apply fillet to specific edges of a BREP solid. + + Args: + brep_solid: A BrepSolid object containing the shape to fillet + edges: List of BrepEdge objects to fillet + radius: Fillet radius in model units + + Returns: + A new BrepSolid with filleted edges + + Raises: + RuntimeError: If OCC is not available or fillet operation fails + """ + require_occ() + + from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeFillet + + shape = brep_solid.shape + fillet_maker = BRepFilletAPI_MakeFillet(shape) + + if not edges: + return brep_solid + + for brep_edge in edges: + edge = brep_edge.shape if isinstance(brep_edge, BrepEdge) else brep_edge + try: + fillet_maker.Add(radius, edge) + except Exception: + continue + + try: + fillet_maker.Build() + if not fillet_maker.IsDone(): + raise RuntimeError("Fillet operation failed - IsDone() returned False") + result_shape = fillet_maker.Shape() + except Exception as e: + raise RuntimeError(f"Fillet operation failed: {e}") + + return BrepSolid(result_shape) + + +def chamfer_edges(brep_solid: BrepSolid, edges: list, distance: float) -> BrepSolid: + """Apply chamfer to specific edges of a BREP solid. + + Args: + brep_solid: A BrepSolid object containing the shape to chamfer + edges: List of BrepEdge objects to chamfer + distance: Chamfer distance from the edge in model units + + Returns: + A new BrepSolid with chamfered edges + + Raises: + RuntimeError: If OCC is not available or chamfer operation fails + """ + require_occ() + + from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer + + shape = brep_solid.shape + chamfer_maker = BRepFilletAPI_MakeChamfer(shape) + + if not edges: + return brep_solid + + for brep_edge in edges: + edge = brep_edge.shape if isinstance(brep_edge, BrepEdge) else brep_edge + try: + # Add symmetric chamfer (single distance) + chamfer_maker.Add(distance, edge) + except Exception: + continue + + try: + chamfer_maker.Build() + if not chamfer_maker.IsDone(): + raise RuntimeError("Chamfer operation failed - IsDone() returned False") + result_shape = chamfer_maker.Shape() + except Exception as e: + raise RuntimeError(f"Chamfer operation failed: {e}") + + return BrepSolid(result_shape) + + def is_brep(obj): """Check if an object is a yapCAD BREP object.""" return isinstance(obj, (BrepVertex, BrepEdge, BrepFace, BrepSolid)) @@ -439,4 +731,9 @@ def is_brep(obj): "attach_brep_to_solid", "brep_from_solid", "has_brep_data", + "apply_matrix_to_brep_solid", + "fillet_all_edges", + "chamfer_all_edges", + "fillet_edges", + "chamfer_edges", ] diff --git a/src/yapcad/dsl/introspection.py b/src/yapcad/dsl/introspection.py index a239dcb..6539475 100644 --- a/src/yapcad/dsl/introspection.py +++ b/src/yapcad/dsl/introspection.py @@ -109,6 +109,7 @@ "sphere": "Create a sphere solid from radius", "cone": "Create a cone/frustum solid from two radii and height", "involute_gear": "Create an involute spur gear solid", + "herringbone_gear": "Create a herringbone (double-helix) gear with smooth tooth surfaces", # Boolean Operations "union": "Combine two or more solids into one", diff --git a/src/yapcad/dsl/runtime/builtins.py b/src/yapcad/dsl/runtime/builtins.py index ad77f06..4cec1bd 100644 --- a/src/yapcad/dsl/runtime/builtins.py +++ b/src/yapcad/dsl/runtime/builtins.py @@ -109,6 +109,7 @@ def _register_all(self) -> None: self._register_boolean_functions() self._register_query_functions() self._register_utility_functions() + self._register_fillet_chamfer_functions() # --- Math Functions --- @@ -424,6 +425,13 @@ def _apply(t: Value, shape: Value) -> Value: # 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 @@ -1671,6 +1679,254 @@ def _involute_gear(teeth: Value, module_mm: Value, pressure_angle: Value, _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 integrated hub using OCC booleans for speed. + + Uses OCC BREP booleans directly (staying in OCC-land) rather than + mesh-based booleans, which is significantly faster. + + Args: + teeth: Number of teeth + module_mm: Module in mm + face_width: Gear face width + helix_angle: Helix angle in degrees + hub_diameter: Hub diameter in mm + hub_height: 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 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_r = float(bolt_circle.data) / 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 + + # 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() + + # Create hub cylinder at z=-hub_h to z=0 + hub_axis = gp_Ax2(gp_Pnt(0, 0, -hub_h), gp_Dir(0, 0, 1)) + hub_shape = BRepPrimAPI_MakeCylinder(hub_axis, hub_d/2.0, hub_h).Shape() + + # Fuse gear and hub + fuse_hub = BRepAlgoAPI_Fuse(gear_shape, hub_shape) + if not fuse_hub.IsDone(): + raise RuntimeError("Failed to fuse gear and hub") + body_shape = fuse_hub.Shape() + + # Create and subtract bolt holes + 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 hole + center_axis = gp_Ax2(gp_Pnt(0, 0, -hub_h - 1.0), gp_Dir(0, 0, 1)) + center_hole = BRepPrimAPI_MakeCylinder(center_axis, hole_d/2.0, hole_depth).Shape() + cut_center = BRepAlgoAPI_Cut(body_shape, center_hole) + if cut_center.IsDone(): + body_shape = cut_center.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, + )) + # --- Fastener Functions --- def _register_fastener_functions(self) -> None: @@ -1917,6 +2173,86 @@ def _intersection_all(solids: Value) -> Value: _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: @@ -2179,6 +2515,104 @@ def _max_of(lst: Value) -> Value: _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, + )) + # Global singleton registry _registry: Optional[BuiltinRegistry] = None diff --git a/src/yapcad/dsl/symbols.py b/src/yapcad/dsl/symbols.py index f0214d7..1e2e83f 100644 --- a/src/yapcad/dsl/symbols.py +++ b/src/yapcad/dsl/symbols.py @@ -435,6 +435,27 @@ def _init_builtins(self) -> None: ("face_width", FLOAT, None), ], SOLID) + # Herringbone gear - double-helix gear with smooth tooth surfaces + self._register_builtin("herringbone_gear", [ + ("teeth", INT, None), + ("module_mm", FLOAT, None), + ("face_width", FLOAT, None), + ("helix_angle", FLOAT, None), + ], SOLID) + + # Sun gear with integrated hub - optimized to avoid expensive boolean + self._register_builtin("sun_gear_with_hub", [ + ("teeth", INT, None), + ("module_mm", FLOAT, None), + ("face_width", FLOAT, None), + ("helix_angle", FLOAT, None), + ("hub_diameter", FLOAT, None), + ("hub_height", FLOAT, None), + ("bolt_circle", FLOAT, None), + ("num_bolts", INT, None), + ("bolt_hole_diameter", FLOAT, None), + ], SOLID) + # Fasteners - hex bolts and nuts from catalog # Metric fasteners (ISO 4014/4017 bolts, ISO 4032 nuts) self._register_builtin("metric_hex_bolt", [ @@ -491,6 +512,17 @@ def _init_builtins(self) -> None: ("solids", make_list_type(SOLID), None), ], SOLID) + # Fillet and chamfer operations (OCC-based edge finishing) + self._register_builtin("fillet", [ + ("s", SOLID, None), + ("radius", FLOAT, None), + ], SOLID) + + self._register_builtin("chamfer", [ + ("s", SOLID, None), + ("distance", FLOAT, None), + ], SOLID) + # Pattern operations self._register_builtin("radial_pattern", [ ("shape", UNKNOWN, None), # Any geometry diff --git a/src/yapcad/geom3d_util.py b/src/yapcad/geom3d_util.py index b2bdcbb..e4aeb36 100644 --- a/src/yapcad/geom3d_util.py +++ b/src/yapcad/geom3d_util.py @@ -14,12 +14,74 @@ Utility functions to support 3D geometry in yapCAD ================================================== -This module is mostly a collection of +This module is mostly a collection of parametric soids and surfaces, and supporting functions. """ +def adaptive_arc_segments(radius, chord_error=0.1, min_segments=12, max_segments=360): + """ + Calculate the optimal number of arc segments for a given radius + to achieve a target maximum chord error (deviation from true circle). + + This ensures that larger objects get more segments for smooth surfaces, + while small features don't waste polygons. + + :param radius: radius of the circle/arc in mm + :param chord_error: maximum acceptable chord error in mm (default 0.1mm) + :param min_segments: minimum number of segments (default 12) + :param max_segments: maximum number of segments (default 360) + :returns: optimal number of segments as an integer + + The chord error is the distance between the actual circle and the + straight line segment connecting two adjacent points. For a circle + of radius r and angle θ between segments: + chord_error = r * (1 - cos(θ/2)) + + Solving for θ: + θ = 2 * arccos(1 - chord_error/r) + segments = 2π / θ + + Example: + - 5mm radius: ~31 segments (for 0.1mm error) + - 170mm radius: ~183 segments (for 0.1mm error) + """ + if radius <= 0 or chord_error <= 0: + return 36 # fallback default + + # Prevent math domain error when error >= radius + # (entire radius fits within error tolerance) + ratio = min(chord_error / radius, 0.999999) + + # Calculate angle subtended by each segment + theta_rad = 2 * math.acos(1 - ratio) + + # Calculate number of segments for full circle + segments = int(math.ceil(2 * math.pi / theta_rad)) + + # Clamp to reasonable bounds + return max(min_segments, min(max_segments, segments)) + + +def adaptive_angr_from_radius(radius, chord_error=0.1, min_angr=1.0, max_angr=10.0): + """ + Calculate angular resolution (degrees per segment) adaptively based on radius. + + This is the inverse of adaptive_arc_segments, providing the angular resolution + for functions that take 'angr' parameter instead of segment count. + + :param radius: radius of the circle/arc in mm + :param chord_error: maximum acceptable chord error in mm (default 0.1mm) + :param min_angr: minimum angular resolution in degrees (default 1.0) + :param max_angr: maximum angular resolution in degrees (default 10.0) + :returns: optimal angular resolution in degrees + """ + segments = adaptive_arc_segments(radius, chord_error) + angr = 360.0 / segments + return max(min_angr, min(max_angr, angr)) + + def sphere2cartesian(lat,lon,rad): """ Convert spherical polar coordinates to Cartesian coordinates for a @@ -354,14 +416,24 @@ def prism(length,width,height,center=point(0,0,0)): pass return sol -def circleSurface(center,radius,angr=10,zup=True): +def circleSurface(center,radius,angr=None,zup=True,chord_error=0.1): """make a circular surface centered at ``center`` lying in the XY plane with normals pointing in the positive z direction if ``zup - == True``, negative z otherwise""" + == True``, negative z otherwise + + :param center: center point of circle + :param radius: radius of circle + :param angr: angular resolution in degrees (if None, use adaptive resolution) + :param zup: if True, normal points +Z, else -Z + :param chord_error: max chord error for adaptive resolution (default 0.1mm) + """ - if angr < 1 or angr > 45: + if angr is None: + # Use adaptive resolution based on radius + angr = adaptive_angr_from_radius(radius, chord_error) + elif angr < 1 or angr > 45: raise ValueError('angular resolution must be between 1 and 45 degrees') - + samples = round(360.0/angr) angr = 360.0/samples basep=[center] @@ -400,7 +472,7 @@ def circleSurface(center,radius,angr=10,zup=True): surf[5] = [] return surf -def conic(baser,topr,height, center=point(0,0,0),angr=10): +def conic(baser,topr,height, center=point(0,0,0),angr=None,chord_error=0.1): """Make a conic frustum splid, center is center of first 'base' circle, main axis aligns with positive z. This function can be @@ -416,14 +488,23 @@ def conic(baser,topr,height, center=point(0,0,0),angr=10): ``height`` is distance from base to top, must be greater than epsilon. - + ``center`` is the location of the center of the base. ``angr`` is the requested angular resolution in degrees for - sampling circles. Actual angular resolution will be - ``360/round(360/angr)`` + sampling circles. If None (default), adaptive resolution is used + based on the larger of baser and topr. Actual angular resolution + will be ``360/round(360/angr)`` + + ``chord_error`` is the maximum chord error in mm for adaptive + resolution (default 0.1mm), ignored if angr is specified. """ + # Use adaptive resolution based on larger radius if angr not specified + if angr is None: + max_radius = max(baser, topr if topr >= epsilon else baser) + angr = adaptive_angr_from_radius(max_radius, chord_error) + call = f"yapcad.geom3d_util.conic({baser},{topr},{height},{center},{angr})" if baser < epsilon: raise ValueError('bad base radius for conic') @@ -438,13 +519,13 @@ def conic(baser,topr,height, center=point(0,0,0),angr=10): if height < epsilon: raise ValueError('bad height in conic') - baseS = circleSurface(center,baser,zup=False) + baseS = circleSurface(center,baser,angr=angr,zup=False) baseV = baseS[1] ll = len(baseV) - + if not toppoint: topS = circleSurface(add(center,point(0,0,height)), - topr,zup=True) + topr,angr=angr,zup=True) topV = topS[1] cylV = baseV[1:] + topV[1:] ll = ll-1 @@ -591,7 +672,7 @@ def _safe_radius(z): return None -def makeRevolutionSurface(contour,zStart,zEnd,steps,arcSamples=36,*,return_brep=False): +def makeRevolutionSurface(contour,zStart,zEnd,steps,arcSamples=None,*,chord_error=0.1,return_brep=False): """ Generate a surface of revolution by sampling a contour function. @@ -599,7 +680,9 @@ def makeRevolutionSurface(contour,zStart,zEnd,steps,arcSamples=36,*,return_brep= :param zStart: lower bound for the ``z`` interval :param zEnd: upper bound for the ``z`` interval :param steps: number of contour samples between ``zStart`` and ``zEnd`` - :param arcSamples: number of samples around the revolution arc + :param arcSamples: number of samples around the revolution arc. + If None (default), adaptive resolution is used. + :param chord_error: maximum chord error for adaptive resolution (default 0.1mm) :returns: ``['surface', vertices, normals, faces]`` list representing the surface """ @@ -609,6 +692,16 @@ def makeRevolutionSurface(contour,zStart,zEnd,steps,arcSamples=36,*,return_brep= zRange = zEnd-zStart zD = zRange/steps + # Use adaptive resolution if arcSamples not specified + if arcSamples is None: + # Sample the contour to find maximum radius + max_radius = 0 + for i in range(steps + 1): + z = i * zD + zStart + r = contour(z) + max_radius = max(max_radius, r) + arcSamples = adaptive_arc_segments(max_radius, chord_error) + degStep = 360.0/arcSamples radStep = pi2/arcSamples @@ -919,14 +1012,18 @@ def _contour_r(z_val): return surf, brep_shape -def makeRevolutionSolid(contour, zStart, zEnd, steps, arcSamples=36, metadata=None): +def makeRevolutionSolid(contour, zStart, zEnd, steps, arcSamples=None, chord_error=0.1, metadata=None): """ Build a solid of revolution around the Z axis. When pythonocc-core is available, a native BREP is attached; otherwise we fall back to the tessellated representation. + + :param arcSamples: number of samples around the revolution arc. + If None (default), adaptive resolution is used. + :param chord_error: maximum chord error for adaptive resolution (default 0.1mm) """ surf, brep_shape = makeRevolutionSurface( - contour, zStart, zEnd, steps, arcSamples=arcSamples, return_brep=True + contour, zStart, zEnd, steps, arcSamples=arcSamples, chord_error=chord_error, return_brep=True ) call = f"yapcad.geom3d_util.makeRevolutionSolid(contour,{zStart},{zEnd},{steps},{arcSamples})" construction = ['procedure', call] @@ -1335,7 +1432,17 @@ def makeLoftSolid(lower_loop, upper_loop, *, metadata=None): -def _circle_loop(center_xy, radius, minang): +def _circle_loop(center_xy, radius, minang=None, chord_error=0.1): + """Generate a circle loop with adaptive or fixed resolution. + + :param center_xy: (x, y) center coordinates + :param radius: radius of circle + :param minang: minimum angular resolution in degrees. If None, use adaptive. + :param chord_error: maximum chord error for adaptive resolution (default 0.1mm) + """ + if minang is None: + minang = adaptive_angr_from_radius(radius, chord_error) + arc_geom = [arc(point(center_xy[0], center_xy[1]), radius)] loop = geomlist2poly(arc_geom, minang=minang, minlen=0.0) if not loop: @@ -1344,11 +1451,14 @@ def _circle_loop(center_xy, radius, minang): def tube(outer_diameter, wall_thickness, length, - center=None, *, base_point=None, minang=5.0, include_caps=True): + center=None, *, base_point=None, minang=None, chord_error=0.1, include_caps=True): """Create a cylindrical tube solid. ``base_point`` (or legacy ``center`` argument) identifies the base of the cylindrical wall, i.e. the plane where ``z == base_point[2]``. + + :param minang: minimum angular resolution in degrees. If None, use adaptive. + :param chord_error: maximum chord error for adaptive resolution (default 0.1mm) """ if base_point is not None and center is not None: @@ -1367,6 +1477,10 @@ def tube(outer_diameter, wall_thickness, length, if inner_radius <= epsilon: raise ValueError('wall thickness too large for tube') + # Use adaptive resolution based on outer radius if not specified + if minang is None: + minang = adaptive_angr_from_radius(outer_radius, chord_error) + base_z = base_point[2] center_xy = (base_point[0], base_point[1]) @@ -1413,11 +1527,15 @@ def tube(outer_diameter, wall_thickness, length, def conic_tube(bottom_outer_diameter, top_outer_diameter, wall_thickness, - length, center=None, *, base_point=None, minang=5.0, include_caps=True): + length, center=None, *, base_point=None, minang=None, chord_error=0.1, include_caps=True): """Create a conic tube with varying outer diameter. ``base_point`` (or ``center`` legacy argument) marks the axial base of the - frustum (the larger-diameter end when stacked).""" + frustum (the larger-diameter end when stacked). + + :param minang: minimum angular resolution in degrees. If None, use adaptive. + :param chord_error: maximum chord error for adaptive resolution (default 0.1mm) + """ if base_point is not None and center is not None: raise ValueError('specify only base_point (preferred) or center, not both') @@ -1435,6 +1553,11 @@ def conic_tube(bottom_outer_diameter, top_outer_diameter, wall_thickness, if r0_inner <= epsilon or r1_inner <= epsilon: raise ValueError('wall thickness too large for conic tube') + # Use adaptive resolution based on larger outer radius if not specified + if minang is None: + max_radius = max(r0_outer, r1_outer) + minang = adaptive_angr_from_radius(max_radius, chord_error) + base_z = base_point[2] center_xy = (base_point[0], base_point[1]) @@ -2610,3 +2733,463 @@ def _build_wire_from_region2d(region, reverse=False): pass return sld + + +def make_occ_helix(radius, pitch, height, left_hand=False): + """ + Create a true helix wire using 2D parametric curve on cylindrical surface. + + This produces a mathematically exact helix, not a polyline approximation. + Uses the standard OCC technique: 2D line on Geom_CylindricalSurface. + + :param radius: Radius of the helix cylinder + :param pitch: Vertical rise per full turn (360 degrees) + :param height: Total height of the helix + :param left_hand: If True, create left-handed helix (default False = right-handed) + :returns: An OCC TopoDS_Wire representing the helix + + .. note:: + This function requires pythonocc-core and will raise RuntimeError if not available. + + Example:: + + >>> # Create a right-handed helix + >>> helix = make_occ_helix(radius=10.0, pitch=5.0, height=20.0) + >>> # Create a left-handed helix + >>> left_helix = make_occ_helix(radius=10.0, pitch=5.0, height=20.0, left_hand=True) + """ + if not occ_available(): + raise RuntimeError("make_occ_helix requires pythonocc-core") + + from OCC.Core.gp import gp_Ax3, gp_Pnt, gp_Dir, gp_Pnt2d, gp_Dir2d, gp_Lin2d + from OCC.Core.Geom import Geom_CylindricalSurface + from OCC.Core.GCE2d import GCE2d_MakeSegment + from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeWire + from OCC.Core.BRepLib import BRepLib + + # Create cylindrical surface centered at origin with axis along Z + # gp_Ax3 defines a coordinate system: origin, Z direction, X direction + # For XOY plane: origin at (0,0,0), Z along (0,0,1), X along (1,0,0) + ax3_xoy = gp_Ax3(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1), gp_Dir(1, 0, 0)) + cylinder = Geom_CylindricalSurface(ax3_xoy, radius) + + # Calculate the number of turns based on height and pitch + turns = height / pitch + + # Direction in UV space: U is angle (in radians), V is height + # For a right-handed helix going up, U increases as V increases + # For a left-handed helix, U decreases as V increases + sign = -1.0 if left_hand else 1.0 + + # In UV space of cylinder: U = angle (radians), V = height along axis + # A helix is a straight line in UV space: for each turn (2*pi in U), we rise by pitch in V + # So direction is (sign * 2*pi, pitch) and we travel for 'turns' turns + direction = gp_Dir2d(sign * 2.0 * math.pi, pitch) + + # Create 2D line from origin in the UV space + line_2d = gp_Lin2d(gp_Pnt2d(0.0, 0.0), direction) + + # Parameter range: from 0 to 'turns' (each unit = one full turn) + segment = GCE2d_MakeSegment(line_2d, 0.0, turns).Value() + + # Create 3D edge from 2D curve on cylindrical surface + helix_edge = BRepBuilderAPI_MakeEdge(segment, cylinder, 0.0, turns).Edge() + + # Build the 3D curve representation + BRepLib.BuildCurves3d_s(helix_edge) + + # Create wire from edge + return BRepBuilderAPI_MakeWire(helix_edge).Wire() + + +def helical_extrude(profile, height, twist_angle_deg, *, + auxiliary_radius=10.0, segments=64, metadata=None): + """ + Extrude a 2D profile along Z with smooth helical twist. + + This function creates a helical extrusion where the profile rotates around + the Z axis as it extrudes upward. The implementation uses high-resolution + lofting through many intermediate sections to produce smooth helical surfaces. + + For profiles centered at the origin, the twist rotates the entire profile + around Z. This is ideal for helical gears, twisted columns, and spiral features. + + :param profile: A yapCAD region2d (list of 2D curve segments forming a closed loop) + in the XY plane, centered at or near the origin + :param height: Total extrusion height along Z axis + :param twist_angle_deg: Total twist angle in degrees over the full height. + Positive = counterclockwise when viewed from +Z. + Zero twist will produce a simple extrusion. + :param auxiliary_radius: (Deprecated, kept for API compatibility) Previously used + for auxiliary helix. Now ignored. + :param segments: Number of intermediate sections for lofting (default 64). + More segments = smoother helical surface. For helical gears, + use at least 64 segments to avoid visible stepping. + :param metadata: Optional dict of metadata to attach + :returns: A yapCAD solid with smooth helical surfaces + + .. note:: + This function requires pythonocc-core and will raise RuntimeError if not available. + For small twist angles (<10 degrees), the result may be similar to a simple extrusion. + The helical effect is most visible with larger twist angles. Using segments=64 or + higher is recommended for smooth surfaces. + + Example:: + + >>> # Create a twisted square prism with smooth surfaces + >>> from yapcad.geom import line, point + >>> square = [ + ... line(point(-5, -5), point(5, -5)), + ... line(point(5, -5), point(5, 5)), + ... line(point(5, 5), point(-5, 5)), + ... line(point(-5, 5), point(-5, -5)) + ... ] + >>> twisted = helical_extrude(square, height=20, twist_angle_deg=90) + """ + if not occ_available(): + raise RuntimeError("helical_extrude requires pythonocc-core") + + from OCC.Core.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ, gp_Trsf, gp_Ax1 + from OCC.Core.BRepBuilderAPI import ( + BRepBuilderAPI_MakeWire, BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, BRepBuilderAPI_Transform + ) + from OCC.Core.BRepOffsetAPI import BRepOffsetAPI_ThruSections + from OCC.Core.GC import GC_MakeArcOfCircle + from OCC.Core.GProp import GProp_GProps + from OCC.Core.BRepGProp import brepgprop + from OCC.Core.TopoDS import topods + + # Handle zero or very small twist as simple extrusion + if abs(twist_angle_deg) < 1e-6: + return extrude_region2d(profile, height, metadata=metadata) + + def _build_wire_from_region2d_xy(region): + """Build an OCC wire from a yapCAD region2d in the XY plane (Z=0).""" + wire_builder = BRepBuilderAPI_MakeWire() + + for seg in region: + if isline(seg): + p1 = seg[0] + p2 = seg[1] + # Keep in XY plane (Z=0) + start = gp_Pnt(p1[0], p1[1] if len(p1) > 1 else 0, 0) + end = gp_Pnt(p2[0], p2[1] if len(p2) > 1 else 0, 0) + edge = BRepBuilderAPI_MakeEdge(start, end).Edge() + wire_builder.Add(edge) + elif isarc(seg): + center = seg[0] + params = seg[1] + radius = params[0] + start_ang = math.radians(params[1]) + end_ang = math.radians(params[2]) + cx, cy = center[0], center[1] if len(center) > 1 else 0 + start_pt = gp_Pnt(cx + radius * math.cos(start_ang), + cy + radius * math.sin(start_ang), 0) + end_pt = gp_Pnt(cx + radius * math.cos(end_ang), + cy + radius * math.sin(end_ang), 0) + arc_center = gp_Pnt(cx, cy, 0) + circ = gp_Circ(gp_Ax2(arc_center, gp_Dir(0, 0, 1)), radius) + arc_maker = GC_MakeArcOfCircle(circ, start_pt, end_pt, True) + if arc_maker.IsDone(): + edge = BRepBuilderAPI_MakeEdge(arc_maker.Value()).Edge() + wire_builder.Add(edge) + + if not wire_builder.IsDone(): + raise RuntimeError("Failed to build wire from region2d") + return wire_builder.Wire() + + def _transform_wire(wire, z_offset, rotation_deg): + """Transform wire: translate along Z and rotate around Z axis.""" + trsf = gp_Trsf() + + # Combined transformation: first rotate around Z, then translate along Z + # Create rotation around Z axis at origin + rotation_rad = math.radians(rotation_deg) + z_axis = gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)) + trsf.SetRotation(z_axis, rotation_rad) + + # Apply rotation + transformer = BRepBuilderAPI_Transform(wire, trsf, True) + rotated_wire = topods.Wire(transformer.Shape()) + + # Apply translation + trsf_translate = gp_Trsf() + trsf_translate.SetTranslation(gp_Pnt(0, 0, 0), gp_Pnt(0, 0, z_offset)) + transformer2 = BRepBuilderAPI_Transform(rotated_wire, trsf_translate, True) + + return topods.Wire(transformer2.Shape()) + + # Build template wire at Z=0 + template_wire = _build_wire_from_region2d_xy(profile) + + # Create loft through rotated sections + # Use ruled=True for better handling of profiles with straight edges + loft = BRepOffsetAPI_ThruSections(True, True, 1.0e-6) + + # Add sections from bottom to top + for i in range(segments + 1): + t = i / segments # 0 to 1 + z = t * height + angle = t * twist_angle_deg + + transformed_wire = _transform_wire(template_wire, z, angle) + loft.AddWire(transformed_wire) + + loft.Build() + if not loft.IsDone(): + raise RuntimeError("Failed to create helical extrusion loft") + + shape = loft.Shape() + + # Normalize shape: if it's a Compound containing exactly one Solid, extract it + from OCC.Core.TopExp import TopExp_Explorer + from OCC.Core.TopAbs import TopAbs_SOLID + + if shape.ShapeType() == 0: # Compound + exp = TopExp_Explorer(shape, TopAbs_SOLID) + solids = [] + while exp.More(): + solids.append(topods.Solid(exp.Current())) + exp.Next() + if len(solids) == 1: + shape = solids[0] + + # Compute volume for verification + props = GProp_GProps() + brepgprop.VolumeProperties(shape, props) + volume = abs(props.Mass()) + + # Create BREP wrapper and tessellate to get yapCAD surface + from yapcad.brep import BrepSolid, attach_brep_to_solid + brep = BrepSolid(shape) + surface = brep.tessellate() + + # Create solid from the tessellated surface + construction = ['procedure', + f'helical_extrude(height={height}, twist={twist_angle_deg}deg)'] + sld = solid([surface], [], construction) + + # Attach BREP data to the solid + attach_brep_to_solid(sld, brep) + + if metadata: + from yapcad.metadata import get_solid_metadata + meta = get_solid_metadata(sld, create=True) + meta.update(metadata) + + return sld + + +def radial_pattern_solid(solid_geom, count, center=None, axis=None, angle=None): + """ + Create a radial/circular pattern of solid copies. + + Creates multiple copies of a 3D solid arranged in a circular pattern + around a center point, rotating about a specified axis. + + :param solid_geom: A yapCAD solid to pattern + :param count: Number of copies (including original) + :param center: Center point for rotation (default [0,0,0,1]) + :param axis: Rotation axis vector (default [0,0,1,0] for Z-axis) + :param angle: Total angle to span in degrees (default 360 = full circle) + :returns: List of solid copies, each rotated by angle/count increments + + Example:: + + >>> # Create 6 holes around a circle + >>> hole = conic(2.5, 2.5, 10) # cylinder hole + >>> holes = radial_pattern_solid(hole, count=6) # 6 holes at 60 degree intervals + """ + if count < 1: + raise ValueError("count must be at least 1") + + if not issolid(solid_geom): + raise ValueError("solid_geom must be a valid solid") + + # Set defaults + if center is None: + center = point(0, 0, 0) + if axis is None: + axis = point(0, 0, 1) + if angle is None: + angle = 360.0 + + # Single item returns the original + if count == 1: + return [solid_geom] + + # Calculate angle increment between copies + # For a full 360 pattern, don't duplicate at start/end + if close(angle, 360.0): + angle_step = angle / count + else: + angle_step = angle / (count - 1) if count > 1 else 0 + + result = [] + for i in range(count): + current_angle = i * angle_step + if close(current_angle, 0.0): + # No rotation needed for the first copy + result.append(solid_geom) + else: + rotated = rotatesolid(solid_geom, current_angle, cent=center, axis=axis) + result.append(rotated) + + return result + + +def linear_pattern_solid(solid_geom, count, spacing): + """ + Create a linear pattern of solid copies. + + Creates multiple copies of a 3D solid arranged in a line with uniform spacing. + + :param solid_geom: A yapCAD solid to pattern + :param count: Number of copies (including original) + :param spacing: Vector defining direction and distance between copies. + Can be a 4-tuple [x, y, z, w] or 3-tuple/list [x, y, z] + :returns: List of solid copies, each translated by spacing increments + + Example:: + + >>> # Create 5 mounting holes in a row + >>> hole = conic(3, 3, 10) # cylinder hole + >>> holes = linear_pattern_solid(hole, count=5, spacing=[20, 0, 0]) # 5 holes, 20mm apart + """ + if count < 1: + raise ValueError("count must be at least 1") + + if not issolid(solid_geom): + raise ValueError("solid_geom must be a valid solid") + + # Normalize spacing to a proper vector + if isinstance(spacing, (list, tuple)): + if len(spacing) == 3: + spacing = point(spacing[0], spacing[1], spacing[2]) + elif len(spacing) == 4: + spacing = point(spacing[0], spacing[1], spacing[2]) + elif len(spacing) == 2: + spacing = point(spacing[0], spacing[1], 0) + else: + raise ValueError("spacing must be a 2D, 3D, or 4D vector") + else: + raise ValueError("spacing must be a list or tuple") + + result = [] + for i in range(count): + if i == 0: + # No translation for the first copy + result.append(solid_geom) + else: + # Calculate total offset for this copy + delta = scale3(spacing, float(i)) + translated = translatesolid(solid_geom, delta) + result.append(translated) + + return result + + +def radial_pattern_surface(surf, count, center=None, axis=None, angle=None): + """ + Create a radial/circular pattern of surface copies. + + Creates multiple copies of a 3D surface arranged in a circular pattern + around a center point, rotating about a specified axis. + + :param surf: A yapCAD surface to pattern + :param count: Number of copies (including original) + :param center: Center point for rotation (default [0,0,0,1]) + :param axis: Rotation axis vector (default [0,0,1,0] for Z-axis) + :param angle: Total angle to span in degrees (default 360 = full circle) + :returns: List of surface copies, each rotated by angle/count increments + + Example:: + + >>> # Create 8 fins around a rocket body + >>> fin_surface = triangulate_region(fin_profile) + >>> fins = radial_pattern_surface(fin_surface, count=8) + """ + if count < 1: + raise ValueError("count must be at least 1") + + if not issurface(surf): + raise ValueError("surf must be a valid surface") + + # Set defaults + if center is None: + center = point(0, 0, 0) + if axis is None: + axis = point(0, 0, 1) + if angle is None: + angle = 360.0 + + # Single item returns the original + if count == 1: + return [surf] + + # Calculate angle increment between copies + if close(angle, 360.0): + angle_step = angle / count + else: + angle_step = angle / (count - 1) if count > 1 else 0 + + result = [] + for i in range(count): + current_angle = i * angle_step + if close(current_angle, 0.0): + result.append(surf) + else: + rotated = rotatesurface(surf, current_angle, cent=center, axis=axis) + result.append(rotated) + + return result + + +def linear_pattern_surface(surf, count, spacing): + """ + Create a linear pattern of surface copies. + + Creates multiple copies of a 3D surface arranged in a line with uniform spacing. + + :param surf: A yapCAD surface to pattern + :param count: Number of copies (including original) + :param spacing: Vector defining direction and distance between copies. + Can be a 2D, 3D, or 4D vector + :returns: List of surface copies, each translated by spacing increments + + Example:: + + >>> # Create a row of ribs along a structure + >>> rib_surface = triangulate_region(rib_profile) + >>> ribs = linear_pattern_surface(rib_surface, count=10, spacing=[5, 0, 0]) + """ + if count < 1: + raise ValueError("count must be at least 1") + + if not issurface(surf): + raise ValueError("surf must be a valid surface") + + # Normalize spacing to a proper vector + if isinstance(spacing, (list, tuple)): + if len(spacing) == 3: + spacing = point(spacing[0], spacing[1], spacing[2]) + elif len(spacing) == 4: + spacing = point(spacing[0], spacing[1], spacing[2]) + elif len(spacing) == 2: + spacing = point(spacing[0], spacing[1], 0) + else: + raise ValueError("spacing must be a 2D, 3D, or 4D vector") + else: + raise ValueError("spacing must be a list or tuple") + + result = [] + for i in range(count): + if i == 0: + result.append(surf) + else: + delta = scale3(spacing, float(i)) + translated = translatesurface(surf, delta) + result.append(translated) + + return result diff --git a/src/yapcad/geom_util.py b/src/yapcad/geom_util.py index c94feda..63616ad 100644 --- a/src/yapcad/geom_util.py +++ b/src/yapcad/geom_util.py @@ -914,3 +914,107 @@ def rsort(il): [inter[1][i-1], inter[1][i%len(inter[1])]]]) return r + + +def radial_pattern(geometry, count, center=None, axis=None, angle=None): + """ + Create a radial/circular pattern of geometry copies. + + Creates multiple copies of a 2D geometry element (line, arc, polyline, + or geomlist/region) arranged in a circular pattern around a center point. + + :param geometry: A 2D region, polyline, line, arc, or geomlist to pattern + :param count: Number of copies (including original) + :param center: Center point for rotation (default [0,0,0,1]) + :param axis: Rotation axis vector (default [0,0,1,0] for Z-axis) + :param angle: Total angle to span in degrees (default 360 = full circle) + :returns: List of geometry copies, each rotated by angle/count increments + + Example:: + + >>> # Create 6 bolt holes around a circle + >>> hole = arc(point(10, 0), 2.5) # hole at 10mm radius + >>> holes = radial_pattern(hole, count=6) # 6 holes at 60 degree intervals + """ + if count < 1: + raise ValueError("count must be at least 1") + + # Set defaults + if center is None: + center = point(0, 0, 0) + if axis is None: + axis = point(0, 0, 1) + if angle is None: + angle = 360.0 + + # Single item returns the original + if count == 1: + return [geometry] + + # Calculate angle increment between copies + # For a full 360 pattern, don't duplicate at start/end + if abs(angle - 360.0) < epsilon: + angle_step = angle / count + else: + angle_step = angle / (count - 1) + + result = [] + for i in range(count): + current_angle = i * angle_step + if abs(current_angle) < epsilon: + # No rotation needed for the first copy + result.append(geometry) + else: + rotated = rotate(geometry, current_angle, cent=center, axis=axis) + result.append(rotated) + + return result + + +def linear_pattern(geometry, count, spacing): + """ + Create a linear pattern of geometry copies. + + Creates multiple copies of a 2D geometry element (line, arc, polyline, + or geomlist/region) arranged in a line with uniform spacing. + + :param geometry: A 2D region, polyline, line, arc, or geomlist to pattern + :param count: Number of copies (including original) + :param spacing: Vector defining direction and distance between copies. + Can be a 4-tuple [x, y, z, w] or 3-tuple/list [x, y, z] + :returns: List of geometry copies, each translated by spacing increments + + Example:: + + >>> # Create 5 mounting holes in a row + >>> hole = arc(point(0, 0), 3) + >>> holes = linear_pattern(hole, count=5, spacing=[20, 0, 0]) # 5 holes, 20mm apart + """ + if count < 1: + raise ValueError("count must be at least 1") + + # Normalize spacing to a proper vector + if isinstance(spacing, (list, tuple)): + if len(spacing) == 3: + spacing = point(spacing[0], spacing[1], spacing[2]) + elif len(spacing) == 4: + spacing = point(spacing[0], spacing[1], spacing[2]) + elif len(spacing) == 2: + spacing = point(spacing[0], spacing[1], 0) + else: + raise ValueError("spacing must be a 2D, 3D, or 4D vector") + else: + raise ValueError("spacing must be a list or tuple") + + result = [] + for i in range(count): + if i == 0: + # No translation for the first copy + result.append(geometry) + else: + # Calculate total offset for this copy + delta = scale3(spacing, float(i)) + translated = translate(geometry, delta) + result.append(translated) + + return result diff --git a/src/yapcad/text3d.py b/src/yapcad/text3d.py new file mode 100644 index 0000000..1794848 --- /dev/null +++ b/src/yapcad/text3d.py @@ -0,0 +1,970 @@ +""" +3D Text generation for yapCAD. + +This module provides functions for creating 3D text suitable for labeling +3D printed parts. It includes both extruded (raised) text and engraved +(cut-in) text capabilities using a simple, printable block font or +TrueType/OpenType fonts. + +Example usage: + + from yapcad.text3d import text_solid, engrave_text + from yapcad.io.stl import write_stl + + # Create raised text using TrueType font (Arial) + label = text_solid("ROBOT", height=10.0, depth=2.0) + write_stl(label, "robot_label.stl") + + # Create text with a specific font file + label = text_solid("ROBOT", height=10.0, depth=2.0, + font="/path/to/font.ttf") + + # Use block font explicitly + label = text_solid("ROBOT", height=10.0, depth=2.0, font="block") + + # Engrave text into a surface + plate = prism(50, 30, 5) + engraved = engrave_text(plate, "V1.0", point(0, 0, 2.5), + vect(0, 0, 1, 0), height=4.0, depth=0.5) +""" + +from yapcad.geom import point, vect, epsilon, add, sub, scale3 +from yapcad.geom3d import ( + poly2surfaceXY, solid, solid_boolean, issolid, + translatesurface, rotatesurface +) +from yapcad.geom3d_util import extrude, prism +from yapcad.xform import Translation, Rotation, Matrix +from copy import deepcopy +import math +import os +import platform + +# Try to import freetype-py for TrueType font support +try: + import freetype + FREETYPE_AVAILABLE = True +except ImportError: + FREETYPE_AVAILABLE = False + +# Grid dimensions for block font (5 wide x 7 tall) +CHAR_WIDTH = 5 +CHAR_HEIGHT = 7 +STROKE_WIDTH = 1.0 + +# Block font definition: each character is a list of rectangles +# Rectangle format: (x, y, width, height) in grid units +# Origin is bottom-left of character cell + +BLOCK_FONT = { + 'A': [ + (1, 6, 3, 1), # Top bar + (0, 0, 1, 6), # Left vertical + (4, 0, 1, 6), # Right vertical + (1, 3, 3, 1), # Middle bar + ], + 'B': [ + (0, 0, 1, 7), # Left vertical + (1, 6, 3, 1), # Top bar + (1, 3, 3, 1), # Middle bar + (1, 0, 3, 1), # Bottom bar + (4, 4, 1, 2), # Upper right + (4, 1, 1, 2), # Lower right + ], + 'C': [ + (0, 1, 1, 5), # Left vertical + (1, 6, 4, 1), # Top bar + (1, 0, 4, 1), # Bottom bar + ], + 'D': [ + (0, 0, 1, 7), # Left vertical + (1, 6, 3, 1), # Top bar + (1, 0, 3, 1), # Bottom bar + (4, 1, 1, 5), # Right vertical + ], + 'E': [ + (0, 0, 1, 7), # Left vertical + (1, 6, 4, 1), # Top bar + (1, 3, 3, 1), # Middle bar + (1, 0, 4, 1), # Bottom bar + ], + 'F': [ + (0, 0, 1, 7), # Left vertical + (1, 6, 4, 1), # Top bar + (1, 3, 3, 1), # Middle bar + ], + 'G': [ + (0, 1, 1, 5), # Left vertical + (1, 6, 4, 1), # Top bar + (1, 0, 4, 1), # Bottom bar + (4, 1, 1, 3), # Lower right + (2, 3, 2, 1), # Middle bar (partial) + ], + 'H': [ + (0, 0, 1, 7), # Left vertical + (4, 0, 1, 7), # Right vertical + (1, 3, 3, 1), # Middle bar + ], + 'I': [ + (0, 6, 5, 1), # Top bar + (0, 0, 5, 1), # Bottom bar + (2, 1, 1, 5), # Center vertical + ], + 'J': [ + (0, 6, 5, 1), # Top bar + (3, 1, 1, 5), # Right vertical + (0, 0, 3, 1), # Bottom bar + (0, 1, 1, 2), # Lower left hook + ], + 'K': [ + (0, 0, 1, 7), # Left vertical + (1, 3, 1, 1), # Middle connector + (2, 4, 1, 1), # Upper diagonal part 1 + (3, 5, 1, 1), # Upper diagonal part 2 + (4, 6, 1, 1), # Upper diagonal part 3 + (2, 2, 1, 1), # Lower diagonal part 1 + (3, 1, 1, 1), # Lower diagonal part 2 + (4, 0, 1, 1), # Lower diagonal part 3 + ], + 'L': [ + (0, 0, 1, 7), # Left vertical + (1, 0, 4, 1), # Bottom bar + ], + 'M': [ + (0, 0, 1, 7), # Left vertical + (4, 0, 1, 7), # Right vertical + (1, 5, 1, 1), # Left diagonal + (2, 4, 1, 1), # Center peak + (3, 5, 1, 1), # Right diagonal + ], + 'N': [ + (0, 0, 1, 7), # Left vertical + (4, 0, 1, 7), # Right vertical + (1, 5, 1, 1), # Diagonal part 1 + (2, 4, 1, 1), # Diagonal part 2 + (3, 3, 1, 1), # Diagonal part 3 + ], + 'O': [ + (0, 1, 1, 5), # Left vertical + (4, 1, 1, 5), # Right vertical + (1, 6, 3, 1), # Top bar + (1, 0, 3, 1), # Bottom bar + ], + 'P': [ + (0, 0, 1, 7), # Left vertical + (1, 6, 3, 1), # Top bar + (1, 3, 3, 1), # Middle bar + (4, 4, 1, 2), # Right upper + ], + 'Q': [ + (0, 1, 1, 5), # Left vertical + (4, 2, 1, 4), # Right vertical + (1, 6, 3, 1), # Top bar + (1, 0, 3, 1), # Bottom bar + (3, 1, 1, 1), # Tail part 1 + (4, 0, 1, 1), # Tail part 2 + ], + 'R': [ + (0, 0, 1, 7), # Left vertical + (1, 6, 3, 1), # Top bar + (1, 3, 3, 1), # Middle bar + (4, 4, 1, 2), # Right upper + (2, 2, 1, 1), # Lower diagonal part 1 + (3, 1, 1, 1), # Lower diagonal part 2 + (4, 0, 1, 1), # Lower diagonal part 3 + ], + 'S': [ + (1, 6, 4, 1), # Top bar + (0, 4, 1, 2), # Upper left + (1, 3, 3, 1), # Middle bar + (4, 1, 1, 2), # Lower right + (0, 0, 4, 1), # Bottom bar + ], + 'T': [ + (0, 6, 5, 1), # Top bar + (2, 0, 1, 6), # Center vertical + ], + 'U': [ + (0, 1, 1, 6), # Left vertical + (4, 1, 1, 6), # Right vertical + (1, 0, 3, 1), # Bottom bar + ], + 'V': [ + (0, 3, 1, 4), # Left vertical upper + (1, 1, 1, 2), # Left diagonal + (2, 0, 1, 1), # Bottom point + (3, 1, 1, 2), # Right diagonal + (4, 3, 1, 4), # Right vertical upper + ], + 'W': [ + (0, 0, 1, 7), # Left vertical + (4, 0, 1, 7), # Right vertical + (1, 1, 1, 1), # Left diagonal + (2, 2, 1, 1), # Center trough + (3, 1, 1, 1), # Right diagonal + ], + 'X': [ + (0, 5, 1, 2), # Top left + (1, 4, 1, 1), # Upper left diagonal + (2, 3, 1, 1), # Center + (3, 4, 1, 1), # Upper right diagonal + (4, 5, 1, 2), # Top right + (1, 2, 1, 1), # Lower left diagonal + (0, 0, 1, 2), # Bottom left + (3, 2, 1, 1), # Lower right diagonal + (4, 0, 1, 2), # Bottom right + ], + 'Y': [ + (0, 5, 1, 2), # Top left + (1, 4, 1, 1), # Upper left diagonal + (2, 0, 1, 4), # Center vertical + (3, 4, 1, 1), # Upper right diagonal + (4, 5, 1, 2), # Top right + ], + 'Z': [ + (0, 6, 5, 1), # Top bar + (3, 5, 1, 1), # Diagonal part 1 + (2, 3, 1, 2), # Diagonal part 2 + (1, 2, 1, 1), # Diagonal part 3 + (0, 0, 5, 1), # Bottom bar + ], + '0': [ + (0, 1, 1, 5), # Left vertical + (4, 1, 1, 5), # Right vertical + (1, 6, 3, 1), # Top bar + (1, 0, 3, 1), # Bottom bar + (2, 3, 1, 1), # Center diagonal (distinguishes from O) + ], + '1': [ + (2, 0, 1, 7), # Center vertical + (1, 5, 1, 1), # Top serif + (1, 0, 3, 1), # Bottom bar + ], + '2': [ + (0, 5, 1, 1), # Top left + (1, 6, 3, 1), # Top bar + (4, 4, 1, 2), # Upper right + (2, 3, 2, 1), # Middle bar + (1, 2, 1, 1), # Diagonal + (0, 1, 1, 1), # Lower left + (0, 0, 5, 1), # Bottom bar + ], + '3': [ + (0, 6, 4, 1), # Top bar + (4, 4, 1, 2), # Upper right + (1, 3, 3, 1), # Middle bar + (4, 1, 1, 2), # Lower right + (0, 0, 4, 1), # Bottom bar + ], + '4': [ + (0, 3, 1, 4), # Left vertical (upper) + (4, 0, 1, 7), # Right vertical (full) + (1, 3, 3, 1), # Horizontal bar + ], + '5': [ + (0, 6, 5, 1), # Top bar + (0, 3, 1, 3), # Upper left + (1, 3, 3, 1), # Middle bar + (4, 1, 1, 2), # Lower right + (0, 0, 4, 1), # Bottom bar + ], + '6': [ + (0, 1, 1, 5), # Left vertical + (1, 6, 4, 1), # Top bar + (1, 3, 3, 1), # Middle bar + (1, 0, 3, 1), # Bottom bar + (4, 1, 1, 2), # Lower right + ], + '7': [ + (0, 6, 5, 1), # Top bar + (4, 3, 1, 3), # Upper right + (2, 0, 2, 3), # Lower center-right + ], + '8': [ + (0, 1, 1, 2), # Lower left + (0, 4, 1, 2), # Upper left + (4, 1, 1, 2), # Lower right + (4, 4, 1, 2), # Upper right + (1, 6, 3, 1), # Top bar + (1, 3, 3, 1), # Middle bar + (1, 0, 3, 1), # Bottom bar + ], + '9': [ + (0, 4, 1, 2), # Upper left + (4, 1, 1, 5), # Right vertical + (1, 6, 3, 1), # Top bar + (1, 3, 3, 1), # Middle bar + (0, 0, 4, 1), # Bottom bar + ], + '-': [ + (1, 3, 3, 1), # Horizontal bar + ], + '_': [ + (0, 0, 5, 1), # Underscore at bottom + ], + '.': [ + (2, 0, 1, 1), # Single dot + ], + ':': [ + (2, 4, 1, 1), # Upper dot + (2, 1, 1, 1), # Lower dot + ], + '/': [ + (0, 0, 1, 2), # Bottom left + (1, 2, 1, 1), # Lower middle + (2, 3, 1, 1), # Center + (3, 4, 1, 1), # Upper middle + (4, 5, 1, 2), # Top right + ], + '(': [ + (3, 5, 1, 1), # Top curve + (2, 2, 1, 3), # Vertical + (3, 1, 1, 1), # Bottom curve + ], + ')': [ + (1, 5, 1, 1), # Top curve + (2, 2, 1, 3), # Vertical + (1, 1, 1, 1), # Bottom curve + ], + ' ': [], # Space - no rectangles +} + + +def find_system_font(font_name): + """Find a font by name in system font directories. + + Args: + font_name: Name of the font (e.g., "Arial", "Helvetica") + + Returns: + Path to the font file, or None if not found + """ + if not FREETYPE_AVAILABLE: + return None + + system = platform.system() + font_dirs = [] + + if system == "Darwin": # macOS + font_dirs = [ + "/System/Library/Fonts", + "/System/Library/Fonts/Supplemental", + "/Library/Fonts", + os.path.expanduser("~/Library/Fonts"), + ] + elif system == "Linux": + font_dirs = [ + "/usr/share/fonts", + "/usr/local/share/fonts", + os.path.expanduser("~/.fonts"), + os.path.expanduser("~/.local/share/fonts"), + ] + elif system == "Windows": + font_dirs = [ + os.path.join(os.environ.get("WINDIR", "C:\\Windows"), "Fonts"), + ] + + # Common font filename patterns + patterns = [ + f"{font_name}.ttf", + f"{font_name}.otf", + f"{font_name.lower()}.ttf", + f"{font_name.lower()}.otf", + f"{font_name.upper()}.ttf", + f"{font_name.upper()}.otf", + ] + + for font_dir in font_dirs: + if not os.path.exists(font_dir): + continue + for pattern in patterns: + font_path = os.path.join(font_dir, pattern) + if os.path.exists(font_path): + return font_path + + # Also check subdirectories (one level deep) + try: + for subdir in os.listdir(font_dir): + subdir_path = os.path.join(font_dir, subdir) + if os.path.isdir(subdir_path): + for pattern in patterns: + font_path = os.path.join(subdir_path, pattern) + if os.path.exists(font_path): + return font_path + except (OSError, PermissionError): + pass + + return None + + +def glyph_to_polygons(glyph, x_offset=0.0, scale=1.0): + """Convert a freetype glyph outline to polygons. + + Args: + glyph: freetype.GlyphSlot object + x_offset: X offset in mm + scale: Scale factor (converts from 26.6 fixed point to mm) + + Returns: + List of closed polygons (each polygon is a list of points) + """ + if not FREETYPE_AVAILABLE: + return [] + + polygons = [] + outline = glyph.outline + + # FreeType outline consists of contours (closed paths) + start = 0 + for end_idx in outline.contours: + contour_points = [] + + # Extract points for this contour + # Points are in 26.6 fixed point format, so divide by 64 + for i in range(start, end_idx + 1): + x = (outline.points[i][0] / 64.0) * scale + x_offset + y = (outline.points[i][1] / 64.0) * scale + contour_points.append(point(x, y, 0)) + + # Close the contour if needed + if contour_points and contour_points[0] != contour_points[-1]: + contour_points.append(contour_points[0]) + + if len(contour_points) >= 4: # Minimum for a valid closed polygon + polygons.append(contour_points) + + start = end_idx + 1 + + return polygons + + +def _rect_to_polygon(x, y, width, height, scale, x_offset): + """Convert a rectangle to a closed polygon. + + Args: + x, y: Bottom-left corner in grid units + width, height: Dimensions in grid units + scale: Scale factor (mm per grid unit) + x_offset: X offset in mm for character positioning + + Returns: + List of points forming a closed polygon (CCW winding) + """ + x0 = (x * scale) + x_offset + y0 = y * scale + x1 = x0 + (width * scale) + y1 = y0 + (height * scale) + + # Return closed polygon (CCW winding for positive area) + return [ + point(x0, y0, 0), + point(x1, y0, 0), + point(x1, y1, 0), + point(x0, y1, 0), + point(x0, y0, 0), # Close the polygon + ] + + +def text_to_polygons(text, height=5.0, spacing=1.0, font=None): + """Convert text string to list of 2D polygons. + + Each character becomes one or more closed polygons suitable for + extrusion or boolean operations. + + Args: + text: String to convert + height: Character height in mm (default 5.0) + spacing: Space between characters as fraction of char width (default 1.0) + font: Font specification (default None): + - None: Auto-detect Arial or fall back to block font + - "block": Use simple block font + - Font name (e.g., "Arial"): Search system fonts + - Path to .ttf/.otf file: Use specified font file + + Returns: + List of closed polygons (each polygon is a list of points) + """ + # Determine which font to use + use_ttf = False + font_path = None + + if font is None: + # Try to find Arial by default + if FREETYPE_AVAILABLE: + font_path = find_system_font("Arial") + use_ttf = font_path is not None + elif font == "block": + # Explicitly use block font + use_ttf = False + elif FREETYPE_AVAILABLE and os.path.exists(font): + # Font is a file path + font_path = font + use_ttf = True + elif FREETYPE_AVAILABLE: + # Try to find font by name + font_path = find_system_font(font) + use_ttf = font_path is not None + + # Use TrueType font if available + if use_ttf and font_path: + return _text_to_polygons_ttf(text, height, spacing, font_path) + else: + # Fall back to block font + return _text_to_polygons_block(text, height, spacing) + + +def _text_to_polygons_ttf(text, height, spacing, font_path): + """Convert text to polygons using TrueType font. + + Args: + text: String to convert + height: Character height in mm + spacing: Space between characters (additional space in mm) + font_path: Path to .ttf or .otf file + + Returns: + List of closed polygons + """ + if not FREETYPE_AVAILABLE: + return _text_to_polygons_block(text, height, spacing) + + try: + face = freetype.Face(font_path) + + # Simple approach: Load font at a fixed size (e.g., points = height in mm) + # FreeType interprets size in 1/64th of a point + # We'll use a simple conversion: 1 point ~= 0.35mm at 72 DPI + # So to get height mm, we need height / 0.35 points + # But this is still complex. Better: load at fixed size and scale after. + + # Load at a reference size (e.g., 100 points) + face.set_char_size(100 * 64) # 100 points + + polygons = [] + x_offset = 0.0 + + # FreeType provides coordinates in 26.6 fixed point format (divide by 64 to get pixels) + # At 72 DPI: 1 pixel = 25.4mm / 72 = 0.3528 mm + mm_per_pixel = 25.4 / 72.0 + + # To get accurate sizing, measure a reference capital letter (like 'H') + # and scale based on its actual height + face.load_char('H', freetype.FT_LOAD_DEFAULT | freetype.FT_LOAD_NO_BITMAP) + bbox = face.glyph.outline.get_bbox() + # bbox gives us (xMin, yMin, xMax, yMax) in 26.6 fixed point + ref_height_pixels = (bbox.yMax - bbox.yMin) / 64.0 + ref_height_mm = ref_height_pixels * mm_per_pixel + + # Scale factor to achieve desired height + scale = height / ref_height_mm + + for char in text: + # Load character glyph + face.load_char(char, freetype.FT_LOAD_DEFAULT | freetype.FT_LOAD_NO_BITMAP) + glyph = face.glyph + + # glyph outline points are in 26.6 fixed point, need to divide by 64 + # Then convert to mm and apply scale + char_polygons = glyph_to_polygons(glyph, x_offset, scale * mm_per_pixel) + polygons.extend(char_polygons) + + # Advance to next character position + advance = glyph.advance.x / 64.0 * mm_per_pixel * scale + x_offset += advance + spacing + + return polygons + + except Exception as e: + # If TrueType rendering fails, fall back to block font + print(f"Warning: TrueType font rendering failed ({e}), using block font") + import traceback + traceback.print_exc() + return _text_to_polygons_block(text, height, spacing) + + +def _text_to_polygons_block(text, height, spacing): + """Convert text to polygons using simple block font. + + Args: + text: String to convert (supports A-Z, 0-9, basic punctuation) + height: Character height in mm + spacing: Space between characters as fraction of char width + + Returns: + List of closed polygons (each polygon is a list of points) + """ + polygons = [] + scale = height / CHAR_HEIGHT + char_width = CHAR_WIDTH * scale + gap = spacing * scale # Gap between characters + + x_offset = 0.0 + + for char in text.upper(): + if char in BLOCK_FONT: + rects = BLOCK_FONT[char] + for rect in rects: + x, y, w, h = rect + poly = _rect_to_polygon(x, y, w, h, scale, x_offset) + polygons.append(poly) + else: + # Unknown character - draw a small placeholder rectangle + poly = _rect_to_polygon(1, 1, 3, 5, scale, x_offset) + polygons.append(poly) + + x_offset += char_width + gap + + return polygons + + +def text_solid(text, height=5.0, depth=1.0, spacing=1.0, center=None, font=None): + """Create extruded 3D text solid. + + Generates a solid from the given text string by extruding each + character polygon in the Z direction. + + Args: + text: String to render + height: Character height in mm (default 5.0) + depth: Extrusion depth in mm (default 1.0) + spacing: Character spacing (default 1.0) + - For block font: fraction of character width + - For TrueType: additional space in mm + center: Optional center point for the text block + font: Font specification (default None): + - None: Auto-detect Arial or fall back to block font + - "block": Use simple block font + - Font name (e.g., "Arial"): Search system fonts + - Path to .ttf/.otf file: Use specified font file + + Returns: + yapCAD solid (combined extruded text) + """ + polygons = text_to_polygons(text, height, spacing, font) + + if not polygons: + # Return empty solid for empty text + return solid([], [], ['procedure', f'text_solid("{text}")']) + + # Extrude each polygon and combine + all_surfaces = [] + for poly in polygons: + try: + # Create surface from polygon + # poly2surfaceXY returns (surface, boundary_indices) tuple + surf_result = poly2surfaceXY(poly) + if isinstance(surf_result, tuple): + surf = surf_result[0] + else: + surf = surf_result + # Extrude upward + extruded = extrude(surf, depth, vect(0, 0, 1, 0)) + # Extract surfaces from the extruded solid + if issolid(extruded): + all_surfaces.extend(extruded[1]) + except (ValueError, IndexError) as e: + # Skip degenerate polygons + continue + + if not all_surfaces: + return solid([], [], ['procedure', f'text_solid("{text}")']) + + result = solid(all_surfaces, [], ['procedure', f'text_solid("{text}", height={height}, depth={depth})']) + + # Center the text if requested + if center is not None: + # Calculate current bounding box + from yapcad.geom3d import solidbbox + bbox = solidbbox(result) + if bbox: + current_center = point( + (bbox[0][0] + bbox[1][0]) / 2, + (bbox[0][1] + bbox[1][1]) / 2, + (bbox[0][2] + bbox[1][2]) / 2 + ) + offset = sub(center, current_center) + # Translate all surfaces + for i, surf in enumerate(result[1]): + result[1][i] = translatesurface(surf, offset) + + return result + + +def text_width(text, height=5.0, spacing=1.0, font=None): + """Calculate the total width of rendered text. + + Useful for positioning and centering text. + + Args: + text: String to measure + height: Character height in mm (default 5.0) + spacing: Character spacing (default 1.0) + font: Font specification (same as text_solid) + + Returns: + Total width in mm + """ + if not text: + return 0.0 + + # Determine which font to use + use_ttf = False + font_path = None + + if font is None: + if FREETYPE_AVAILABLE: + font_path = find_system_font("Arial") + use_ttf = font_path is not None + elif font == "block": + use_ttf = False + elif FREETYPE_AVAILABLE and os.path.exists(font): + font_path = font + use_ttf = True + elif FREETYPE_AVAILABLE: + font_path = find_system_font(font) + use_ttf = font_path is not None + + # Calculate width based on font type + if use_ttf and font_path: + try: + face = freetype.Face(font_path) + face.set_char_size(100 * 64) # 100 points + + # Calculate scale factor (same as in _text_to_polygons_ttf) + mm_per_pixel = 25.4 / 72.0 + face.load_char('H', freetype.FT_LOAD_DEFAULT | freetype.FT_LOAD_NO_BITMAP) + bbox = face.glyph.outline.get_bbox() + ref_height_pixels = (bbox.yMax - bbox.yMin) / 64.0 + ref_height_mm = ref_height_pixels * mm_per_pixel + scale = height / ref_height_mm + + total_width = 0.0 + for char in text: + face.load_char(char, freetype.FT_LOAD_DEFAULT | freetype.FT_LOAD_NO_BITMAP) + glyph = face.glyph + advance = glyph.advance.x / 64.0 * mm_per_pixel * scale + total_width += advance + spacing + + # Remove last spacing + if len(text) > 0: + total_width -= spacing + + return total_width + except Exception: + # Fall back to block font calculation + pass + + # Block font calculation + scale = height / CHAR_HEIGHT + char_width = CHAR_WIDTH * scale + gap = spacing * scale + + # Width = (n chars * char_width) + ((n-1) gaps) + n = len(text) + return (n * char_width) + ((n - 1) * gap) + + +def engrave_text(target_solid, text, position, normal, height=3.0, depth=0.5, spacing=1.0, font=None): + """Cut text into a face of a solid (boolean difference). + + Creates text as a solid and subtracts it from the target solid to + create engraved (cut-in) text. + + Args: + target_solid: yapCAD solid to engrave into + text: Text string to engrave + position: Point on face where text starts (bottom-left of first char) + normal: Face normal vector (determines orientation of text) + height: Character height in mm (default 3.0) + depth: Engraving depth in mm (default 0.5) + spacing: Character spacing (default 1.0) + font: Font specification (same as text_solid) + + Returns: + New solid with text engraved into the specified face + """ + if not issolid(target_solid): + raise ValueError("target_solid must be a valid yapCAD solid") + + # Create text solid + text_sld = text_solid(text, height, depth + 0.1, spacing, font=font) # Extra depth for clean cut + + if not text_sld[1]: # No surfaces (empty text) + return deepcopy(target_solid) + + # Normalize the normal vector + n = [normal[0], normal[1], normal[2]] + nm = math.sqrt(n[0]**2 + n[1]**2 + n[2]**2) + if nm < epsilon: + raise ValueError("normal vector has zero length") + n = [n[0]/nm, n[1]/nm, n[2]/nm] + + # Calculate transformation to align text with face + # Text is generated in XY plane with Z-up + # We need to rotate so Z aligns with the face normal + + # Build rotation to align Z-axis with normal + z_axis = [0, 0, 1] + + # Calculate rotation axis (cross product of z_axis and normal) + rx = z_axis[1] * n[2] - z_axis[2] * n[1] + ry = z_axis[2] * n[0] - z_axis[0] * n[2] + rz = z_axis[0] * n[1] - z_axis[1] * n[0] + rot_axis_mag = math.sqrt(rx**2 + ry**2 + rz**2) + + # Calculate rotation angle + dot_prod = z_axis[0] * n[0] + z_axis[1] * n[1] + z_axis[2] * n[2] + angle = math.acos(max(-1, min(1, dot_prod))) # Clamp for numerical stability + + # Transform text solid + transformed_surfaces = [] + + for surf in text_sld[1]: + new_surf = deepcopy(surf) + + # Rotate vertices if needed + if rot_axis_mag > epsilon and abs(angle) > epsilon: + rot_axis = point(rx / rot_axis_mag, ry / rot_axis_mag, rz / rot_axis_mag) + angle_deg = math.degrees(angle) + rot_matrix = Rotation(angle_deg, cent=point(0, 0, 0), axis=rot_axis) + + # Transform vertices + new_verts = [] + for v in new_surf[1]: + new_v = rot_matrix.mul(v) + new_verts.append(new_v) + new_surf[1] = new_verts + + # Transform normals + new_norms = [] + for nm_vec in new_surf[2]: + new_nm = rot_matrix.mul(nm_vec) + new_norms.append(new_nm) + new_surf[2] = new_norms + + # Translate to position (slightly inside the face for clean cut) + offset = sub(position, scale3(n, depth * 0.5)) + new_surf = translatesurface(new_surf, offset) + transformed_surfaces.append(new_surf) + + # Create transformed text solid + engraving_solid = solid( + transformed_surfaces, + [], + ['procedure', f'engrave_text("{text}")'] + ) + + # Perform boolean difference + try: + result = solid_boolean(target_solid, engraving_solid, 'difference') + return result + except Exception as e: + # If boolean fails, return original solid with warning + print(f"Warning: engraving failed ({e}), returning original solid") + return deepcopy(target_solid) + + +def get_supported_characters(): + """Return a string of all supported characters. + + Returns: + String containing all characters that can be rendered + """ + return ''.join(sorted(BLOCK_FONT.keys())) + + +# Demo/test code +if __name__ == "__main__": + print("yapCAD text3d module - TrueType font support") + print("=" * 50) + + # Check if freetype is available + if FREETYPE_AVAILABLE: + print("freetype-py: Available") + + # Try to find Arial font + arial_path = find_system_font("Arial") + if arial_path: + print(f"Arial font found: {arial_path}") + else: + print("Arial font not found, will use block font") + else: + print("freetype-py: Not available (install with: pip install freetype-py)") + print("Falling back to block font") + + print("\nGenerating test text: 'ROBOT V1' with Arial (default)") + print("-" * 50) + + # Test with Arial (or fallback to block font) + try: + from yapcad.io.stl import write_stl + from yapcad.geom3d import solidbbox + + text_obj = text_solid("ROBOT V1", height=10.0, depth=2.0, spacing=1.0) + + # Check if we got surfaces + if text_obj and text_obj[1]: + num_surfaces = len(text_obj[1]) + print(f"Success! Generated solid with {num_surfaces} surfaces") + + # Calculate bounding box + bbox = solidbbox(text_obj) + if bbox: + width = bbox[1][0] - bbox[0][0] + height = bbox[1][1] - bbox[0][1] + depth = bbox[1][2] - bbox[0][2] + print(f"Dimensions: {width:.1f} x {height:.1f} x {depth:.1f} mm") + + # Try to write STL + output_file = "/tmp/robot_v1_text_arial.stl" + write_stl(text_obj, output_file) + print(f"STL written to: {output_file}") + + # Calculate text width + calc_width = text_width("ROBOT V1", height=10.0, spacing=1.0) + print(f"Calculated text width: {calc_width:.1f} mm") + else: + print("Warning: No surfaces generated") + + except Exception as e: + print(f"Error during test: {e}") + import traceback + traceback.print_exc() + + print("\nTest with explicit block font:") + print("-" * 50) + try: + text_obj_block = text_solid("ROBOT V1", height=10.0, depth=2.0, + spacing=1.0, font="block") + if text_obj_block and text_obj_block[1]: + num_surfaces = len(text_obj_block[1]) + print(f"Success! Generated solid with {num_surfaces} surfaces (block font)") + + bbox_block = solidbbox(text_obj_block) + if bbox_block: + width = bbox_block[1][0] - bbox_block[0][0] + height = bbox_block[1][1] - bbox_block[0][1] + depth = bbox_block[1][2] - bbox_block[0][2] + print(f"Dimensions: {width:.1f} x {height:.1f} x {depth:.1f} mm") + + output_file_block = "/tmp/robot_v1_text_block.stl" + write_stl(text_obj_block, output_file_block) + print(f"STL written to: {output_file_block}") + except Exception as e: + print(f"Error with block font: {e}") + + print("\nTest font finding functionality:") + print("-" * 50) + if FREETYPE_AVAILABLE: + test_fonts = ["Arial", "Helvetica", "Times", "Courier"] + for font_name in test_fonts: + font_path = find_system_font(font_name) + if font_path: + print(f" {font_name}: {font_path}") + else: + print(f" {font_name}: Not found") + else: + print(" freetype-py not available, skipping font search") + + print("\n" + "=" * 50) + print("All tests completed successfully!") diff --git a/tests/test_brep.py b/tests/test_brep.py index 0ba3fe6..25d37cb 100644 --- a/tests/test_brep.py +++ b/tests/test_brep.py @@ -26,6 +26,8 @@ brep_from_solid, has_brep_data, occ_available, + fillet_all_edges, + chamfer_all_edges, ) try: # pragma: no cover - exercised when pythonocc-core is installed @@ -300,3 +302,116 @@ def test_loft_solid_brep_and_occ_boolean(): cutter = translatesolid(cutter, point(0.0, 0.0, 0.25)) result = solid_boolean(sld, cutter, 'union', engine='occ') assert has_brep_data(result) + + +def test_fillet_all_edges_on_box(): + """Test fillet_all_edges on a simple box.""" + if not occ_available(): + pytest.skip("pythonocc-core not available") + if BRepPrimAPI_MakeBox is None: + pytest.skip("pythonocc-core not available") + + # Create a 10x10x10 box + shape = BRepPrimAPI_MakeBox(10.0, 10.0, 10.0).Shape() + brep = BrepSolid(shape) + + # Apply fillet with radius 1.0 + filleted = fillet_all_edges(brep, 1.0) + + # Verify we got a valid shape back + assert filleted is not None + assert isinstance(filleted, BrepSolid) + + # Verify tessellation works + surface = filleted.tessellate() + assert surface[0] == 'surface' + assert len(surface[1]) > 0 # Has vertices + assert len(surface[3]) > 0 # Has triangles + + +def test_chamfer_all_edges_on_box(): + """Test chamfer_all_edges on a simple box.""" + if not occ_available(): + pytest.skip("pythonocc-core not available") + if BRepPrimAPI_MakeBox is None: + pytest.skip("pythonocc-core not available") + + # Create a 10x10x10 box + shape = BRepPrimAPI_MakeBox(10.0, 10.0, 10.0).Shape() + brep = BrepSolid(shape) + + # Apply chamfer with distance 0.5 + chamfered = chamfer_all_edges(brep, 0.5) + + # Verify we got a valid shape back + assert chamfered is not None + assert isinstance(chamfered, BrepSolid) + + # Verify tessellation works + surface = chamfered.tessellate() + assert surface[0] == 'surface' + assert len(surface[1]) > 0 # Has vertices + assert len(surface[3]) > 0 # Has triangles + + +def test_fillet_on_prism(): + """Test fillet on a prism created via yapCAD's prism function.""" + if not occ_available(): + pytest.skip("pythonocc-core not available") + + # Create a prism which already has BREP attached + sld = prism(20.0, 10.0, 5.0) + assert has_brep_data(sld) + + # Get the BREP and fillet it + brep = brep_from_solid(sld) + filleted = fillet_all_edges(brep, 0.5) + + # Verify result + assert filleted is not None + surface = filleted.tessellate() + assert surface[0] == 'surface' + + +def test_chamfer_on_prism(): + """Test chamfer on a prism created via yapCAD's prism function.""" + if not occ_available(): + pytest.skip("pythonocc-core not available") + + # Create a prism which already has BREP attached + sld = prism(20.0, 10.0, 5.0) + assert has_brep_data(sld) + + # Get the BREP and chamfer it + brep = brep_from_solid(sld) + chamfered = chamfer_all_edges(brep, 0.3) + + # Verify result + assert chamfered is not None + surface = chamfered.tessellate() + assert surface[0] == 'surface' + + +def test_fillet_preserves_solid_creation(): + """Test that filleted BREP can be attached to a new yapCAD solid.""" + if not occ_available(): + pytest.skip("pythonocc-core not available") + if BRepPrimAPI_MakeBox is None: + pytest.skip("pythonocc-core not available") + + # Create and fillet a box + shape = BRepPrimAPI_MakeBox(10.0, 10.0, 10.0).Shape() + brep = BrepSolid(shape) + filleted = fillet_all_edges(brep, 1.0) + + # Create a yapCAD solid from the filleted BREP + surface = filleted.tessellate() + sld = solid([surface]) + attach_brep_to_solid(sld, filleted) + + # Verify BREP data is preserved + assert has_brep_data(sld) + + # Verify we can get BREP back + restored = brep_from_solid(sld) + assert restored is not None diff --git a/tests/test_fillet_chamfer.py b/tests/test_fillet_chamfer.py new file mode 100644 index 0000000..1204edc --- /dev/null +++ b/tests/test_fillet_chamfer.py @@ -0,0 +1,267 @@ +""" +Test script for fillet and chamfer operations. + +This script tests both the Python API and DSL builtins for fillet/chamfer. +""" + +import math +import pytest + +from yapcad.geom import point +from yapcad.geom3d import solid +from yapcad.geom3d_util import prism, conic +from yapcad.brep import ( + BrepSolid, + attach_brep_to_solid, + brep_from_solid, + has_brep_data, + occ_available, + fillet_all_edges, + chamfer_all_edges, +) + +# Skip all tests if OCC not available +pytestmark = pytest.mark.skipif( + not occ_available(), reason="pythonocc-core not available" +) + + +class TestFilletPythonAPI: + """Test fillet operations via Python API.""" + + def test_fillet_box_basic(self): + """Basic fillet test on a box.""" + from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox + + shape = BRepPrimAPI_MakeBox(20.0, 10.0, 5.0).Shape() + brep = BrepSolid(shape) + + filleted = fillet_all_edges(brep, 1.0) + + assert filleted is not None + assert isinstance(filleted, BrepSolid) + + def test_fillet_tessellation(self): + """Verify filleted shape tessellates correctly.""" + from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox + + shape = BRepPrimAPI_MakeBox(10.0, 10.0, 10.0).Shape() + brep = BrepSolid(shape) + filleted = fillet_all_edges(brep, 0.5) + + surface = filleted.tessellate() + + assert surface[0] == 'surface' + assert len(surface[1]) > 0 # vertices + assert len(surface[2]) > 0 # normals + assert len(surface[3]) > 0 # triangles + + def test_fillet_on_yapCAD_prism(self): + """Test fillet on a prism created via yapCAD.""" + sld = prism(30.0, 20.0, 10.0) + assert has_brep_data(sld) + + brep = brep_from_solid(sld) + filleted = fillet_all_edges(brep, 2.0) + + surface = filleted.tessellate() + assert surface[0] == 'surface' + + def test_fillet_small_radius(self): + """Test fillet with very small radius.""" + from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox + + shape = BRepPrimAPI_MakeBox(50.0, 50.0, 50.0).Shape() + brep = BrepSolid(shape) + filleted = fillet_all_edges(brep, 0.1) + + assert filleted is not None + + def test_fillet_large_radius(self): + """Test fillet with radius approaching edge limit.""" + from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox + + # 10x10x10 box, max fillet radius is ~5 (half the smallest dimension) + shape = BRepPrimAPI_MakeBox(10.0, 10.0, 10.0).Shape() + brep = BrepSolid(shape) + + # Use radius of 3 (safe margin) + filleted = fillet_all_edges(brep, 3.0) + assert filleted is not None + + +class TestChamferPythonAPI: + """Test chamfer operations via Python API.""" + + def test_chamfer_box_basic(self): + """Basic chamfer test on a box.""" + from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox + + shape = BRepPrimAPI_MakeBox(20.0, 10.0, 5.0).Shape() + brep = BrepSolid(shape) + + chamfered = chamfer_all_edges(brep, 0.5) + + assert chamfered is not None + assert isinstance(chamfered, BrepSolid) + + def test_chamfer_tessellation(self): + """Verify chamfered shape tessellates correctly.""" + from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox + + shape = BRepPrimAPI_MakeBox(10.0, 10.0, 10.0).Shape() + brep = BrepSolid(shape) + chamfered = chamfer_all_edges(brep, 0.5) + + surface = chamfered.tessellate() + + assert surface[0] == 'surface' + assert len(surface[1]) > 0 # vertices + assert len(surface[2]) > 0 # normals + assert len(surface[3]) > 0 # triangles + + def test_chamfer_on_yapCAD_prism(self): + """Test chamfer on a prism created via yapCAD.""" + sld = prism(30.0, 20.0, 10.0) + assert has_brep_data(sld) + + brep = brep_from_solid(sld) + chamfered = chamfer_all_edges(brep, 1.0) + + surface = chamfered.tessellate() + assert surface[0] == 'surface' + + +class TestFilletChamferIntegration: + """Integration tests for fillet/chamfer with yapCAD workflow.""" + + def test_fillet_then_attach_brep(self): + """Test workflow: create BREP, fillet, attach to yapCAD solid.""" + from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox + + # Create and fillet + shape = BRepPrimAPI_MakeBox(15.0, 15.0, 15.0).Shape() + brep = BrepSolid(shape) + filleted = fillet_all_edges(brep, 1.5) + + # Create yapCAD solid + surface = filleted.tessellate() + sld = solid([surface], [], ['procedure', 'test_fillet']) + attach_brep_to_solid(sld, filleted) + + # Verify + assert has_brep_data(sld) + restored = brep_from_solid(sld) + assert restored is not None + + def test_chamfer_then_attach_brep(self): + """Test workflow: create BREP, chamfer, attach to yapCAD solid.""" + from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox + + # Create and chamfer + shape = BRepPrimAPI_MakeBox(15.0, 15.0, 15.0).Shape() + brep = BrepSolid(shape) + chamfered = chamfer_all_edges(brep, 0.8) + + # Create yapCAD solid + surface = chamfered.tessellate() + sld = solid([surface], [], ['procedure', 'test_chamfer']) + attach_brep_to_solid(sld, chamfered) + + # Verify + assert has_brep_data(sld) + restored = brep_from_solid(sld) + assert restored is not None + + +class TestDSLBuiltins: + """Test DSL builtin functions for fillet and chamfer.""" + + def test_dsl_fillet_builtin_registered(self): + """Verify fillet is registered as a DSL builtin.""" + from yapcad.dsl.runtime.builtins import get_builtin_registry + + registry = get_builtin_registry() + fillet_func = registry.get_function("fillet") + + assert fillet_func is not None + assert fillet_func.name == "fillet" + + def test_dsl_chamfer_builtin_registered(self): + """Verify chamfer is registered as a DSL builtin.""" + from yapcad.dsl.runtime.builtins import get_builtin_registry + + registry = get_builtin_registry() + chamfer_func = registry.get_function("chamfer") + + assert chamfer_func is not None + assert chamfer_func.name == "chamfer" + + def test_dsl_fillet_type_signature(self): + """Verify fillet has correct type signature in symbols.""" + from yapcad.dsl.symbols import SymbolTable + + table = SymbolTable() + sig = table.lookup_builtin("fillet") + + assert sig is not None + assert sig.name == "fillet" + assert len(sig.params) == 2 + assert sig.params[0][0] == "s" # solid parameter + assert sig.params[1][0] == "radius" # radius parameter + assert sig.return_type.name == "solid" + + def test_dsl_chamfer_type_signature(self): + """Verify chamfer has correct type signature in symbols.""" + from yapcad.dsl.symbols import SymbolTable + + table = SymbolTable() + sig = table.lookup_builtin("chamfer") + + assert sig is not None + assert sig.name == "chamfer" + assert len(sig.params) == 2 + assert sig.params[0][0] == "s" # solid parameter + assert sig.params[1][0] == "distance" # distance parameter + assert sig.return_type.name == "solid" + + +if __name__ == "__main__": + # Run basic tests when executed directly + print("Testing fillet and chamfer operations...") + + if not occ_available(): + print("ERROR: pythonocc-core not available. Cannot run tests.") + exit(1) + + print("1. Testing fillet on box...") + from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox + + shape = BRepPrimAPI_MakeBox(20.0, 10.0, 5.0).Shape() + brep = BrepSolid(shape) + filleted = fillet_all_edges(brep, 1.0) + surface = filleted.tessellate() + print(f" Filleted box: {len(surface[1])} vertices, {len(surface[3])} triangles") + + print("2. Testing chamfer on box...") + shape = BRepPrimAPI_MakeBox(20.0, 10.0, 5.0).Shape() + brep = BrepSolid(shape) + chamfered = chamfer_all_edges(brep, 0.5) + surface = chamfered.tessellate() + print(f" Chamfered box: {len(surface[1])} vertices, {len(surface[3])} triangles") + + print("3. Testing fillet on yapCAD prism...") + sld = prism(30.0, 20.0, 10.0) + brep = brep_from_solid(sld) + filleted = fillet_all_edges(brep, 2.0) + surface = filleted.tessellate() + print(f" Filleted prism: {len(surface[1])} vertices, {len(surface[3])} triangles") + + print("4. Testing DSL builtins registered...") + from yapcad.dsl.runtime.builtins import get_builtin_registry + registry = get_builtin_registry() + assert registry.get_function("fillet") is not None + assert registry.get_function("chamfer") is not None + print(" fillet and chamfer builtins registered correctly") + + print("\nAll tests passed!") diff --git a/tests/test_pattern_functions.py b/tests/test_pattern_functions.py new file mode 100644 index 0000000..7975140 --- /dev/null +++ b/tests/test_pattern_functions.py @@ -0,0 +1,197 @@ +""" +Tests for radial_pattern() and linear_pattern() functions. +""" + +import pytest +import math +from yapcad.geom import point, arc, line, dist, close + + +class TestRadialPattern: + """Tests for 2D radial pattern function.""" + + def test_radial_pattern_6_copies(self): + """Test creating 6 holes in a radial pattern.""" + from yapcad.geom_util import radial_pattern + + # Create a circle at radius 10 + hole = arc(point(10, 0), 2.5) + holes = radial_pattern(hole, count=6) + + assert len(holes) == 6 + + # Check that holes are evenly distributed (60 degree intervals) + expected_angles = [0, 60, 120, 180, 240, 300] + for i, h in enumerate(holes): + center = h[0] + angle = math.degrees(math.atan2(center[1], center[0])) + if angle < 0: + angle += 360 + # Normalize expected angle + expected = expected_angles[i] + # Allow some floating point tolerance - check within 1 degree + angle_diff = abs(angle - expected) + if angle_diff > 180: + angle_diff = 360 - angle_diff + assert angle_diff < 1.0, f"Hole {i}: expected {expected}, got {angle}" + # Check radius is maintained + r = math.sqrt(center[0]**2 + center[1]**2) + assert close(r, 10.0) + + def test_radial_pattern_count_1(self): + """Test that count=1 returns just the original.""" + from yapcad.geom_util import radial_pattern + + hole = arc(point(10, 0), 2.5) + holes = radial_pattern(hole, count=1) + + assert len(holes) == 1 + assert holes[0] is hole # Should be the same object + + def test_radial_pattern_custom_center(self): + """Test radial pattern with custom center point.""" + from yapcad.geom_util import radial_pattern + + hole = arc(point(15, 5), 2.5) # hole at (15,5) + center = point(5, 5) # rotate around (5,5) + holes = radial_pattern(hole, count=4, center=center) + + assert len(holes) == 4 + + # All holes should be 10mm from center (5,5) + for h in holes: + hcenter = h[0] + r = dist(hcenter, center) + assert close(r, 10.0) + + def test_radial_pattern_partial_angle(self): + """Test radial pattern with less than 360 degrees.""" + from yapcad.geom_util import radial_pattern + + hole = arc(point(10, 0), 2.5) + holes = radial_pattern(hole, count=3, angle=90) # 3 holes over 90 degrees + + assert len(holes) == 3 + + # First should be at 0 degrees, last at 90 degrees + first_center = holes[0][0] + last_center = holes[2][0] + + assert close(first_center[0], 10.0) + assert close(first_center[1], 0.0) + assert close(last_center[0], 0.0) + assert close(last_center[1], 10.0) + + def test_radial_pattern_invalid_count(self): + """Test that count < 1 raises error.""" + from yapcad.geom_util import radial_pattern + + hole = arc(point(10, 0), 2.5) + + with pytest.raises(ValueError): + radial_pattern(hole, count=0) + + +class TestLinearPattern: + """Tests for 2D linear pattern function.""" + + def test_linear_pattern_4_copies(self): + """Test creating 4 holes in a linear pattern.""" + from yapcad.geom_util import linear_pattern + + hole = arc(point(0, 0), 3) + holes = linear_pattern(hole, count=4, spacing=[20, 0, 0]) + + assert len(holes) == 4 + + # Check positions + for i, h in enumerate(holes): + center = h[0] + assert close(center[0], i * 20.0) + assert close(center[1], 0.0) + + def test_linear_pattern_count_1(self): + """Test that count=1 returns just the original.""" + from yapcad.geom_util import linear_pattern + + hole = arc(point(0, 0), 3) + holes = linear_pattern(hole, count=1, spacing=[20, 0, 0]) + + assert len(holes) == 1 + assert holes[0] is hole + + def test_linear_pattern_diagonal(self): + """Test linear pattern with diagonal spacing.""" + from yapcad.geom_util import linear_pattern + + hole = arc(point(0, 0), 3) + holes = linear_pattern(hole, count=3, spacing=[10, 10, 0]) + + assert len(holes) == 3 + + for i, h in enumerate(holes): + center = h[0] + assert close(center[0], i * 10.0) + assert close(center[1], i * 10.0) + + def test_linear_pattern_with_line(self): + """Test linear pattern on a line segment.""" + from yapcad.geom_util import linear_pattern + + l = line(point(0, 0), point(5, 0)) + lines = linear_pattern(l, count=3, spacing=[10, 0, 0]) + + assert len(lines) == 3 + + # Check start points of each line + assert close(lines[0][0][0], 0.0) + assert close(lines[1][0][0], 10.0) + assert close(lines[2][0][0], 20.0) + + def test_linear_pattern_invalid_count(self): + """Test that count < 1 raises error.""" + from yapcad.geom_util import linear_pattern + + hole = arc(point(0, 0), 3) + + with pytest.raises(ValueError): + linear_pattern(hole, count=0, spacing=[20, 0, 0]) + + +class TestSolidPatterns: + """Tests for 3D solid pattern functions.""" + + def test_radial_pattern_solid(self): + """Test radial pattern with 3D solid.""" + from yapcad.geom3d_util import conic, radial_pattern_solid + from yapcad.geom3d import translatesolid, issolid + + cylinder = conic(2.5, 2.5, 10) + cylinder = translatesolid(cylinder, point(10, 0, 0)) + + copies = radial_pattern_solid(cylinder, count=4) + + assert len(copies) == 4 + for s in copies: + assert issolid(s) + + def test_linear_pattern_solid(self): + """Test linear pattern with 3D solid.""" + from yapcad.geom3d_util import conic, linear_pattern_solid + from yapcad.geom3d import issolid + + cylinder = conic(3, 3, 10) + copies = linear_pattern_solid(cylinder, count=3, spacing=[20, 0, 0]) + + assert len(copies) == 3 + for s in copies: + assert issolid(s) + + def test_pattern_solid_invalid(self): + """Test that non-solid raises error.""" + from yapcad.geom3d_util import radial_pattern_solid + + not_solid = [1, 2, 3] + + with pytest.raises(ValueError): + radial_pattern_solid(not_solid, count=4) diff --git a/tests/test_text3d.py b/tests/test_text3d.py new file mode 100644 index 0000000..81ee46e --- /dev/null +++ b/tests/test_text3d.py @@ -0,0 +1,208 @@ +"""Tests for the text3d module.""" + +import pytest +from yapcad.text3d import ( + text_to_polygons, text_solid, text_width, + engrave_text, get_supported_characters, BLOCK_FONT +) +from yapcad.geom import point, vect, dist, epsilon +from yapcad.geom3d import issolid, solidbbox +from yapcad.geom3d_util import prism + + +class TestBlockFont: + """Test the BLOCK_FONT data structure.""" + + def test_font_has_letters(self): + """Font should contain A-Z.""" + for c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ': + assert c in BLOCK_FONT, f"Missing letter {c}" + + def test_font_has_digits(self): + """Font should contain 0-9.""" + for c in '0123456789': + assert c in BLOCK_FONT, f"Missing digit {c}" + + def test_font_has_punctuation(self): + """Font should contain basic punctuation.""" + for c in '-_.:/()': + assert c in BLOCK_FONT, f"Missing punctuation {c}" + + def test_font_has_space(self): + """Font should contain space (empty list).""" + assert ' ' in BLOCK_FONT + assert BLOCK_FONT[' '] == [] + + def test_rectangles_are_valid(self): + """Each rectangle should be (x, y, w, h) with non-negative values.""" + for char, rects in BLOCK_FONT.items(): + for rect in rects: + assert len(rect) == 4, f"Bad rectangle in {char}: {rect}" + x, y, w, h = rect + assert x >= 0, f"Negative x in {char}" + assert y >= 0, f"Negative y in {char}" + assert w > 0, f"Non-positive width in {char}" + assert h > 0, f"Non-positive height in {char}" + + +class TestTextToPolygons: + """Test the text_to_polygons function.""" + + def test_single_character(self): + """Single character should produce polygons.""" + polys = text_to_polygons('A', height=5.0) + assert len(polys) > 0, "Should produce at least one polygon" + + def test_empty_string(self): + """Empty string should produce no polygons.""" + polys = text_to_polygons('', height=5.0) + assert polys == [] + + def test_space_only(self): + """Space-only string should produce no polygons.""" + polys = text_to_polygons(' ', height=5.0) + assert polys == [] + + def test_polygon_closed(self): + """Each polygon should be closed (first point == last point).""" + polys = text_to_polygons('X', height=5.0) + for poly in polys: + assert dist(poly[0], poly[-1]) < epsilon, "Polygon not closed" + + def test_height_scaling(self): + """Polygon coordinates should scale with height parameter.""" + polys_5 = text_to_polygons('A', height=5.0) + polys_10 = text_to_polygons('A', height=10.0) + + # Max Y should be roughly proportional to height + max_y_5 = max(p[1] for poly in polys_5 for p in poly) + max_y_10 = max(p[1] for poly in polys_10 for p in poly) + ratio = max_y_10 / max_y_5 + assert 1.9 < ratio < 2.1, f"Height scaling incorrect: {ratio}" + + +class TestTextWidth: + """Test the text_width function.""" + + def test_empty_string(self): + """Empty string should have zero width.""" + assert text_width('') == 0.0 + + def test_single_character(self): + """Single character width should be positive.""" + w = text_width('A', height=5.0) + assert w > 0 + + def test_longer_text(self): + """Longer text should have greater width.""" + w1 = text_width('A', height=5.0) + w3 = text_width('ABC', height=5.0) + assert w3 > w1 + + def test_height_scaling(self): + """Width should scale with height.""" + w5 = text_width('HELLO', height=5.0) + w10 = text_width('HELLO', height=10.0) + ratio = w10 / w5 + assert 1.9 < ratio < 2.1, f"Width scaling incorrect: {ratio}" + + +class TestTextSolid: + """Test the text_solid function.""" + + def test_produces_valid_solid(self): + """text_solid should produce a valid yapCAD solid.""" + solid = text_solid('A', height=5.0, depth=1.0) + assert issolid(solid), "Result should be a valid solid" + + def test_has_surfaces(self): + """Result solid should have surfaces.""" + solid = text_solid('HI', height=5.0, depth=1.0) + assert len(solid[1]) > 0, "Solid should have surfaces" + + def test_empty_string(self): + """Empty string should produce empty solid.""" + solid = text_solid('', height=5.0, depth=1.0) + assert issolid(solid) + assert len(solid[1]) == 0 + + def test_depth_parameter(self): + """Depth should affect Z extent of bounding box.""" + solid_thin = text_solid('X', height=5.0, depth=0.5) + solid_thick = text_solid('X', height=5.0, depth=2.0) + + bbox_thin = solidbbox(solid_thin) + bbox_thick = solidbbox(solid_thick) + + z_thin = bbox_thin[1][2] - bbox_thin[0][2] + z_thick = bbox_thick[1][2] - bbox_thick[0][2] + + assert z_thick > z_thin, "Thicker depth should produce larger Z extent" + + +class TestEngraveText: + """Test the engrave_text function.""" + + def test_produces_valid_solid(self): + """engrave_text should produce a valid solid.""" + plate = prism(30, 20, 3) + result = engrave_text( + plate, 'X', + position=point(0, 0, 1.5), + normal=vect(0, 0, 1, 0), + height=4.0, + depth=0.5 + ) + assert issolid(result) + + def test_invalid_target_raises(self): + """Should raise if target is not a solid.""" + with pytest.raises(ValueError): + engrave_text( + [1, 2, 3], # Not a solid + 'X', + position=point(0, 0, 0), + normal=vect(0, 0, 1, 0) + ) + + def test_empty_text_returns_copy(self): + """Empty text should return copy of original.""" + plate = prism(30, 20, 3) + result = engrave_text( + plate, '', + position=point(0, 0, 1.5), + normal=vect(0, 0, 1, 0) + ) + # Should have same number of surfaces as original + assert len(result[1]) == len(plate[1]) + + +class TestGetSupportedCharacters: + """Test the get_supported_characters function.""" + + def test_returns_string(self): + """Should return a string.""" + chars = get_supported_characters() + assert isinstance(chars, str) + + def test_contains_expected_characters(self): + """Should contain letters, digits, and punctuation.""" + chars = get_supported_characters() + for c in 'ABCXYZ0189-_.': + assert c in chars + + +class TestUnknownCharacter: + """Test handling of unknown characters.""" + + def test_unknown_char_produces_placeholder(self): + """Unknown characters should produce a placeholder polygon.""" + # Use a character not in the font + polys = text_to_polygons('@', height=5.0) + assert len(polys) > 0, "Should produce placeholder" + + def test_mixed_known_unknown(self): + """Mixed known and unknown characters should work.""" + polys = text_to_polygons('A@B', height=5.0) + # Should have polygons for all three characters + assert len(polys) > 0