Skip to content

API Reference

High-Level Visualization

generate_cdl_svg

Generate SVG visualization from a CDL string.

from crystal_renderer import generate_cdl_svg

generate_cdl_svg(
    cdl_string="cubic[m3m]:{111}@1.0",
    output_path="crystal.svg",
    show_axes=True,
    elev=30,
    azim=-45,
    color_by_form=False,
    show_grid=True,
    face_labels=False
)

crystal_renderer.generate_cdl_svg(cdl_string, output_path, show_axes=True, elev=30, azim=-45, color_by_form=False, show_grid=True, face_labels=False, info_properties=None, info_position='top-right', info_style='compact', info_fontsize=10, figsize=(10, 10), dpi=150, c_ratio=None)

Generate SVG from Crystal Description Language notation.

Parameters:

Name Type Description Default
cdl_string str

CDL notation string (e.g., "cubic[m3m]:{111}@1.0 + {100}@1.3")

required
output_path str | Path

Output SVG file path

required
show_axes bool

Whether to show crystallographic axes

True
elev float

Elevation angle for view

30
azim float

Azimuth angle for view

-45
color_by_form bool

If True, color faces by which form they belong to

False
show_grid bool

If False, hide background grid and panes

True
face_labels bool

If True, show Miller indices on visible faces

False
info_properties dict[str, Any] | None

Dictionary of properties to display in info panel

None
info_position str

Panel position

'top-right'
info_style str

Panel style ('compact', 'detailed', 'minimal')

'compact'
info_fontsize int

Font size for info panel

10
figsize tuple[int, int]

Figure size in inches

(10, 10)
dpi int

Output resolution

150
c_ratio float | None

c/a ratio for non-cubic systems (default: 1.1 for trigonal/hexagonal, 1.0 for others). Use higher values for more elongated crystals.

None

Returns:

Type Description
Path

Path to output file

