00 — Basic Tree¶
Programmatic Mesh → Grow → Activate → Export¶
Minimal, self-contained demo that:
Generates a small surface mesh programmatically
Grows a fractal tree on it
Wraps it into a
PurkinjeTree
and runs FIM activationExports VTK files for visualization.
Why start here? No external data is required. This notebook is the fastest path to confirm the pipeline works end-to-end on your machine.
Outputs
output/examples/00_basic_tree/basic_tree_AT.vtu
— unstructured grid with activation time field.output/examples/00_basic_tree/basic_tree_meshio.vtu
— line mesh viameshio
(compatible with ParaView).output/examples/00_basic_tree/basic_tree_pmj.vtp
— PMJs as a point set.
You can keep runtimes small with EXAMPLES_LITE=1
(default in this notebook).
[ ]:
%pip install -q --upgrade pip
%pip install -q purkinje-uv
%pip install "pyvista[jupyter]"
Settings, Reproducibility, and Output Paths¶
We use:
SEED
for determinism where applicable.LITE
toggle to keep the example small and fast by default.A dedicated
OUT_DIR
underoutput/examples/00_basic_tree/
to avoid clutter.
You may override these with environment variables:
EXAMPLES_SEED=2025
EXAMPLES_LITE=0
EXAMPLES_SHOW=1
(optional 3D viewer at the end)
[ ]:
from pathlib import Path
import os
import numpy as np
import pyvista as pv
from IPython.display import HTML, display
from purkinje_uv import FractalTreeParameters, FractalTree, PurkinjeTree
[ ]:
SEED = int(os.getenv("EXAMPLES_SEED", "1234"))
LITE = bool(int(os.getenv("EXAMPLES_LITE", "1")))
SHOW = bool(int(os.getenv("EXAMPLES_SHOW", "1"))) # optional PyVista preview
OUT_DIR = Path("output") / "examples" / "00_basic_tree"
OUT_DIR.mkdir(parents=True, exist_ok=True)
DATA_DIR = Path("data")
DATA_DIR.mkdir(parents=True, exist_ok=True)
np.random.seed(SEED)
print(f"SEED={SEED} LITE={LITE} SHOW={SHOW}")
print("OUT_DIR:", OUT_DIR)
Create a Programmatic Mesh (Sphere)¶
We generate a simple triangulated sphere with PyVista and save it as an OBJ under data/
. This avoids any external files and ensures the example is portable.
[ ]:
# Generate an OPEN ellipsoid-like surface and save as OBJ
mesh_path = Path("data/basic_open_ellipsoid.obj")
mesh_path.parent.mkdir(parents=True, exist_ok=True)
# 1) Start with a triangulated sphere
sphere = pv.Sphere(radius=1.0, theta_resolution=160, phi_resolution=160).triangulate()
# 2) Scale non-uniformly → ellipsoid-like surface
ellip = sphere.copy(deep=True)
ellip.scale([1.30, 1.00, 0.75])
# 3) Clip with a plane to create an OPEN boundary (no capping)
open_surf = ellip.clip(normal=(0, 0, 1), origin=(0, 0, 0.35), invert=False)
open_surf = open_surf.clean().triangulate()
# 4) Verify it's actually open: boundary edges must be > 0
edges = open_surf.extract_feature_edges(
boundary_edges=True, feature_edges=False, manifold_edges=False, non_manifold_edges=True
)
n_boundary = edges.n_cells
print("Boundary edges (first try):", n_boundary)
# If somehow still closed, flip the keep-side and try again
if n_boundary == 0:
open_surf = ellip.clip(normal=(0, 0, 1), origin=(0, 0, 0.35), invert=True).clean().triangulate()
edges = open_surf.extract_feature_edges(
boundary_edges=True, feature_edges=False, manifold_edges=False, non_manifold_edges=True
)
n_boundary = edges.n_cells
print("Boundary edges (after invert):", n_boundary)
assert n_boundary > 0, "Surface still closed; adjust clip origin (e.g., 0.3–0.6) or invert."
# 5) Save OBJ (PyVista will use meshio under the hood if needed)
open_surf.save(mesh_path)
print(f"Saved OPEN surface: {mesh_path} | points={open_surf.n_points} faces={open_surf.n_cells}")
Fractal Tree Parameters (Small & CI-friendly)¶
We start with conservative values that run quickly:
l_segment=0.01
init_length=0.25
,length=0.12
(initial and subsequent segment lengths)branch_angle=0.15 rad
,w=0.1
(branching control)init_node_id=0
,second_node_id=1
(work well on the generated sphere)
If you disable LITE mode, you can increase size/complexity later.
[ ]:
lseg = 0.01
params = FractalTreeParameters(
meshfile=str(mesh_path),
l_segment=lseg,
init_length=0.30,
length=0.16,
branch_angle=0.22, # wider fan-out
w=0.08, # small curvature tweak
N_it=10, # deeper branching
fascicles_angles=[-0.6, -0.2, 0.2, 0.6], # radians
fascicles_length=[25*lseg, 35*lseg, 35*lseg, 25*lseg],
)
params.to_json_file(OUT_DIR / "params.json")
print("Saved params to", OUT_DIR / "params.json")
print(params.to_json())
Grow the Fractal Tree¶
We instantiate FractalTree
with our parameters and call grow_tree()
. The class loads the OBJ, performs the internal UV mapping/scaling, and generates:
nodes_xyz
(list of 3D points),connectivity
(list of edges),end_nodes
(PMJ indices).
[ ]:
ft = FractalTree(params=params)
ft.grow_tree()
n_nodes = len(ft.nodes_xyz)
n_edges = len(ft.connectivity)
n_pmj = len(ft.end_nodes)
print(f"Tree grown.")
print(f"Nodes={n_nodes} Edges={n_edges} PMJs={n_pmj}")
Wrap into PurkinjeTree
and Run FIM Activation¶
We now build a PurkinjeTree
and run the Fast Iterative Method (FIM).
We stimulate node 0 at time 0.0.
return_only_pmj=False
returns activation times for all nodes.
You can inspect PMJ activation times via act[P.pmj]
.
[ ]:
P = PurkinjeTree(
nodes=np.asarray(ft.nodes_xyz, dtype=float),
connectivity=np.asarray(ft.connectivity, dtype=int),
end_nodes=np.asarray(ft.end_nodes, dtype=int),
)
act = P.activate_fim(
x0=np.array([0], dtype=int),
x0_vals=np.array([0.0], dtype=float),
return_only_pmj=False,
)
act_min = float(np.min(act)) if act.size else float("nan")
act_max = float(np.max(act)) if act.size else float("nan")
print("Activation array:", act.shape, " min/max:", act_min, act_max)
if hasattr(P, "pmj"):
pmj_count = len(getattr(P, "pmj"))
print("PMJ count:", pmj_count)
Export for Visualization¶
We save three files into OUT_DIR
:
*_AT.vtu
— Unstructured grid with activation times (native writer).*_meshio.vtu
— Line mesh viameshio
.*_pmj.vtp
— PMJs as points.
Open them in ParaView to verify visually.
[ ]:
at_path = OUT_DIR / "basic_tree_AT.vtu"
meshio_path= OUT_DIR / "basic_tree_meshio.vtu"
pmj_path = OUT_DIR / "basic_tree_pmj.vtu"
P.save(str(at_path))
P.save_meshio(str(meshio_path))
P.save_pmjs(str(pmj_path))
print("Wrote:")
print(" -", at_path)
print(" -", meshio_path)
print(" -", pmj_path)
Optional: Quick 3D Preview (PyVista)¶
If you set EXAMPLES_SHOW=1
, the cell below displays:
Tree lines,
PMJ points.
This is optional and may require a local 3D backend.
[ ]:
# --- Colab-safe PyVista visualization: OBJ surface + tree + PMJs ---
# Resolve paths from notebook globals or sensible defaults
out_dir = Path(globals().get("OUT_DIR", "output/examples/00_basic_tree"))
at_path = Path(globals().get("at_path", out_dir / "basic_tree_AT.vtu"))
pmj_path = Path(globals().get("pmj_path", out_dir / "basic_tree_pmj.vtu"))
# Try to reuse the exact mesh you used to grow the tree
mesh_path = globals().get("mesh_path", None)
if mesh_path is None:
# Fallback chain (adjust to your generation cell name)
for candidate in [
"data/basic_open_ellipsoid.obj",
"data/basic_open_surface.obj",
"data/basic_sphere.obj",
]:
if Path(candidate).exists():
mesh_path = candidate
break
if mesh_path is None:
raise SystemExit("Could not locate the OBJ used for growth. Set `mesh_path` before running.")
mesh_path = Path(mesh_path)
print("Reading tree VTU:", at_path)
tree_ds = pv.read(str(at_path))
pmj_ds = None
if pmj_path.exists():
try:
pmj_ds = pv.read(str(pmj_path))
print("Reading PMJs:", pmj_path)
except Exception as e:
print("PMJ read failed; overlay will be skipped:", e)
print("Reading surface OBJ:", mesh_path)
surface = pv.read(str(mesh_path)) # expect PolyData
if not isinstance(surface, pv.PolyData):
surface = surface.extract_geometry()
surface = surface.clean()
try:
surface = surface.compute_normals(auto_orient_normals=True, splitting=False)
except Exception:
pass
# Optional thinning of heavy surfaces (0.0 keeps full detail)
DECIMATE = float(os.getenv("EXAMPLES_DECIMATE", "0.0"))
if DECIMATE > 0.0 and surface.n_cells > 0:
try:
surface = surface.decimate_proportion(DECIMATE)
print(f"Decimated surface to ~{int((1-DECIMATE)*100)}% faces.")
except Exception:
pass
def pick_scalar(ds: pv.DataSet):
preferred = ["Activation", "AT", "activation", "activation_time", "time_activation"]
names = set(list(ds.point_data.keys()) + list(ds.cell_data.keys()))
for nm in preferred:
if nm in names:
return nm
if len(ds.point_data.keys()) > 0:
return list(ds.point_data.keys())[0]
if len(ds.cell_data.keys()) > 0:
return list(ds.cell_data.keys())[0]
return None
def ensure_polydata(ds: pv.DataSet) -> pv.PolyData:
try:
pd = ds.extract_geometry()
if isinstance(pd, pv.PolyData) and (pd.n_cells > 0 or pd.n_points > 0):
return pd
except Exception:
pass
try:
edges = ds.extract_all_edges()
if isinstance(edges, pv.PolyData) and edges.n_cells > 0:
return edges
except Exception:
pass
return pv.PolyData(ds.points) if hasattr(ds, "points") else pv.PolyData()
def to_tubes(pd: pv.PolyData, radius=0.003):
try:
if pd.n_cells > 0:
return pd.tube(radius=radius)
except Exception:
pass
return pd
# Build tree geometry as tubes for better visibility
tree_geom = ensure_polydata(tree_ds)
tree_tubes = to_tubes(tree_geom, radius=0.003)
tree_scalar = pick_scalar(tree_ds)
# Extract PMJ points if present
pmj_geom = None
if pmj_ds is not None:
pmj_geom = ensure_polydata(pmj_ds) if getattr(pmj_ds, "n_points", 0) > 0 else None
# Also show the boundary edge loop of the OBJ (helps confirm it's OPEN)
try:
boundary_edges = surface.extract_feature_edges(
boundary_edges=True, feature_edges=False, manifold_edges=False, non_manifold_edges=True
)
has_boundary = boundary_edges.n_cells > 0
except Exception:
has_boundary = False
boundary_edges = None
# Try inline HTML backend first
try:
pv.global_theme.jupyter_backend = "html"
print("Using PyVista backend:", pv.global_theme.jupyter_backend)
p = pv.Plotter(notebook=True)
# Surface first (slight opacity so tubes are visible)
p.add_mesh(surface, opacity=0.2, smooth_shading=True)
if has_boundary:
p.add_mesh(boundary_edges, line_width=2)
# Tree tubes with scalar if present
p.add_mesh(
tree_tubes,
scalars=tree_scalar if tree_scalar else None,
show_scalar_bar=bool(tree_scalar),
scalar_bar_args={"title": tree_scalar or ""},
)
# PMJs overlay as points
if pmj_geom is not None and pmj_geom.n_points > 0:
p.add_mesh(pmj_geom, style="points", point_size=12)
p.show(title="OBJ Surface + Purkinje Tree + PMJs")
except Exception as e_html_backend:
print("Inline HTML backend render failed:", e_html_backend)
# Fallback 1: standalone HTML export and embed
try:
html_path = out_dir / "basic_tree_with_surface.html"
try:
pv.start_xvfb()
except Exception:
pass
p = pv.Plotter(off_screen=True)
p.add_mesh(surface, opacity=0.2, smooth_shading=True)
if has_boundary:
p.add_mesh(boundary_edges, line_width=2)
p.add_mesh(
tree_tubes,
scalars=tree_scalar if tree_scalar else None,
show_scalar_bar=bool(tree_scalar),
scalar_bar_args={"title": tree_scalar or ""},
)
if pmj_geom is not None and pmj_geom.n_points > 0:
p.add_mesh(pmj_geom, style="points", point_size=12)
p.export_html(str(html_path))
display(HTML(html_path.read_text(encoding="utf-8")))
print("Embedded exported HTML:", html_path)
except Exception as e_export:
print("HTML export/embed failed:", e_export)
# Fallback 2: screenshot
try:
img_path = out_dir / "basic_tree_with_surface.png"
p = pv.Plotter(off_screen=True)
p.add_mesh(surface, opacity=0.2, smooth_shading=True)
if has_boundary:
p.add_mesh(boundary_edges, line_width=2)
p.add_mesh(
tree_tubes,
scalars=tree_scalar if tree_scalar else None,
show_scalar_bar=bool(tree_scalar),
scalar_bar_args={"title": tree_scalar or ""},
)
if pmj_geom is not None and pmj_geom.n_points > 0:
p.add_mesh(pmj_geom, style="points", point_size=12)
p.show(screenshot=str(img_path))
from IPython.display import Image
display(Image(filename=str(img_path)))
print("Screenshot fallback saved:", img_path)
except Exception as e_ss:
print("Screenshot fallback failed:", e_ss)
Verification Checklist¶
This notebook produced three files in
output/examples/00_basic_tree/
.Activation array has the same length as the number of tree nodes.
PMJ file exists and contains points.
Reproducibility tips
Keep
SEED
fixed to stabilize stochastic parts.Use
EXAMPLES_LITE=1
for CI or quick runs; setEXAMPLES_LITE=0
to explore larger trees.
Troubleshooting¶
``vtk`` wheel installation errors (Windows/Linux): ensure you’re using a supported Python version and recent pip (
python -m pip install --upgrade pip
).OBJ writer not available: the notebook falls back to
meshio
to write OBJ or VTU. Ensuremeshio
is installed (the first cell does this).Blank preview window: set
EXAMPLES_SHOW=1
to enable the viewer and ensure your environment supports interactive windows (e.g., not running headless).