"""
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)
Fit-aware text generation:
from yapcad.text3d import (
validate_text_fit, text_solid_fitted,
calculate_text_height_for_width
)
# Check if text fits before creating
fits, width, msg = validate_text_fit("DARK MATTER LAB",
height=15, max_width=160)
if not fits:
print(f"Warning: {msg}")
# Auto-fit instead
solid, actual_height = text_solid_fitted("DARK MATTER LAB",
max_width=160, depth=2)
print(f"Created text at height {actual_height:.1f}mm")
else:
solid = text_solid("DARK MATTER LAB", height=15, depth=2)
# Calculate exact height for a target width
height = calculate_text_height_for_width("ROBOT V1", target_width=100)
label = text_solid("ROBOT V1", height=height, depth=2)
"""
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
# Public API
__all__ = [
'text_solid',
'text_width',
'validate_text_fit',
'calculate_text_height_for_width',
'text_solid_fitted',
'text_on_surface',
'engrave_text',
'text_to_polygons',
'get_supported_characters',
'find_system_font',
]
# 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
}
[docs]
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 _polygon_signed_area(polygon):
"""Calculate the signed area of a 2D polygon using the shoelace formula.
Positive area = counter-clockwise winding (outer boundary)
Negative area = clockwise winding (hole/inner boundary)
Args:
polygon: List of points forming a closed polygon
Returns:
Signed area (positive for CCW, negative for CW)
"""
n = len(polygon)
if n < 3:
return 0.0
# Remove closing point if present for area calculation
if polygon[0][0] == polygon[-1][0] and polygon[0][1] == polygon[-1][1]:
n -= 1
area = 0.0
for i in range(n):
j = (i + 1) % n
area += polygon[i][0] * polygon[j][1]
area -= polygon[j][0] * polygon[i][1]
return area / 2.0
def _point_in_polygon(px, py, polygon):
"""Check if a point is inside a polygon using ray casting.
Args:
px, py: Point coordinates
polygon: List of points forming a closed polygon
Returns:
True if point is inside the polygon
"""
n = len(polygon)
if n < 3:
return False
# Remove closing point if present
if polygon[0][0] == polygon[-1][0] and polygon[0][1] == polygon[-1][1]:
n -= 1
inside = False
j = n - 1
for i in range(n):
xi, yi = polygon[i][0], polygon[i][1]
xj, yj = polygon[j][0], polygon[j][1]
if ((yi > py) != (yj > py)) and (px < (xj - xi) * (py - yi) / (yj - yi) + xi):
inside = not inside
j = i
return inside
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 glyph_to_grouped_contours(glyph, x_offset=0.0, scale=1.0):
"""Convert a freetype glyph outline to grouped contours with hole information.
This function properly identifies outer boundaries and inner holes (counters)
for letters like P, D, B, A, R, etc.
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 dicts, each with:
- 'outer': The outer boundary polygon
- 'holes': List of inner hole polygons
"""
polygons = glyph_to_polygons(glyph, x_offset, scale)
if not polygons:
return []
# Calculate signed area for each polygon
poly_data = []
for poly in polygons:
area = _polygon_signed_area(poly)
# Get a representative interior point for containment testing
# Use centroid of first 3 points
cx = (poly[0][0] + poly[1][0] + poly[2][0]) / 3.0
cy = (poly[0][1] + poly[1][1] + poly[2][1]) / 3.0
poly_data.append({
'polygon': poly,
'area': area,
'abs_area': abs(area),
'centroid': (cx, cy),
'is_hole': None, # To be determined
'parent': None # Index of containing polygon
})
# Sort by absolute area (largest first) - outer contours are typically larger
poly_data.sort(key=lambda x: x['abs_area'], reverse=True)
# Determine parent-child relationships based on containment
for i, pd in enumerate(poly_data):
cx, cy = pd['centroid']
# Check if this polygon's centroid is inside any larger polygon
for j in range(i): # Only check larger polygons (already sorted)
if _point_in_polygon(cx, cy, poly_data[j]['polygon']):
pd['parent'] = j
break # Found immediate parent (smallest containing)
# Build grouped contours
# A polygon is an outer boundary if it has no parent, or its parent is a hole
# A polygon is a hole if it's contained in an outer boundary
# First pass: identify which polygons are outer boundaries (no parent or parent is hole)
for i, pd in enumerate(poly_data):
if pd['parent'] is None:
pd['is_hole'] = False # Top-level = outer
# Otherwise, it will be determined based on parent
# Second pass: propagate hole/outer status
changed = True
iterations = 0
while changed and iterations < 100:
changed = False
iterations += 1
for i, pd in enumerate(poly_data):
if pd['is_hole'] is None and pd['parent'] is not None:
parent = poly_data[pd['parent']]
if parent['is_hole'] is not None:
# Alternate: if parent is outer, this is hole; if parent is hole, this is outer
pd['is_hole'] = not parent['is_hole']
changed = True
# Build result: group holes with their outer boundaries
result = []
outer_to_result_idx = {}
for i, pd in enumerate(poly_data):
if not pd['is_hole']:
# This is an outer boundary
outer_to_result_idx[i] = len(result)
result.append({
'outer': pd['polygon'],
'holes': []
})
# Add holes to their parent outer boundaries
for i, pd in enumerate(poly_data):
if pd['is_hole'] and pd['parent'] is not None:
# Find the outermost non-hole ancestor
ancestor = pd['parent']
while poly_data[ancestor]['is_hole'] and poly_data[ancestor]['parent'] is not None:
ancestor = poly_data[ancestor]['parent']
if ancestor in outer_to_result_idx:
result[outer_to_result_idx[ancestor]]['holes'].append(pd['polygon'])
return result
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
]
[docs]
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_grouped_glyphs_ttf(text, height, spacing, font_path):
"""Convert text to grouped glyph contours with hole information using TrueType font.
This function properly handles letters with interior holes (counters) like
P, D, B, A, R, O, Q, etc.
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 glyph groups, where each group is a dict with:
- 'outer': The outer boundary polygon
- 'holes': List of inner hole polygons
"""
if not FREETYPE_AVAILABLE:
return None # Signal to fall back to block font
try:
face = freetype.Face(font_path)
face.set_char_size(100 * 64) # 100 points
grouped_glyphs = []
x_offset = 0.0
mm_per_pixel = 25.4 / 72.0
# Measure reference height
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
for char in text:
face.load_char(char, freetype.FT_LOAD_DEFAULT | freetype.FT_LOAD_NO_BITMAP)
glyph = face.glyph
# Get grouped contours with hole information
char_groups = glyph_to_grouped_contours(glyph, x_offset, scale * mm_per_pixel)
grouped_glyphs.extend(char_groups)
# Advance to next character position
advance = glyph.advance.x / 64.0 * mm_per_pixel * scale
x_offset += advance + spacing
return grouped_glyphs
except Exception as e:
print(f"Warning: TrueType font rendering failed ({e}), using block font")
import traceback
traceback.print_exc()
return None
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
[docs]
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. Properly handles letters with
interior holes (counters) like P, D, B, A, R, O, Q, etc.
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)
"""
# 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
# Try to get grouped glyphs with hole information for TrueType fonts
grouped_glyphs = None
if use_ttf and font_path:
grouped_glyphs = _text_to_grouped_glyphs_ttf(text, height, spacing, font_path)
all_surfaces = []
if grouped_glyphs is not None:
# Use grouped glyph data with proper hole handling
for glyph_group in grouped_glyphs:
outer = glyph_group['outer']
holes = glyph_group['holes']
try:
# Create surface with holes using poly2surfaceXY
surf_result = poly2surfaceXY(outer, holes)
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 glyphs
continue
else:
# Fall back to simple polygon processing (block font or TTF fallback)
polygons = text_to_polygons(text, height, spacing, font)
if not polygons:
return solid([], [], ['procedure', f'text_solid("{text}")'])
for poly in polygons:
try:
# Create surface from polygon
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
[docs]
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)
[docs]
def validate_text_fit(text, height, max_width, spacing=1.0, font=None):
"""Check if text will fit within a maximum width.
This function validates whether the generated text will fit within a
specified maximum width, useful for ensuring text fits on target surfaces
before creating the 3D solid.
Args:
text: String to validate
height: Character height in mm
max_width: Maximum allowed width in mm
spacing: Character spacing (default 1.0)
font: Font specification (same as text_solid)
Returns:
Tuple of (fits: bool, actual_width: float, message: str)
- fits: True if text fits within max_width
- actual_width: The calculated width of the text
- message: Human-readable status message
Example:
>>> fits, width, msg = validate_text_fit("ROBOT", height=10, max_width=50)
>>> if not fits:
... print(f"Warning: {msg}")
"""
actual_width = text_width(text, height, spacing, font)
fits = actual_width <= max_width
if fits:
message = f"Text fits: {actual_width:.1f}mm <= {max_width:.1f}mm"
else:
overflow = actual_width - max_width
message = f"Text TOO WIDE: {actual_width:.1f}mm > {max_width:.1f}mm (overflow: {overflow:.1f}mm)"
return (fits, actual_width, message)
[docs]
def calculate_text_height_for_width(text, target_width, spacing=1.0, font=None):
"""Calculate the character height needed to achieve a target text width.
Given a desired total text width, this function calculates the character
height that will produce text of approximately that width. Useful for
fitting text precisely into a known space.
Args:
text: String to size
target_width: Desired total text width in mm
spacing: Character spacing (default 1.0)
font: Font specification (same as text_solid)
Returns:
Float: Character height in mm that will produce the target width
Example:
>>> height = calculate_text_height_for_width("ROBOT V1", target_width=100)
>>> solid = text_solid("ROBOT V1", height=height, depth=2)
>>> # solid will be approximately 100mm wide
"""
# Use a reference height to get the width ratio
ref_height = 10.0
ref_width = text_width(text, ref_height, spacing, font)
# Avoid division by zero
if ref_width < epsilon:
return ref_height
# Scale height proportionally to achieve target width
return ref_height * (target_width / ref_width)
[docs]
def text_solid_fitted(text, max_width, depth, spacing=1.0, font=None, min_height=3.0):
"""Create 3D text that fits within max_width by auto-scaling height.
This function automatically calculates the optimal character height to
ensure the text fits within the specified maximum width. It will not
go below min_height to maintain readability.
Args:
text: String to render
max_width: Maximum allowed width in mm
depth: Extrusion depth in mm
spacing: Character spacing (default 1.0)
font: Font specification (same as text_solid)
min_height: Minimum character height in mm (default 3.0)
Returns:
Tuple of (solid, actual_height: float)
- solid: The generated yapCAD solid
- actual_height: The character height that was used
Example:
>>> solid, height = text_solid_fitted("DARK MATTER LAB", max_width=160, depth=2)
>>> print(f"Text created with height {height:.1f}mm")
>>> # solid is guaranteed to fit within 160mm width
Workflow:
When creating text for a surface with known dimensions, use this
function to ensure the text will fit:
>>> # Check if preferred size fits
>>> fits, width, msg = validate_text_fit("LABEL", height=15, max_width=160)
>>> if not fits:
... # Auto-fit instead
... solid, height = text_solid_fitted("LABEL", max_width=160, depth=2)
... else:
... # Use preferred size
... solid = text_solid("LABEL", height=15, depth=2)
"""
if not text:
# Return empty solid for empty text
return (solid([], [], ['procedure', 'text_solid_fitted("")']), min_height)
# Start with a reasonable height estimate based on character count
# Assume average character takes up about 60% of its height in width
height = max_width / len(text) * 1.5
# Iteratively refine to find the optimal height
for iteration in range(10): # Max iterations to prevent infinite loops
width = text_width(text, height, spacing, font)
if width <= max_width:
# Text fits at this height
break
# Scale down height proportionally with a safety margin (95%)
height = height * (max_width / width) * 0.95
# Ensure we don't go below minimum height
height = max(height, min_height)
# Create the text solid at the calculated height
text_sld = text_solid(text, height, depth, spacing, font=font)
return (text_sld, height)
[docs]
def text_on_surface(
text: str,
surface_center, # (x, y, z) center point of target surface
surface_normal, # (nx, ny, nz) outward-pointing normal
up_direction, # (ux, uy, uz) "up" direction on surface
max_width: float, # maximum text width
depth: float = 1.5, # how far text protrudes
margin: float = 0.0, # margin from surface (0 = touching)
font=None, # font specification (same as text_solid)
):
"""Place text on a surface, automatically handling all rotations.
Creates 3D text positioned on an arbitrary surface. The text is automatically
sized to fit within max_width and rotated to face outward along the surface
normal, with its "up" direction aligned with the specified up_direction.
This is a convenience function that handles the complex coordinate frame
transformation needed to place text on non-XY-plane surfaces.
Args:
text: String to render
surface_center: (x, y, z) center point where text should be placed
surface_normal: (nx, ny, nz) outward-pointing normal of the surface
up_direction: (ux, uy, uz) "up" direction on the surface (text baseline to top)
max_width: Maximum text width in mm (text will be auto-scaled to fit)
depth: How far text protrudes from surface in mm (default 1.5)
margin: Gap between text back and surface (default 0 = touching)
font: Font specification (same as text_solid)
Returns:
yapCAD solid (text positioned and oriented on surface)
The text will:
- Face outward along surface_normal
- Have its "up" direction aligned with up_direction
- Be centered at surface_center
- Protrude outward by depth
- Fit within max_width
Example for front face of battery cage (+Y face):
text_on_surface(
"DARK MATTER LAB",
surface_center=(0, 66.5, 100), # front face, centered, Z=100
surface_normal=(0, 1, 0), # faces +Y
up_direction=(0, 0, 1), # text stands up in Z
max_width=180,
depth=1.5
)
Example for top face:
text_on_surface(
"TOP",
surface_center=(0, 0, 50), # top surface
surface_normal=(0, 0, 1), # faces +Z (up)
up_direction=(0, 1, 0), # text reads toward +Y
max_width=40,
depth=1.0
)
"""
from yapcad.geom3d import solidbbox, translatesolid
# Convert inputs to proper format if needed
if isinstance(surface_center, (list, tuple)):
surface_center = point(surface_center[0], surface_center[1], surface_center[2])
if isinstance(surface_normal, (list, tuple)):
surface_normal = vect(surface_normal[0], surface_normal[1], surface_normal[2])
if isinstance(up_direction, (list, tuple)):
up_direction = vect(up_direction[0], up_direction[1], up_direction[2])
# Normalize the surface normal
n_mag = math.sqrt(surface_normal[0]**2 + surface_normal[1]**2 + surface_normal[2]**2)
if n_mag < epsilon:
raise ValueError("surface_normal has zero length")
n = vect(surface_normal[0]/n_mag, surface_normal[1]/n_mag, surface_normal[2]/n_mag)
# Normalize the up direction
u_mag = math.sqrt(up_direction[0]**2 + up_direction[1]**2 + up_direction[2]**2)
if u_mag < epsilon:
raise ValueError("up_direction has zero length")
up = vect(up_direction[0]/u_mag, up_direction[1]/u_mag, up_direction[2]/u_mag)
# Compute the "right" direction as cross(up, normal)
# This gives us the local X-axis for the text (text width direction)
right = [
up[1]*n[2] - up[2]*n[1],
up[2]*n[0] - up[0]*n[2],
up[0]*n[1] - up[1]*n[0],
0.0
]
r_mag = math.sqrt(right[0]**2 + right[1]**2 + right[2]**2)
if r_mag < epsilon:
raise ValueError("up_direction and surface_normal are parallel - cannot determine text orientation")
right = vect(right[0]/r_mag, right[1]/r_mag, right[2]/r_mag)
# Re-orthogonalize up to ensure perpendicularity
# new_up = cross(normal, right)
new_up = [
n[1]*right[2] - n[2]*right[1],
n[2]*right[0] - n[0]*right[2],
n[0]*right[1] - n[1]*right[0],
0.0
]
new_up_mag = math.sqrt(new_up[0]**2 + new_up[1]**2 + new_up[2]**2)
new_up = vect(new_up[0]/new_up_mag, new_up[1]/new_up_mag, new_up[2]/new_up_mag)
# Create text solid fitted to max_width (text is in XY plane, Z = depth direction)
text_sld, actual_height = text_solid_fitted(text, max_width, depth, font=font)
if not text_sld[1]: # No surfaces (empty text)
return text_sld
# Get text bounding box to find its center
bbox = solidbbox(text_sld)
if not bbox:
return text_sld
text_center = point(
(bbox[0][0] + bbox[1][0]) / 2.0,
(bbox[0][1] + bbox[1][1]) / 2.0,
(bbox[0][2] + bbox[1][2]) / 2.0
)
# First, translate text to center it at origin
text_centered = translatesolid(text_sld, scale3(text_center, -1.0))
# Build the rotation matrix from local frame to target frame
# Local frame: X (text width), Y (text height), Z (text depth/extrusion)
# Target frame: right (text width), new_up (text height), n (outward normal)
#
# The rotation matrix R transforms local basis to target basis:
# R = [right | new_up | n] (as column vectors)
# This means: R * [1,0,0] = right, R * [0,1,0] = new_up, R * [0,0,1] = n
# Build 4x4 transformation matrix (rotation only)
R = Matrix([
[right[0], new_up[0], n[0], 0],
[right[1], new_up[1], n[1], 0],
[right[2], new_up[2], n[2], 0],
[0, 0, 0, 1]
])
# Apply rotation to all surfaces
from yapcad.geom3d import rotatesurface
rotated_surfaces = []
for surf in text_centered[1]:
# Apply matrix to all vertices
new_verts = [R.mul(v) for v in surf[1]]
# Apply matrix to all normals
new_norms = [R.mul(nm) for nm in surf[2]]
# Create new surface
new_surf = deepcopy(surf)
new_surf[1] = new_verts
new_surf[2] = new_norms
rotated_surfaces.append(new_surf)
# Create rotated solid
from yapcad.geom3d import solid as make_solid
text_rotated = make_solid(rotated_surfaces, [], text_centered[3] if len(text_centered) > 3 else None)
# Translate to surface center, with margin offset along normal
offset = point(
surface_center[0] + n[0] * margin,
surface_center[1] + n[1] * margin,
surface_center[2] + n[2] * margin
)
result = translatesolid(text_rotated, offset)
return result
[docs]
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)
[docs]
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!")