Source code in src/crystal_renderer/visualization.py
def generate_cdl_svg(
    cdl_string: str,
    output_path: str | Path,
    show_axes: bool = True,
    elev: float = 30,
    azim: float = -45,
    color_by_form: bool = False,
    show_grid: bool = True,
    face_labels: bool = False,
    info_properties: dict[str, Any] | None = None,
    info_position: str = "top-right",
    info_style: str = "compact",
    info_fontsize: int = 10,
    figsize: tuple[int, int] = (10, 10),
    dpi: int = 150,
    c_ratio: float | None = None,
) -> Path:
    """Generate SVG from Crystal Description Language notation.

    Args:
        cdl_string: CDL notation string (e.g., "cubic[m3m]:{111}@1.0 + {100}@1.3")
        output_path: Output SVG file path
        show_axes: Whether to show crystallographic axes
        elev: Elevation angle for view
        azim: Azimuth angle for view
        color_by_form: If True, color faces by which form they belong to
        show_grid: If False, hide background grid and panes
        face_labels: If True, show Miller indices on visible faces
        info_properties: Dictionary of properties to display in info panel
        info_position: Panel position
        info_style: Panel style ('compact', 'detailed', 'minimal')
        info_fontsize: Font size for info panel
        figsize: Figure size in inches
        dpi: Output resolution
        c_ratio: c/a ratio for non-cubic systems (default: 1.1 for trigonal/hexagonal,
                 1.0 for others). Use higher values for more elongated crystals.

    Returns:
        Path to output file
    """
    # Import CDL parser and geometry
    try:
        from cdl_parser import parse_cdl
        from crystal_geometry import cdl_to_geometry
    except ImportError as e:
        raise ImportError(
            "cdl-parser and crystal-geometry packages required. "
            "Install with: pip install cdl-parser crystal-geometry"
        ) from e

    output_path = Path(output_path)

    # Parse and generate geometry
    description = parse_cdl(cdl_string)
    geometry = cdl_to_geometry(description)

    # Detect amorphous and aggregate modes
    is_amorphous = getattr(geometry, "is_amorphous", False)
    aggregate_meta = getattr(geometry, "aggregate_metadata", None)

    # Get colours based on crystal system
    crystal_system = description.system
    default_colours = HABIT_COLOURS.get(crystal_system, HABIT_COLOURS["cubic"])

    # Softer colours for amorphous shapes
    if is_amorphous:
        default_colours = {"face": "#B0BEC5", "edge": "#546E7A"}

    # Create figure
    fig = plt.figure(figsize=figsize)
    ax = fig.add_subplot(111, projection="3d")
    ax.set_proj_type("ortho")
    ax.view_init(elev=elev, azim=azim)

    # Draw faces
    has_components = (
        geometry.component_ids is not None
        and len(set(geometry.component_ids)) > 1
        and aggregate_meta is not None
    )

    if has_components and not color_by_form:
        # Color each face by component_id (aggregate mode)
        for i, face in enumerate(geometry.faces):
            verts = [geometry.vertices[j] for j in face]
            comp_id = geometry.component_ids[i] if geometry.component_ids else 0
            colours = FORM_COLORS[comp_id % len(FORM_COLORS)]
            # Simplify rendering for large aggregates
            lw = 0.5 if aggregate_meta and aggregate_meta.n_instances > 20 else 1.5
            poly = Poly3DCollection(
                [verts],
                alpha=0.7,
                facecolor=colours["face"],
                edgecolor=colours["edge"],
                linewidth=lw,
            )
            ax.add_collection3d(poly)
    elif color_by_form:
        # Color each face by its form
        for i, face in enumerate(geometry.faces):
            verts = [geometry.vertices[j] for j in face]
            form_idx = geometry.face_forms[i] if i < len(geometry.face_forms) else 0
            colours = FORM_COLORS[form_idx % len(FORM_COLORS)]
            poly = Poly3DCollection(
                [verts],
                alpha=0.7,
                facecolor=colours["face"],
                edgecolor=colours["edge"],
                linewidth=1.5,
            )
            ax.add_collection3d(poly)
    else:
        # Use single color for all faces
        face_vertices = [[geometry.vertices[i] for i in face] for face in geometry.faces]
        poly = Poly3DCollection(
            face_vertices,
            alpha=0.7,
            facecolor=default_colours["face"],
            edgecolor=default_colours["edge"],
            linewidth=1.5,
        )
        ax.add_collection3d(poly)

    # Add vertices with depth-based visibility
    front_mask = calculate_vertex_visibility(geometry.vertices, geometry.faces, elev, azim)

    if np.any(front_mask):
        ax.scatter3D(
            geometry.vertices[front_mask, 0],
            geometry.vertices[front_mask, 1],
            geometry.vertices[front_mask, 2],
            color=default_colours["edge"],
            s=30,
            alpha=0.9,
            zorder=10,
        )

    back_mask = ~front_mask
    if np.any(back_mask):
        ax.scatter3D(
            geometry.vertices[back_mask, 0],
            geometry.vertices[back_mask, 1],
            geometry.vertices[back_mask, 2],
            color=default_colours["edge"],
            s=30,
            alpha=0.3,
            zorder=5,
        )

    # Add face labels if requested (skip for amorphous — no Miller indices)
    if (
        face_labels
        and not is_amorphous
        and hasattr(geometry, "face_millers")
        and geometry.face_millers
    ):
        _add_face_labels(ax, geometry, elev, azim)

    # Draw axes
    if show_axes:
        axis_origin, axis_length = calculate_axis_origin(geometry.vertices, elev, azim)
        draw_crystallographic_axes(ax, axis_origin, axis_length)

        # Calculate view bounds including axes
        center, half_extent = calculate_view_bounds(geometry.vertices, axis_origin, axis_length)
    else:
        center = np.array([0.0, 0.0, 0.0])
        half_extent = np.max(np.abs(geometry.vertices)) * 1.1

    # Set axis limits
    ax.set_xlim([center[0] - half_extent, center[0] + half_extent])
    ax.set_ylim([center[1] - half_extent, center[1] + half_extent])
    ax.set_zlim([center[2] - half_extent, center[2] + half_extent])

    # Hide grid if requested
    if not show_grid:
        hide_axes_and_grid(ax)

    # Create title from CDL
    if is_amorphous:
        title = "Amorphous (Schematic)"
    elif hasattr(description, "forms") and hasattr(description, "point_group"):
        forms_str = " + ".join(str(f.miller) for f in description.forms if hasattr(f, "miller"))
        title = f"{description.system.title()} [{description.point_group}] : {forms_str}"
    else:
        title = description.system.title()
    ax.set_title(title, fontsize=14, fontweight="bold")

    # Add legend for color-by-form mode
    if color_by_form and hasattr(description, "forms") and len(description.forms) > 1:
        _add_form_legend(ax, description.forms)

    # Clean up axes
    ax.set_xlabel("")
    ax.set_ylabel("")
    ax.set_zlabel("")
    ax.set_xticklabels([])
    ax.set_yticklabels([])
    ax.set_zticklabels([])

    # Render info panel
    if info_properties:
        render_info_panel(
            ax, info_properties, position=info_position, style=info_style, fontsize=info_fontsize
        )

    # Save
    plt.tight_layout()
    plt.savefig(output_path, format="svg", dpi=dpi, bbox_inches="tight")
    plt.close(fig)

    return output_path

