Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/arch.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Arch
on: [pull_request]
jobs:
test_on_arch_linux:
arch:
runs-on: ubuntu-latest
container:
image: archlinux:latest
Expand Down
27 changes: 21 additions & 6 deletions .github/workflows/ubuntu.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
name: Ubuntu

on: [pull_request]

jobs:
test_and_coverage:
runs-on: ubuntu-latest
ubuntu:
runs-on: ubuntu-24.04

steps:
- uses: actions/checkout@v4
- name: Install Libraries

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: |
sudo apt-get update -y && sudo apt-get install -y python3-matplotlib
python3 -m venv "$RUNNER_TEMP/plotpy-venv"
"$RUNNER_TEMP/plotpy-venv/bin/python3" -m pip install --upgrade pip
"$RUNNER_TEMP/plotpy-venv/bin/python3" -m pip install "matplotlib>=3.10,<3.13"
echo "$RUNNER_TEMP/plotpy-venv/bin" >> "$GITHUB_PATH"

- name: Verify matplotlib
run: python3 -c "import matplotlib, sys; print(sys.executable, matplotlib.__version__)"

- name: Run tests
run: |
cargo test -- --nocapture
run: cargo test -- --nocapture
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ rust_modules
Cargo.lock
call_python3_works.py
.aider*
*.pyc
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@
"Mathematica",
"Matplotlib",
"meshgrid",
"mises",
"mohr",
"MOVETO",
"mplot",
"nocapture",
Expand All @@ -94,6 +96,7 @@
"pyplot",
"rarrow",
"rgba",
"rodrigues",
"roundtooth",
"rstride",
"savefig",
Expand Down
14 changes: 14 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,17 @@ keywords = ["plot", "2D", "3D", "python", "matplotlib"]

[dependencies]
num-traits = "0.2"