generate_geometry_svg

Generate SVG from raw geometry data.

from crystal_renderer import generate_geometry_svg

generate_geometry_svg(
    vertices=geom.vertices,
    faces=geom.faces,
    output_path="geometry.svg",
    face_color='#81D4FA',
    edge_color='#0277BD'
)

crystal_renderer.generate_geometry_svg(vertices, faces, output_path, face_normals=None, show_axes=True, elev=30, azim=-45, show_grid=True, face_color='#81D4FA', edge_color='#0277BD', title=None, info_properties=None, figsize=(10, 10), dpi=150)

Generate SVG from raw geometry data.

Parameters:

Name Type Description Default
vertices ndarray

Nx3 array of vertex positions

required
faces list[list[int]]

List of faces (each face is list of vertex indices)

required
output_path str | Path

Output SVG file path

required
face_normals list[ndarray] | None

Optional list of face normal vectors

None
show_axes bool

Whether to show crystallographic axes

True
elev float

Elevation angle for view

30
azim float

Azimuth angle for view

-45
show_grid bool

If False, hide background grid and panes

True
face_color str

Face fill color

'#81D4FA'
edge_color str

Edge line color

'#0277BD'
title str | None

Optional title

None
info_properties dict[str, Any] | None

Properties for info panel

None
figsize tuple[int, int]

Figure size in inches

(10, 10)
dpi int

Output resolution

150

Returns:

Type Description
Path

Path to output file

Source code in src/crystal_renderer/visualization.py
def generate_geometry_svg(
    vertices: np.ndarray,
    faces: list[list[int]],
    output_path: str | Path,
    face_normals: list[np.ndarray] | None = None,
    show_axes: bool = True,
    elev: float = 30,
    azim: float = -45,
    show_grid: bool = True,
    face_color: str = "#81D4FA",
    edge_color: str = "#0277BD",
    title: str | None = None,
    info_properties: dict[str, Any] | None = None,
    figsize: tuple[int, int] = (10, 10),
    dpi: int = 150,
) -> Path:
    """Generate SVG from raw geometry data.

    Args:
        vertices: Nx3 array of vertex positions
        faces: List of faces (each face is list of vertex indices)
        output_path: Output SVG file path
        face_normals: Optional list of face normal vectors
        show_axes: Whether to show crystallographic axes
        elev: Elevation angle for view
        azim: Azimuth angle for view
        show_grid: If False, hide background grid and panes
        face_color: Face fill color
        edge_color: Edge line color
        title: Optional title
        info_properties: Properties for info panel
        figsize: Figure size in inches
        dpi: Output resolution

    Returns:
        Path to output file
    """
    output_path = Path(output_path)
    vertices = np.asarray(vertices)

    # Create figure
    fig = plt.figure(figsize=figsize)
    ax = fig.add_subplot(111, projection="3d")
    ax.set_proj_type("ortho")
    ax.view_init(elev=elev, azim=azim)

    # Draw faces
    face_vertices = [[vertices[i] for i in face] for face in faces]
    poly = Poly3DCollection(
        face_vertices, alpha=0.7, facecolor=face_color, edgecolor=edge_color, linewidth=1.5
    )
    ax.add_collection3d(poly)

    # Add vertices
    front_mask = calculate_vertex_visibility(vertices, faces, elev, azim)

    if np.any(front_mask):
        ax.scatter3D(
            vertices[front_mask, 0],
            vertices[front_mask, 1],
            vertices[front_mask, 2],
            color=edge_color,
            s=30,
            alpha=0.9,
            zorder=10,
        )

    back_mask = ~front_mask
    if np.any(back_mask):
        ax.scatter3D(
            vertices[back_mask, 0],
            vertices[back_mask, 1],
            vertices[back_mask, 2],
            color=edge_color,
            s=30,
            alpha=0.3,
            zorder=5,
        )

    # Draw axes
    if show_axes:
        axis_origin, axis_length = calculate_axis_origin(vertices, elev, azim)
        draw_crystallographic_axes(ax, axis_origin, axis_length)
        center, half_extent = calculate_view_bounds(vertices, axis_origin, axis_length)
    else:
        center = np.mean(vertices, axis=0)
        half_extent = np.max(np.abs(vertices - center)) * 1.2

    ax.set_xlim([center[0] - half_extent, center[0] + half_extent])
    ax.set_ylim([center[1] - half_extent, center[1] + half_extent])
    ax.set_zlim([center[2] - half_extent, center[2] + half_extent])

    if not show_grid:
        hide_axes_and_grid(ax)

    if title:
        ax.set_title(title, fontsize=14, fontweight="bold")

    ax.set_xlabel("")
    ax.set_ylabel("")
    ax.set_zlabel("")
    ax.set_xticklabels([])
    ax.set_yticklabels([])
    ax.set_zticklabels([])

    if info_properties:
        render_info_panel(ax, info_properties)

    plt.tight_layout()
    plt.savefig(output_path, format="svg", dpi=dpi, bbox_inches="tight")
    plt.close(fig)

    return output_path

3D Export

export_stl

Export geometry to STL format for 3D printing.

from crystal_renderer import export_stl

export_stl(vertices, faces, "model.stl", binary=True)

crystal_renderer.export_stl(vertices, faces, output_path, binary=True)

Export crystal geometry to an STL file.

Parameters:

Name Type Description Default
vertices ndarray

Nx3 array of vertex positions

required
faces list[list[int]]

List of faces (each face is a list of vertex indices)

required
output_path str | Path

Output file path

required
binary bool

If True, output binary STL; if False, output ASCII STL

True

Returns:

Type Description
Path

Path to output file

Source code in src/crystal_renderer/formats/stl.py
def export_stl(
    vertices: np.ndarray, faces: list[list[int]], output_path: str | Path, binary: bool = True
) -> Path:
    """Export crystal geometry to an STL file.

    Args:
        vertices: Nx3 array of vertex positions
        faces: List of faces (each face is a list of vertex indices)
        output_path: Output file path
        binary: If True, output binary STL; if False, output ASCII STL

    Returns:
        Path to output file
    """
    output_path = Path(output_path)
    stl_data = geometry_to_stl(vertices, faces, binary)

    with open(output_path, "wb") as f:
        f.write(stl_data)

    return output_path

export_gltf

Export geometry to glTF format for web/AR.

from crystal_renderer import export_gltf

export_gltf(
    vertices, faces, "model.gltf",
    color=(0.5, 0.7, 0.9, 0.8),  # RGBA
    name="crystal"
)

crystal_renderer.export_gltf(vertices, faces, output_path, color=None, name='crystal')

Export crystal geometry to a glTF file.

Parameters:

Name Type Description Default
vertices ndarray

Nx3 array of vertex positions

required
faces list[list[int]]

List of faces (each face is a list of vertex indices)

required
output_path str | Path

Output file path

required
color tuple[float, float, float, float] | None

Optional RGBA color tuple (0-1 range)

None
name str

Mesh name

'crystal'

Returns:

Type Description
Path

Path to output file

Source code in src/crystal_renderer/formats/gltf.py
def export_gltf(
    vertices: np.ndarray,
    faces: list[list[int]],
    output_path: str | Path,
    color: tuple[float, float, float, float] | None = None,
    name: str = "crystal",
) -> Path:
    """Export crystal geometry to a glTF file.

    Args:
        vertices: Nx3 array of vertex positions
        faces: List of faces (each face is a list of vertex indices)
        output_path: Output file path
        color: Optional RGBA color tuple (0-1 range)
        name: Mesh name

    Returns:
        Path to output file
    """
    output_path = Path(output_path)
    gltf = geometry_to_gltf(vertices, faces, color, name)

    with open(output_path, "w") as f:
        json.dump(gltf, f, indent=2)

    return output_path

Format Conversion

convert_svg_to_raster

Convert existing SVG to raster format.

from crystal_renderer import convert_svg_to_raster

convert_svg_to_raster("input.svg", "output.png", scale=2.0)

crystal_renderer.convert_svg_to_raster(svg_path, output_path, output_format='png', scale=2.0, quality=95)

Convert SVG to raster format (PNG, JPG, BMP).

Parameters:

Name Type Description Default
svg_path str | Path

Path to input SVG file

required
output_path str | Path

Path for output raster file

required
output_format str

'png', 'jpg', 'jpeg', or 'bmp'

'png'
scale float

Scale factor for higher resolution (default 2x)

2.0
quality int

JPEG quality (1-100, default 95)

95

Returns:

Type Description
Path

Path to output file

Raises:

Type Description
ImportError

If required libraries not available

ValueError

If format not supported