[lints.clippy]
approx_constant = "allow" # allow large constants; e.g. for OEIS reference
bool_assert_comparison = "allow" # allow "assert_eq!(x, true)" for better diagnostics
collapsible_if = "allow" # allow nested "if" for readability
excessive_precision = "allow" # allow large constants; e.g. for OEIS reference
identity_op = "allow" # allow "i + 0" for uniform unrolled loops
let_unit_value = "allow" # allow "let _ = CONST;" to force const evaluation
manual_is_multiple_of = "allow" # allow "n % k == 0" instead of is_multiple_of()
manual_range_contains = "allow" # allow if "a < b && c < d" constructions
needless_range_loop = "allow" # allow "for i in a..b" instead of range
new_without_default = "allow" # The user is encouraged to call new()
too_many_arguments = "allow" # allow functions with many parameters
type_complexity = "allow" # allow deeply nested array types (index maps)
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
[![Arch](https://github.com/cpmech/plotpy/actions/workflows/arch.yml/badge.svg)](https://github.com/cpmech/plotpy/actions/workflows/arch.yml)
[![Ubuntu](https://github.com/cpmech/plotpy/actions/workflows/ubuntu.yml/badge.svg)](https://github.com/cpmech/plotpy/actions/workflows/ubuntu.yml)

`*` Requires Ubuntu 24.04 or newer.

## Contents <!-- omit from toc -->

Expand All @@ -27,6 +28,8 @@
- [InsetAxes](#insetaxes)
- [Surface](#surface)
- [Text](#text)
- [Comparing figures](#comparing-figures)
- [Usage](#usage)
- [Architecture](#architecture)
- [Chaining pattern (builder style)](#chaining-pattern-builder-style)
- [Consistent conventions across all files](#consistent-conventions-across-all-files)
Expand Down Expand Up @@ -526,6 +529,60 @@ fn main() -> Result<(), StrError> {

---

## Comparing figures

The tests and doc-tests generate figures into `/tmp/plotpy` (and its
`doc_tests/`, `integ_tests/`, and `unit_tests/` sub-directories), while the
committed reference figures live in `figures/`. The script
`zscripts/compare-figures.py` compares the generated figures against the
references and reports which ones are **identical**, **different**, or **missing**.

It offers two comparison modes:

1. **Text mode** (default) — normalises both SVGs before comparing: it removes the
`<!DOCTYPE>`, XML comments, and the `<metadata>` block (which holds a timestamp
and the Matplotlib version), masks Matplotlib's random clip-path/marker/image
identifiers, and rounds floating-point numbers (`--decimals`). This catches
real content differences while ignoring Matplotlib noise.
2. **Raster mode** (`--raster`) — rasterises both SVGs to PNG at the same pixel
width (using `rsvg-convert`, `inkscape`, or ImageMagick, whichever is
installed) and compares pixels. It reports the mean absolute error, the
percentage of differing pixels, and the maximum delta; this is tolerant to
Matplotlib-version and anti-aliasing differences.

By default the tool reads `/tmp/plotpy` recursively and the `figures/` directory.
Use `--pattern`, `--generated`, `--reference`, and `--json` as needed. In raster
mode, `--diff-image DIR` additionally writes a red diff heat-map and a
side-by-side PNG for every differing figure. The exit status is `0` only when
every compared figure is identical (or within tolerance) and none is missing,
which makes the tool suitable for CI.

### Usage

```bash
# generate the figures first
cargo test

# text compare (defaults: /tmp/plotpy -> ./figures, recursive)
./zscripts/compare-figures.py

# show a unified diff for the figures that differ
./zscripts/compare-figures.py --diff

# pixel compare (tolerant to Matplotlib version differences), writing diff images
./zscripts/compare-figures.py --raster --diff-image /tmp/plotpy/diffs

# refresh the committed reference figures from the current output
./zscripts/compare-figures.py --update
# or:
bash zscripts/copy-figures.bash
```

Run `./zscripts/compare-figures.py --help` for the full list of options.
Raster mode requires `numpy` and `Pillow` in addition to a SVG rasteriser.

---

## Architecture

(Generated by [DeepSeek](https://www.deepseek.com/))
Expand Down
20 changes: 20 additions & 0 deletions examples/plot_sphere.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
use plotpy::{Plot, StrError, Surface};

fn main() -> Result<(), StrError> {
let mut surf = Surface::new();
surf.set_surf_color("#ff880090") // Orange with some transparency
.set_with_wireframe(true)
.set_wire_line_color("black")
.draw_sphere(&[0.0, 0.0, 0.0], 1.0, 30, 30)?;

let mut plot = Plot::new();
plot.add(&surf)
.set_equal_axes(true) // Forces 1:1:1 aspect ratio so it's a perfect sphere
.set_zoom_3d(1.5) // Zooms in optically
.set_figure_size_points(800.0, 800.0)
.save("/tmp/plotpy/sphere_zoomed.png")?;

println!("Saved /tmp/plotpy/sphere_zoomed.png");

Ok(())
}
126 changes: 126 additions & 0 deletions examples/plot_yield_surfaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
from mpl_toolkits.mplot3d.art3d import Poly3DCollection

def plot_von_mises(elev, azim):
fig = plt.figure(figsize=(8, 8))
ax = fig.add_subplot(111, projection='3d')

# Cylinder parameters
radius = 3
height = 15

# Generate cylinder grid
z_cyl = np.linspace(0, height, 50)
theta = np.linspace(0, 2*np.pi, 50)
theta_grid, z_grid = np.meshgrid(theta, z_cyl)

x_cyl = radius * np.cos(theta_grid)
y_cyl = radius * np.sin(theta_grid)

# Rotation matrix to align cylinder with space diagonal (sigma_1 = sigma_2 = sigma_3)
# The space diagonal direction is [1, 1, 1]
# We rotate from [0, 0, 1] (Z-axis) to [1/sqrt(3), 1/sqrt(3), 1/sqrt(3)]

v = np.array([1, 1, 1]) / np.sqrt(3)
z_axis = np.array([0, 0, 1])

# Cross product and angle for rotation
axis = np.cross(z_axis, v)
axis = axis / np.linalg.norm(axis)
angle = np.arccos(np.dot(z_axis, v))

# Rodrigues' rotation formula components
K = np.array([[0, -axis[2], axis[1]],
[axis[2], 0, -axis[0]],
[-axis[1], axis[0], 0]])
I = np.eye(3)
R = I + np.sin(angle)*K + (1 - np.cos(angle))*np.dot(K, K)

# Apply rotation
points = np.stack([x_cyl.flatten(), y_cyl.flatten(), z_grid.flatten()])
rotated_points = R.dot(points)

X = rotated_points[0, :].reshape(x_cyl.shape)
Y = rotated_points[1, :].reshape(y_cyl.shape)
Z = rotated_points[2, :].reshape(z_grid.shape) # <--- THIS LINE IS FIXED

ax.plot_surface(X, Y, Z, alpha=0.6, color='blue', edgecolor='none')

# Plot hydrostatic axis
ax.plot([0, 15], [0, 15], [0, 15], color='red', linestyle='dashed', linewidth=2, label='Hydrostatic Axis')

ax.set_xlabel(r'$\sigma_1$')
ax.set_ylabel(r'$\sigma_2$')
ax.set_zlabel(r'$\sigma_3$')
ax.set_title('von Mises Yield Surface (Cylinder)')
ax.set_xlim([0, 15])
ax.set_ylim([0, 15])
ax.set_zlim([0, 15])
ax.view_init(elev, azim)

plt.savefig('von_mises_surface.png',bbox_inches='tight', dpi=300)
print("Saved von_mises_surface.png")

def plot_mohr_coulomb(elev, azim):
# Simplified visual representation of the Mohr-Coulomb hexagonal pyramid
fig = plt.figure(figsize=(8, 8))
ax = fig.add_subplot(111, projection='3d')

# Apex of the pyramid (tensile region, assumed at -2 for visual purposes)
apex = np.array([-2, -2, -2])

# Vertices of the irregular hexagon in the octahedral plane at a given p'
# For a frictional material, compressive strength > tensile strength
r_c = 6.0 # Compression radius
r_e = 3.5 # Extension radius

# Angles for the vertices (Lode angles corresponding to triaxial compression/extension)
angles = np.array([0, 60, 120, 180, 240, 300]) * np.pi / 180.0
radii = np.array([r_c, r_e, r_c, r_e, r_c, r_e])

base_center = np.array([12, 12, 12])

# Base vectors for the octahedral plane
n = np.array([1, 1, 1]) / np.sqrt(3)
u = np.array([1, -1, 0]) / np.sqrt(2)
v_vec = np.cross(n, u)

# Calculate base vertices
base_vertices = []
for ang, r in zip(angles, radii):
point = base_center + r * (np.cos(ang) * u + np.sin(ang) * v_vec)
base_vertices.append(point)

base_vertices = np.array(base_vertices)

# Plot faces
faces = []
for i in range(6):
faces.append([apex, base_vertices[i], base_vertices[(i+1)%6]])

poly3d = Poly3DCollection(faces, alpha=0.5, facecolors='green', edgecolors='black', linewidths=1)
ax.add_collection3d(poly3d)

# Plot hydrostatic axis
ax.plot([-3, 15], [-3, 15], [-3, 15], color='red', linestyle='dashed', linewidth=2, label='Hydrostatic Axis')

ax.set_xlabel(r'$\sigma_1$')
ax.set_ylabel(r'$\sigma_2$')
ax.set_zlabel(r'$\sigma_3$')
ax.set_title('Mohr-Coulomb Yield Surface (Hexagonal Pyramid)')

ax.set_xlim([-3, 15])
ax.set_ylim([-3, 15])
ax.set_zlim([-3, 15])
ax.view_init(elev, azim)

plt.savefig('mohr_coulomb_surface.png',bbox_inches='tight', dpi=300)
print("Saved mohr_coulomb_surface.png")

if __name__ == '__main__':
elev = 20
azim = 20
plot_von_mises(elev, azim)
plot_mohr_coulomb(elev, azim)
Loading
Loading