Source code in src/crystal_renderer/conversion.py
def convert_svg_to_raster(
    svg_path: str | Path,
    output_path: str | Path,
    output_format: str = "png",
    scale: float = 2.0,
    quality: int = 95,
) -> Path:
    """Convert SVG to raster format (PNG, JPG, BMP).

    Args:
        svg_path: Path to input SVG file
        output_path: Path for output raster file
        output_format: 'png', 'jpg', 'jpeg', or 'bmp'
        scale: Scale factor for higher resolution (default 2x)
        quality: JPEG quality (1-100, default 95)

    Returns:
        Path to output file

    Raises:
        ImportError: If required libraries not available
        ValueError: If format not supported
    """
    output_format = output_format.lower()
    if output_format == "jpeg":
        output_format = "jpg"

    if output_format not in ("png", "jpg", "bmp"):
        raise ValueError(f"Unsupported format: {output_format}. Use png, jpg, or bmp.")

    if not CAIROSVG_AVAILABLE:
        raise ImportError("cairosvg not available. Install with: pip install cairosvg")

    svg_path = Path(svg_path)
    output_path = Path(output_path)

    # Convert SVG to PNG using cairosvg
    png_data = cairosvg.svg2png(url=str(svg_path), scale=scale)

    if output_format == "png":
        # Direct PNG output
        with open(output_path, "wb") as f:
            f.write(png_data)
    else:
        # Convert PNG to JPG or BMP using PIL
        if not PIL_AVAILABLE:
            raise ImportError("PIL not available. Install with: pip install Pillow")

        img = Image.open(io.BytesIO(png_data))

        # Convert RGBA to RGB for formats that don't support alpha
        if img.mode == "RGBA":
            background = Image.new("RGB", img.size, (255, 255, 255))
            background.paste(img, mask=img.split()[3])
            img = background

        if output_format == "jpg":
            img.save(output_path, "JPEG", quality=quality)
        elif output_format == "bmp":
            img.save(output_path, "BMP")

    return output_path

generate_with_format

Generate visualization directly to any supported format.

from crystal_renderer import generate_with_format, generate_cdl_svg

generate_with_format(
    generator_func=generate_cdl_svg,
    output_path="crystal.png",
    output_format="png",
    cdl_string="cubic[m3m]:{111}"
)

crystal_renderer.generate_with_format(generator_func, output_path, output_format='svg', scale=2.0, quality=95, **kwargs)

Generate crystal visualization in specified format.

Parameters:

Name Type Description Default
generator_func Callable

Function that generates SVG

required
output_path str | Path

Output file path

required
output_format str

'svg', 'png', 'jpg', or 'bmp'

'svg'
scale float

Scale factor for raster output

2.0
quality int

JPEG quality

95
**kwargs

Additional arguments for generator_func

{}

Returns:

Type Description
Path

Path to output file

Raises:

Type Description
ValueError

If format not supported

Source code in src/crystal_renderer/conversion.py
def generate_with_format(
    generator_func: Callable,
    output_path: str | Path,
    output_format: str = "svg",
    scale: float = 2.0,
    quality: int = 95,
    **kwargs,
) -> Path:
    """Generate crystal visualization in specified format.

    Args:
        generator_func: Function that generates SVG
        output_path: Output file path
        output_format: 'svg', 'png', 'jpg', or 'bmp'
        scale: Scale factor for raster output
        quality: JPEG quality
        **kwargs: Additional arguments for generator_func

    Returns:
        Path to output file

    Raises:
        ValueError: If format not supported
    """
    output_format = output_format.lower()
    if output_format == "jpeg":
        output_format = "jpg"

    output_path = Path(output_path)

    # For SVG, just call the generator directly
    if output_format == "svg":
        result = generator_func(output_path=str(output_path), **kwargs)
        return Path(result) if isinstance(result, str) else output_path

    # For raster formats, generate SVG to temp file then convert
    if output_format in ("png", "jpg", "bmp"):
        with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as tmp:
            tmp_svg = tmp.name

        try:
            generator_func(output_path=tmp_svg, **kwargs)

            # Adjust output path extension
            if output_path.suffix.lower() == ".svg":
                output_path = output_path.with_suffix("." + output_format)

            convert_svg_to_raster(tmp_svg, output_path, output_format, scale, quality)
            return output_path
        finally:
            if os.path.exists(tmp_svg):
                os.remove(tmp_svg)

    raise ValueError(f"Unsupported format: {output_format}")

Info Panels

render_info_panel

Render an info panel on a matplotlib axes.

from crystal_renderer import render_info_panel

properties = {
    'name': 'Ruby',
    'chemistry': 'Al2O3',
    'hardness': '9',
    'ri': '1.762-1.770'
}
render_info_panel(ax, properties, position='top-right', style='compact')

crystal_renderer.render_info_panel(ax, properties, position='top-right', style='compact', fontsize=10, get_label=None, format_value=None)

Render gemstone information panel on the visualization.

Parameters:

Name Type Description Default
ax Any

Matplotlib axes object

required
properties dict[str, Any]

Dictionary of property key -> value

required
position str

Panel position ('top-left', 'top-right', 'bottom-left', 'bottom-right')

'top-right'
style str

Panel style ('compact', 'detailed', 'minimal')

'compact'
fontsize int

Base font size in points

10
get_label Callable[[str], str] | None

Optional custom function to get property labels

None
format_value Callable[[str, Any], str] | None

Optional custom function to format property values

None
Source code in src/crystal_renderer/info_panel.py
def render_info_panel(
    ax: Any,
    properties: dict[str, Any],
    position: str = "top-right",
    style: str = "compact",
    fontsize: int = 10,
    get_label: Callable[[str], str] | None = None,
    format_value: Callable[[str, Any], str] | None = None,
) -> None:
    """Render gemstone information panel on the visualization.

    Args:
        ax: Matplotlib axes object
        properties: Dictionary of property key -> value
        position: Panel position ('top-left', 'top-right', 'bottom-left', 'bottom-right')
        style: Panel style ('compact', 'detailed', 'minimal')
        fontsize: Base font size in points
        get_label: Optional custom function to get property labels
        format_value: Optional custom function to format property values
    """
    if not properties:
        return

    # Use custom or default formatters
    if get_label is None:
        get_label = get_property_label
    if format_value is None:
        format_value = format_property_value

    # Determine position coordinates (in axes fraction 0-1)
    positions = {
        "top-left": (0.02, 0.98, "left", "top"),
        "top-right": (0.98, 0.98, "right", "top"),
        "bottom-left": (0.02, 0.02, "left", "bottom"),
        "bottom-right": (0.98, 0.02, "right", "bottom"),
    }
    x, y, ha, va = positions.get(position, positions["top-right"])

    # Build text lines based on style
    lines = []

    if style == "minimal":
        # Just values, no labels
        for key, value in properties.items():
            lines.append(format_value(key, value))

    elif style == "detailed":
        # Full labels with grouping
        name = properties.get("name", "")
        if name:
            lines.append(name.upper())
            lines.append("-" * max(len(name), 15))

        for key, value in properties.items():
            if key == "name":
                continue
            label = get_label(key)
            formatted = format_value(key, value)
            lines.append(f"{label}: {formatted}")

    else:  # compact (default)
        # Name on first line, then key: value pairs
        name = properties.get("name", "")
        if name:
            lines.append(name)

        for key, value in properties.items():
            if key == "name":
                continue
            label = get_label(key)
            formatted = format_value(key, value)
            lines.append(f"{label}: {formatted}")

    if not lines:
        return

    # Join lines and render
    text = "\n".join(lines)

    # Create text box with semi-transparent background
    bbox_props = {
        "boxstyle": "round,pad=0.4",
        "facecolor": "white",
        "edgecolor": "#cccccc",
        "alpha": 0.9,
        "linewidth": 1,
    }

    ax.text2D(
        x,
        y,
        text,
        transform=ax.transAxes,
        fontsize=fontsize,
        fontfamily="monospace",
        ha=ha,
        va=va,
        bbox=bbox_props,
        linespacing=1.3,
    )

create_fga_info_panel

Create FGA-style property panel from mineral data.

from crystal_renderer import create_fga_info_panel

fga_props = create_fga_info_panel(mineral_data)

crystal_renderer.create_fga_info_panel(mineral_data, include_keys=None)

Create a standardized FGA-style info panel from mineral data.

Parameters:

Name Type Description Default
mineral_data dict[str, Any]

Dictionary of mineral properties

required
include_keys list | None

Optional list of keys to include (default: standard FGA set)

None

Returns:

Type Description
dict[str, Any]

Dictionary ready for render_info_panel

Source code in src/crystal_renderer/info_panel.py
def create_fga_info_panel(
    mineral_data: dict[str, Any], include_keys: list | None = None
) -> dict[str, Any]:
    """Create a standardized FGA-style info panel from mineral data.

    Args:
        mineral_data: Dictionary of mineral properties
        include_keys: Optional list of keys to include (default: standard FGA set)

    Returns:
        Dictionary ready for render_info_panel
    """
    if include_keys is None:
        include_keys = [
            "name",
            "chemistry",
            "hardness",
            "sg",
            "ri",
            "dr",
            "crystal_system",
            "optic_sign",
            "pleochroism",
        ]

    result = {}
    for key in include_keys:
        if key in mineral_data and mineral_data[key] is not None:
            result[key] = mineral_data[key]

    return result

Color Constants

AXIS_COLOURS

Colors for crystallographic axes (a, b, c).

from crystal_renderer import AXIS_COLOURS

print(AXIS_COLOURS['a'])  # Red
print(AXIS_COLOURS['b'])  # Green
print(AXIS_COLOURS['c'])  # Blue

ELEMENT_COLOURS

Colors for chemical elements.

from crystal_renderer import ELEMENT_COLOURS, get_element_colour

color = get_element_colour('Si')  # '#F0C8A0'
color = get_element_colour('O')   # '#FF0000'

crystal_renderer.get_element_colour(symbol)

Get colour for an element.

Parameters:

Name Type Description Default
symbol str

Chemical element symbol

required

Returns:

Type Description
str

Hex colour string

Source code in src/crystal_renderer/rendering.py
def get_element_colour(symbol: str) -> str:
    """Get colour for an element.

    Args:
        symbol: Chemical element symbol

    Returns:
        Hex colour string
    """
    if symbol in ELEMENT_COLOURS:
        return ELEMENT_COLOURS[symbol]

    # Try ASE's jmol colours if available
    try:
        from ase.data import atomic_numbers
        from ase.data.colors import jmol_colors

        z = atomic_numbers[symbol]
        rgb = jmol_colors[z]
        return f"#{int(rgb[0] * 255):02x}{int(rgb[1] * 255):02x}{int(rgb[2] * 255):02x}"
    except (ImportError, KeyError, IndexError):
        return "#808080"  # Default grey

HABIT_COLOURS

Colors for crystal systems/habits.

from crystal_renderer import HABIT_COLOURS

print(HABIT_COLOURS['cubic'])     # Color for cubic system
print(HABIT_COLOURS['trigonal'])  # Color for trigonal system

FORM_COLORS

Colors for multi-form rendering.

from crystal_renderer import FORM_COLORS

# Colors for distinguishing different crystal forms
# e.g., {111} in one color, {100} in another

Projection Utilities

calculate_view_direction

Calculate the view direction vector from elevation and azimuth.

from crystal_renderer import calculate_view_direction

view = calculate_view_direction(elev=30, azim=-45)

crystal_renderer.calculate_view_direction(elev, azim)

Calculate view direction vector from elevation and azimuth angles.

Parameters:

Name Type Description Default
elev float

Elevation angle in degrees

required
azim float

Azimuth angle in degrees

required

Returns:

Type Description
ndarray

Unit vector pointing towards viewer

Source code in src/crystal_renderer/projection.py
def calculate_view_direction(elev: float, azim: float) -> np.ndarray:
    """Calculate view direction vector from elevation and azimuth angles.

    Args:
        elev: Elevation angle in degrees
        azim: Azimuth angle in degrees

    Returns:
        Unit vector pointing towards viewer
    """
    elev_rad = np.radians(elev)
    azim_rad = np.radians(azim)
    return np.array(
        [np.cos(elev_rad) * np.cos(azim_rad), np.cos(elev_rad) * np.sin(azim_rad), np.sin(elev_rad)]
    )

calculate_axis_origin

Calculate the origin point for axis rendering.

from crystal_renderer import calculate_axis_origin

origin = calculate_axis_origin(vertices, padding=0.1)

crystal_renderer.calculate_axis_origin(vertices, elev=30, azim=-45, offset_factor=0.02)

Calculate axis placement for maximum crystal fill efficiency.

Places axes at crystal corner with minimal clearance and adaptive length. The axes stay attached to the crystal's geometry in world coordinates.

Parameters:

Name Type Description Default
vertices ndarray

Array of crystal vertex positions (N x 3)

required
elev float

Elevation angle (unused, kept for API compatibility)

30
azim float

Azimuth angle (unused, kept for API compatibility)

-45
offset_factor float

Clearance from bounding box corner (0.02 = 2% typical)

0.02

Returns:

Type Description
tuple[ndarray, float]

Tuple of (axis_origin, axis_length)

Source code in src/crystal_renderer/projection.py
def calculate_axis_origin(
    vertices: np.ndarray, elev: float = 30, azim: float = -45, offset_factor: float = 0.02
) -> tuple[np.ndarray, float]:
    """Calculate axis placement for maximum crystal fill efficiency.

    Places axes at crystal corner with minimal clearance and adaptive length.
    The axes stay attached to the crystal's geometry in world coordinates.

    Args:
        vertices: Array of crystal vertex positions (N x 3)
        elev: Elevation angle (unused, kept for API compatibility)
        azim: Azimuth angle (unused, kept for API compatibility)
        offset_factor: Clearance from bounding box corner (0.02 = 2% typical)

    Returns:
        Tuple of (axis_origin, axis_length)
    """
    # Calculate bounding box
    min_bounds = np.min(vertices, axis=0)
    max_bounds = np.max(vertices, axis=0)
    extent = max_bounds - min_bounds
    max_extent = np.max(extent)

    # Place origin at front-bottom-left corner with tiny clearance (2%)
    axis_origin = np.array(
        [
            min_bounds[0] - max_extent * offset_factor,
            min_bounds[1] - max_extent * offset_factor,
            min_bounds[2] - max_extent * offset_factor * 0.5,
        ]
    )

    # Adaptive axis length: 25% base, capped per crystal dimension
    base_length = max_extent * 0.25
    min_length = max_extent * 0.18  # Minimum for visibility

    # Cap to 45% of smallest crystal dimension (prevents oversized axes)
    axis_length = max(min(base_length, np.min(extent) * 0.45), min_length)

    return axis_origin, axis_length

calculate_vertex_visibility

Calculate which vertices are front-facing.

from crystal_renderer import calculate_vertex_visibility

visibility = calculate_vertex_visibility(vertices, faces, elev=30, azim=-45)

crystal_renderer.calculate_vertex_visibility(vertices, faces, elev, azim, threshold=0.1)

Determine which vertices are on front-facing vs back-facing surfaces.

Parameters:

Name Type Description Default
vertices ndarray

Array of vertex positions (N x 3)

required
faces list[list[int]]

List of face vertex index arrays (each face is a list of indices)

required
elev float

Elevation angle in degrees

required
azim float

Azimuth angle in degrees

required
threshold float

Dot product threshold for front-facing (default 0.1)

0.1

Returns:

Type Description
ndarray

Boolean array, True for front-facing vertices

Source code in src/crystal_renderer/projection.py
def calculate_vertex_visibility(
    vertices: np.ndarray, faces: list[list[int]], elev: float, azim: float, threshold: float = 0.1
) -> np.ndarray:
    """Determine which vertices are on front-facing vs back-facing surfaces.

    Args:
        vertices: Array of vertex positions (N x 3)
        faces: List of face vertex index arrays (each face is a list of indices)
        elev: Elevation angle in degrees
        azim: Azimuth angle in degrees
        threshold: Dot product threshold for front-facing (default 0.1)

    Returns:
        Boolean array, True for front-facing vertices
    """
    view_dir = calculate_view_direction(elev, azim)

    # Track which vertices are on at least one front-facing face
    vertex_front_facing = np.zeros(len(vertices), dtype=bool)

    for face in faces:
        face_indices = np.array(face)
        face_verts = vertices[face_indices]
        if len(face_verts) < 3:
            continue

        # Calculate face normal
        v1 = face_verts[1] - face_verts[0]
        v2 = face_verts[2] - face_verts[0]
        normal = np.cross(v1, v2)
        norm_len = np.linalg.norm(normal)
        if norm_len < 1e-10:
            continue
        normal = normal / norm_len

        # Check if face is front-facing
        if np.dot(normal, view_dir) > threshold:
            vertex_front_facing[face_indices] = True

    return vertex_front_facing

is_face_visible

Check if a face is visible from the current view direction.

from crystal_renderer import is_face_visible

visible = is_face_visible(face_normal, view_direction)

crystal_renderer.is_face_visible(vertices, face, elev, azim, threshold=0.1)

Check if a face is visible from the given view angle.

Parameters:

Name Type Description Default
vertices ndarray

Array of all vertex positions

required
face list[int]

List of vertex indices forming the face

required
elev float

Elevation angle in degrees

required
azim float

Azimuth angle in degrees

required
threshold float

Dot product threshold for visibility

0.1

Returns:

Type Description
bool

True if face is front-facing

Source code in src/crystal_renderer/projection.py
def is_face_visible(
    vertices: np.ndarray, face: list[int], elev: float, azim: float, threshold: float = 0.1
) -> bool:
    """Check if a face is visible from the given view angle.

    Args:
        vertices: Array of all vertex positions
        face: List of vertex indices forming the face
        elev: Elevation angle in degrees
        azim: Azimuth angle in degrees
        threshold: Dot product threshold for visibility

    Returns:
        True if face is front-facing
    """
    view_dir = calculate_view_direction(elev, azim)
    normal = calculate_face_normal(vertices, face)
    return np.dot(normal, view_dir) > threshold