Skip to content

viz

Visualization utilities for morphology and simulation results.

toric_spines_sim.viz

Visualization utilities for morphology and simulation results.

TimeSeriesPlotter(*, title=None, xlabel='t (ms)', ylabel='V (mV)', figsize=(8, 4), xlim=None, ylim=None, nrows=1, sharex=True, ylabels=None, dpi=100)

Convenience Matplotlib plotter for time series.

Source code in toric_spines_sim/viz/plotting.py
def __init__(
    self,
    *,
    title: str | None = None,
    xlabel: str = "t (ms)",
    ylabel: str = "V (mV)",
    figsize: Tuple[int, int] = (8, 4),
    xlim: Optional[Tuple[float, float]] = None,
    ylim: Optional[Tuple[float, float]] = None,
    nrows: int = 1,
    sharex: bool = True,
    ylabels: Optional[Sequence[str]] = None,
    dpi: int = 100,
):
    self._fig, axes = plt.subplots(
        nrows=nrows, ncols=1, sharex=sharex, figsize=figsize, dpi=dpi
    )
    if isinstance(axes, (list, tuple)):
        self._axes_list = list(axes)
    elif hasattr(axes, "ravel"):
        self._axes_list = list(axes.ravel())
    else:
        self._axes_list = [axes]
    self._ax = self._axes_list[0]
    if title:
        if len(self._axes_list) > 1:
            self._fig.suptitle(title)
        else:
            self._ax.set_title(title)
    if len(self._axes_list) > 1:
        self._axes_list[-1].set_xlabel(xlabel)
        if ylabels is not None:
            for a, yl in zip(self._axes_list, ylabels):
                a.set_ylabel(yl)
        else:
            for a in self._axes_list:
                a.set_ylabel(ylabel)
    else:
        self._ax.set_xlabel(xlabel)
        self._ax.set_ylabel(ylabel)
    # self._ax.ticklabel_format(style="plain", axis="both", useOffset=False)
    # self._ax.xaxis.set_major_formatter(FormatStrFormatter('%.2f'))
    # self._ax.yaxis.set_major_formatter(FormatStrFormatter('%.2f'))
    self._lines: list = []
    if xlim:
        for a in self._axes_list:
            a.set_xlim(xlim)
    if ylim:
        for a in self._axes_list:
            a.set_ylim(ylim)

RasterPlotter(*, ax=None, title=None, xlabel='t (ms)', ylabel='stream', figsize=(8, 4), xlim=None, ylim=None, dpi=100, linewidth=1.0)

Raster plotter for multiple event streams (rows over time).

Source code in toric_spines_sim/viz/plotting.py
def __init__(
    self,
    *,
    ax=None,
    title: str | None = None,
    xlabel: str = "t (ms)",
    ylabel: str = "stream",
    figsize: Tuple[int, int] = (8, 4),
    xlim: Optional[Tuple[float, float]] = None,
    ylim: Optional[Tuple[float, float]] = None,
    dpi: int = 100,
    linewidth: float = 1.0,
):
    self._owns_figure = ax is None
    if ax is None:
        self._fig, self._ax = plt.subplots(figsize=figsize, dpi=dpi)
    else:
        self._fig = ax.figure
        self._ax = ax
    self._linewidth = linewidth
    if self._owns_figure:
        if title:
            self._ax.set_title(title)
        self._ax.set_xlabel(xlabel)
        self._ax.set_ylabel(ylabel)
    self._ax.invert_yaxis()
    self._lines: list = []
    self._labels: List[str] = []
    self._label_to_row: Dict[str, int] = {}
    if xlim:
        self._ax.set_xlim(xlim)
    if ylim:
        self._ax.set_ylim(ylim)

Animation(results, swc_filepath)

Build and save voltage animations from simulation results.

Source code in toric_spines_sim/viz/animation.py
def __init__(
    self,
    results: SimulationResults,
    swc_filepath: Union[str, Path],
) -> None:
    self.results = results
    self.swc_filepath = Path(swc_filepath)
    self._fig: Optional[go.Figure] = None

prepare_frame_cache(*, colorscale='Plasma', stride=1, clim=None, opacity=0.8, flatshading=True, radius_scale=1.0, colorbar_title=None, show_axes=True, show_synapses=True, synapse_ball_size=0.5, synapse_stacks=6, synapse_slices=12, synapse_inactive_color='#8b0000', synapse_active_color='#ff4444', synapse_flash_duration_ms=DEFAULT_SYNAPSE_FLASH_DURATION_MS, synapse_colors=None, frustum_sides=16)

Precompute strided frame data for animation or Dash dashboards.

Source code in toric_spines_sim/viz/animation.py
def prepare_frame_cache(
    self,
    *,
    colorscale: str = "Plasma",
    stride: int = 1,
    clim: Optional[Tuple[float, float]] = None,
    opacity: float = 0.8,
    flatshading: bool = True,
    radius_scale: float = 1.0,
    colorbar_title: Optional[str] = None,
    show_axes: bool = True,
    show_synapses: bool = True,
    synapse_ball_size: float = 0.5,
    synapse_stacks: int = 6,
    synapse_slices: int = 12,
    synapse_inactive_color: str = "#8b0000",
    synapse_active_color: str = "#ff4444",
    synapse_flash_duration_ms: float = DEFAULT_SYNAPSE_FLASH_DURATION_MS,
    synapse_colors: Optional[Mapping[str, Tuple[str, str]]] = None,
    frustum_sides: int = 16,
) -> AnimationFrameCache:
    """Precompute strided frame data for animation or Dash dashboards."""
    results = self.results
    logger.info(
        "Preparing animation frames from %d voltage traces",
        len(results.voltage_traces.columns),
    )

    frusta = FrustaSet.from_swc_file(
        str(self.swc_filepath), sides=frustum_sides
    )
    logger.debug(
        "Loaded FrustaSet with %d frusta (sides=%d)",
        frusta.n_frusta,
        frustum_sides,
    )

    probe_coords = list(results.record_points.values())
    frustum_indices = [
        frusta.nearest_frustum_index(coord) for coord in probe_coords
    ]
    probe_labels = list(results.record_points.keys())

    time_domain_ms = results.voltage_traces.as_units("ms").index.values
    n_timepoints = len(time_domain_ms)

    n_frusta = frusta.n_frusta
    amplitudes = np.full((n_timepoints, n_frusta), np.nan)
    for probe_label, frustum_idx in zip(probe_labels, frustum_indices):
        if probe_label in results.voltage_traces.columns:
            amplitudes[:, frustum_idx] = results.voltage_traces[probe_label].values

    logger.info(
        "Mapped %d probes to frusta, preparing %d time steps...",
        len(probe_labels),
        n_timepoints,
    )

    if stride > 1:
        time_domain_ms = time_domain_ms[::stride]
        amplitudes = amplitudes[::stride]
        logger.info(
            "Applied stride=%d: reduced to %d frames",
            stride,
            len(time_domain_ms),
        )

    fr = frusta if radius_scale == 1.0 else frusta.scaled(radius_scale)
    mesh_x, mesh_y, mesh_z, mesh_i, mesh_j, mesh_k = fr.to_mesh3d_arrays()
    face_slices = tuple(fr.frustum_face_slices_map().values())

    if clim is None:
        cmin = float(np.nanmin(amplitudes))
        cmax = float(np.nanmax(amplitudes))
    else:
        cmin, cmax = clim

    logger.info("Color limits: [%.3f, %.3f]", cmin, cmax)

    synapse_coords: Optional[tuple[tuple[float, float, float], ...]] = None
    synapse_labels: Optional[tuple[str, ...]] = None
    synapse_mesh_arrays: Optional[tuple[Any, ...]] = None
    synapse_facecolors: Optional[tuple[list[str], ...]] = None

    if show_synapses:
        overlay = self._prepare_synapse_overlay(
            stride=stride,
            time_domain_ms=results.voltage_traces.as_units("ms").index.values,
            synapse_ball_size=synapse_ball_size,
            synapse_stacks=synapse_stacks,
            synapse_slices=synapse_slices,
            synapse_inactive_color=synapse_inactive_color,
            synapse_active_color=synapse_active_color,
            synapse_flash_duration_ms=synapse_flash_duration_ms,
            synapse_colors=synapse_colors,
        )
        if overlay is not None:
            synapse_coords = tuple(overlay["coords"])
            synapse_labels = tuple(overlay["labels"])
            synapse_mesh_arrays = overlay["mesh_arrays"]
            synapse_facecolors = tuple(overlay["facecolors_per_frame"])
            synapse_facecolor_indices = tuple(
                overlay["facecolor_indices_per_frame"]
            )
            synapse_color_palette = tuple(overlay["facecolor_palette"])

    return AnimationFrameCache(
        time_ms=np.asarray(time_domain_ms, dtype=float),
        amplitudes=np.asarray(amplitudes, dtype=float),
        mesh_x=mesh_x,
        mesh_y=mesh_y,
        mesh_z=mesh_z,
        mesh_i=mesh_i,
        mesh_j=mesh_j,
        mesh_k=mesh_k,
        face_slices=face_slices,
        cmin=cmin,
        cmax=cmax,
        colorscale=colorscale,
        opacity=opacity,
        flatshading=flatshading,
        colorbar_title=colorbar_title,
        show_axes=show_axes,
        synapse_coords=synapse_coords,
        synapse_labels=synapse_labels,
        synapse_mesh_arrays=synapse_mesh_arrays,
        synapse_facecolors_per_frame=synapse_facecolors,
        synapse_facecolor_indices=synapse_facecolor_indices,
        synapse_color_palette=synapse_color_palette,
    )

build(*, colorscale='Plasma', fps=30, stride=1, clim=None, opacity=0.8, flatshading=True, radius_scale=1.0, title=None, colorbar_title=None, show_axes=True, show_synapses=True, synapse_ball_size=0.5, synapse_stacks=6, synapse_slices=12, synapse_inactive_color='#8b0000', synapse_active_color='#ff4444', synapse_flash_duration_ms=DEFAULT_SYNAPSE_FLASH_DURATION_MS, synapse_colors=None, frustum_sides=16)

Build the animation figure (frusta voltage + optional synapse overlay).

Source code in toric_spines_sim/viz/animation.py
def build(
    self,
    *,
    colorscale: str = "Plasma",
    fps: int = 30,
    stride: int = 1,
    clim: Optional[Tuple[float, float]] = None,
    opacity: float = 0.8,
    flatshading: bool = True,
    radius_scale: float = 1.0,
    title: Optional[str] = None,
    colorbar_title: Optional[str] = None,
    show_axes: bool = True,
    show_synapses: bool = True,
    synapse_ball_size: float = 0.5,
    synapse_stacks: int = 6,
    synapse_slices: int = 12,
    synapse_inactive_color: str = "#8b0000",
    synapse_active_color: str = "#ff4444",
    synapse_flash_duration_ms: float = DEFAULT_SYNAPSE_FLASH_DURATION_MS,
    synapse_colors: Optional[Mapping[str, Tuple[str, str]]] = None,
    frustum_sides: int = 16,
) -> go.Figure:
    """Build the animation figure (frusta voltage + optional synapse overlay)."""
    cache = self.prepare_frame_cache(
        colorscale=colorscale,
        stride=stride,
        clim=clim,
        opacity=opacity,
        flatshading=flatshading,
        radius_scale=radius_scale,
        colorbar_title=colorbar_title,
        show_axes=show_axes,
        show_synapses=show_synapses,
        synapse_ball_size=synapse_ball_size,
        synapse_stacks=synapse_stacks,
        synapse_slices=synapse_slices,
        synapse_inactive_color=synapse_inactive_color,
        synapse_active_color=synapse_active_color,
        synapse_flash_duration_ms=synapse_flash_duration_ms,
        synapse_colors=synapse_colors,
        frustum_sides=frustum_sides,
    )

    frusta = FrustaSet.from_swc_file(
        str(self.swc_filepath), sides=frustum_sides
    )

    extra_traces: list[Any] = []
    extra_frame_traces: Optional[ExtraFrameTraces] = None

    if cache.synapse_coords is not None and cache.synapse_mesh_arrays is not None:
        extra_traces.append(
            _synapse_hover_trace(cache.synapse_coords, cache.synapse_labels or ())
        )
        mesh_arrays = cache.synapse_mesh_arrays
        facecolors_per_frame = cache.synapse_facecolors_per_frame or ()

        def _synapse_frame_traces(
            frame_idx: int,
            _arrays=mesh_arrays,
            _facecolors=facecolors_per_frame,
        ) -> list[go.Mesh3d]:
            syn_x, syn_y, syn_z, syn_i, syn_j, syn_k = _arrays
            return [
                _synapse_mesh_trace(
                    syn_x,
                    syn_y,
                    syn_z,
                    syn_i,
                    syn_j,
                    syn_k,
                    _facecolors[frame_idx],
                )
            ]

        extra_frame_traces = _synapse_frame_traces

    build_kwargs: dict = dict(
        colorscale=cache.colorscale,
        fps=fps,
        stride=1,
        opacity=cache.opacity,
        flatshading=cache.flatshading,
        radius_scale=radius_scale,
        show_axes=cache.show_axes,
        extra_traces=extra_traces or None,
        extra_frame_traces=extra_frame_traces,
        clim=(cache.cmin, cache.cmax),
    )
    if title is not None:
        build_kwargs["title"] = title
    if cache.colorbar_title is not None:
        build_kwargs["colorbar_title"] = cache.colorbar_title

    fig = _build_frusta_timeseries_figure(
        frusta,
        cache.time_ms,
        cache.amplitudes,
        **build_kwargs,
    )
    self._fig = fig
    return fig

save(output_path, fig=None, *, auto_open=False)

Write the animation figure to HTML.

Source code in toric_spines_sim/viz/animation.py
def save(
    self,
    output_path: Union[str, Path],
    fig: Optional[go.Figure] = None,
    *,
    auto_open: bool = False,
) -> go.Figure:
    """Write the animation figure to HTML."""
    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    fig = fig if fig is not None else self._fig
    if fig is None:
        raise RuntimeError("No figure to save; call build() first.")

    fig.write_html(str(output_path), auto_play=False)
    logger.info("Animation saved to %s", output_path)

    if auto_open:
        webbrowser.open(f"file://{output_path.absolute()}")

    return fig

create(output_path, *, auto_open=False, **build_kwargs)

Build and save the animation in one call.

Source code in toric_spines_sim/viz/animation.py
def create(
    self,
    output_path: Union[str, Path],
    *,
    auto_open: bool = False,
    **build_kwargs,
) -> go.Figure:
    """Build and save the animation in one call."""
    fig = self.build(**build_kwargs)
    return self.save(output_path, fig, auto_open=auto_open)

AnimationFrameCache(time_ms, amplitudes, mesh_x, mesh_y, mesh_z, mesh_i, mesh_j, mesh_k, face_slices, cmin, cmax, colorscale, opacity, flatshading, colorbar_title, show_axes, synapse_coords=None, synapse_labels=None, synapse_mesh_arrays=None, synapse_facecolors_per_frame=None, synapse_facecolor_indices=None, synapse_color_palette=None) dataclass

Precomputed frame data for 3D voltage animation.

faces_intensity_array(frame_idx)

Return the 3D frusta intensity as a flat Float32 array.

Source code in toric_spines_sim/viz/animation.py
def faces_intensity_array(self, frame_idx: int) -> np.ndarray:
    """Return the 3D frusta intensity as a flat Float32 array."""
    vt = self.amplitudes[frame_idx]
    counts = np.array([count for _, count in self.face_slices], dtype=np.int32)
    return np.repeat(vt, counts).astype(np.float32)

synapse_facecolor_hex(frame_idx)

Expand palette-index facecolors to full hex strings.

Source code in toric_spines_sim/viz/animation.py
def synapse_facecolor_hex(self, frame_idx: int) -> list[str]:
    """Expand palette-index facecolors to full hex strings."""
    if self.synapse_facecolor_indices is None:
        return list(self.synapse_facecolors_per_frame[frame_idx])
    indices = self.synapse_facecolor_indices[frame_idx]
    palette = self.synapse_color_palette
    return [palette[idx] for idx in indices]

build_3d_clientside_bundle()

Return JSON-serializable per-frame data for clientside 3D updates.

Source code in toric_spines_sim/viz/animation.py
def build_3d_clientside_bundle(self) -> dict[str, Any]:
    """Return JSON-serializable per-frame data for clientside 3D updates."""
    bundle: dict[str, Any] = {
        "time_ms": self.time_ms.tolist(),
        "n_frames": self.n_frames,
        "mesh_intensity": [
            self.faces_intensity_array(i).tolist()
            for i in range(self.n_frames)
        ],
    }
    if self.synapse_facecolor_indices is not None:
        bundle["synapse_facecolor_indices"] = [
            arr.tolist() for arr in self.synapse_facecolor_indices
        ]
        bundle["synapse_color_palette"] = list(self.synapse_color_palette)
    elif self.synapse_facecolors_per_frame is not None:
        bundle["synapse_facecolor"] = list(self.synapse_facecolors_per_frame)
    return bundle

draw_morphology(morph, ax=None, *, color=COLORS['skeleton'], linewidth=1.0, alpha=0.6)

Draw the morphology as polylines (prox->dist for each msegment) on a 3D axes.

Parameters:

Name Type Description Default
morph morphology

Morphology to render.

required
ax matplotlib 3D axes

If None, a new figure and axes are created.

None
color str

Line color for cables.

COLORS['skeleton']
linewidth float

Line width for polylines.

1.0
alpha float

Line alpha.

0.6

Returns:

Type Description
Axes3DSubplot

The 3D axes used for drawing.

Source code in toric_spines_sim/viz/arbor.py
def draw_morphology(
    morph: "A.morphology",
    ax=None,
    *,
    color: str = COLORS["skeleton"],
    linewidth: float = 1.0,
    alpha: float = 0.6,
):
    """Draw the morphology as polylines (prox->dist for each msegment) on a 3D axes.

    Parameters
    ----------
    morph : arbor.morphology
        Morphology to render.
    ax : matplotlib 3D axes, optional
        If None, a new figure and axes are created.
    color : str
        Line color for cables.
    linewidth : float
        Line width for polylines.
    alpha : float
        Line alpha.

    Returns
    -------
    matplotlib.axes._subplots.Axes3DSubplot
        The 3D axes used for drawing.
    """
    ax = _ensure_axes3d(ax)
    morph = _as_morphology(morph)

    # Iterate over branches and their segments
    for b in range(morph.num_branches):
        segs = morph.branch_segments(b)
        for s in segs:
            p, q = s.prox, s.dist  # mpoint
            ax.plot(
                [p.x, q.x],
                [p.y, q.y],
                [p.z, q.z],
                color=_color_mpl,
                linewidth=linewidth,
                alpha=alpha,
            )

    ax.set_xlabel("x (µm)")
    ax.set_ylabel("y (µm)")
    ax.set_zlabel("z (µm)")
    return ax

draw_morphology_frusta(morph, ax=None, *, n_sides=16, color=COLORS['segment'], alpha=0.8, edgecolor=None, linewidth=0.0, min_radius=0.001, radius_scale=1.0, caps=False)

Render each segment as a truncated cone (frustum) between endpoints with radii.

Parameters:

Name Type Description Default
morph morphology

Morphology whose segments will be rendered.

required
ax matplotlib 3D axes or None
None
n_sides int

Number of sides for the circular cross-sections (smoothness).

16
color str

Face color for the frusta.

COLORS['segment']
alpha float

Face alpha for the frusta.

0.8
edgecolor str | None

Edge color for the frusta; None disables edges.

None
linewidth float

Edge line width.

0.0
min_radius float

Minimum radius to use to avoid degenerate rings.

0.001
radius_scale float

Uniform multiplier applied to all segment radii for visualization (default 1.0).

1.0
caps bool

If True, add end-caps (disks). Defaults to False to avoid overlap artifacts.

False

Returns:

Name Type Description
ax 3D axes used for drawing.
Source code in toric_spines_sim/viz/arbor.py
def draw_morphology_frusta(
    morph: "A.morphology",
    ax=None,
    *,
    n_sides: int = 16,
    color: str = COLORS["segment"],
    alpha: float = 0.8,
    edgecolor: Optional[str] = None,
    linewidth: float = 0.0,
    min_radius: float = 1e-3,
    radius_scale: float = 1.0,
    caps: bool = False,
):
    """Render each segment as a truncated cone (frustum) between endpoints with radii.

    Parameters
    ----------
    morph : arbor.morphology
        Morphology whose segments will be rendered.
    ax : matplotlib 3D axes or None
    n_sides : int
        Number of sides for the circular cross-sections (smoothness).
    color : str
        Face color for the frusta.
    alpha : float
        Face alpha for the frusta.
    edgecolor : str | None
        Edge color for the frusta; None disables edges.
    linewidth : float
        Edge line width.
    min_radius : float
        Minimum radius to use to avoid degenerate rings.
    radius_scale : float
        Uniform multiplier applied to all segment radii for visualization (default 1.0).
    caps : bool
        If True, add end-caps (disks). Defaults to False to avoid overlap artifacts.

    Returns
    -------
    ax : 3D axes used for drawing.
    """
    ax = _ensure_axes3d(ax)
    morph = _as_morphology(morph)

    # Collect all polygon faces across the morphology
    faces: List[List[Tuple[float, float, float]]] = []

    # Precompute circle angles
    twopi = 2.0 * math.pi
    angles = [twopi * k / n_sides for k in range(n_sides)]

    for b in range(morph.num_branches):
        for s in morph.branch_segments(b):
            p, q = s.prox, s.dist  # mpoint endpoints
            r1 = max(min_radius, radius_scale * _mpoint_radius(p))
            r2 = max(min_radius, radius_scale * _mpoint_radius(q))

            dx, dy, dz = (q.x - p.x), (q.y - p.y), (q.z - p.z)
            u, a, bvec = _orthonormal_frame_from_axis(dx, dy, dz)
            ax_, ay_, az_ = a
            bx_, by_, bz_ = bvec

            # Build rings at each end
            ring1: List[Tuple[float, float, float]] = []
            ring2: List[Tuple[float, float, float]] = []
            for th in angles:
                ct = math.cos(th)
                st = math.sin(th)
                # point on circle in the plane perpendicular to axis
                rx1 = r1 * (ct * ax_ + st * bx_)
                ry1 = r1 * (ct * ay_ + st * by_)
                rz1 = r1 * (ct * az_ + st * bz_)
                rx2 = r2 * (ct * ax_ + st * bx_)
                ry2 = r2 * (ct * ay_ + st * by_)
                rz2 = r2 * (ct * az_ + st * bz_)
                ring1.append((p.x + rx1, p.y + ry1, p.z + rz1))
                ring2.append((q.x + rx2, q.y + ry2, q.z + rz2))

            # Connect rings with quads
            for i in range(n_sides):
                j = (i + 1) % n_sides
                v00 = ring1[i]
                v01 = ring2[i]
                v11 = ring2[j]
                v10 = ring1[j]
                faces.append([v00, v01, v11, v10])

            if caps:
                # Simple fan triangulation for caps (optional)
                c1 = (p.x, p.y, p.z)
                c2 = (q.x, q.y, q.z)
                for i in range(1, n_sides - 1):
                    faces.append([ring1[0], ring1[i], ring1[i + 1]])
                    faces.append([ring2[0], ring2[i + 1], ring2[i]])

    if not faces:
        return ax

    poly = Poly3DCollection(faces)
    poly.set_facecolor(COLORS["segment"])
    poly.set_alpha(alpha)
    if edgecolor is None:
        poly.set_edgecolor("none")
    else:
        poly.set_edgecolor(edgecolor)
    poly.set_linewidth(linewidth)
    ax.add_collection3d(poly)

    # Set axes limits based on geometry
    xs = [v[0] for face in faces for v in face]
    ys = [v[1] for face in faces for v in face]
    zs = [v[2] for face in faces for v in face]
    xmin, xmax = min(xs), max(xs)
    ymin, ymax = min(ys), max(ys)
    zmin, zmax = min(zs), max(zs)
    ax.set_xlim(xmin, xmax)
    ax.set_ylim(ymin, ymax)
    ax.set_zlim(zmin, zmax)
    ax.set_xlabel("x (µm)")
    ax.set_ylabel("y (µm)")
    ax.set_zlabel("z (µm)")
    return ax

plot_morphology_3d(obj, *, overlays=None, overlay_styles=None, backend='auto', isometry=None, line_kwargs=None, scatter_kwargs=None, layout_kwargs=None, config=None)

Unified 3D plot of an Arbor morphology or cable_cell, with optional overlays.

Parameters:

Name Type Description Default
obj morphology | loaded_morphology | cable_cell

Object whose morphology will be rendered.

required
overlays dict[str, Sequence[location | str]]

Mapping of label -> sequence of overlay items. Each item can be an arbor.location or an explicit '(location b x)' string.

None
overlay_styles dict[str, dict]

Matplotlib/Plotly styling per overlay label. For Matplotlib, accepts {'color': 'r', 'size': 10}. For Plotly, accepts {'color': 'r', 'size': 3}.

None
backend ('auto', 'plotly', 'mpl')
  • 'auto': Plotly in notebooks (if available), otherwise Matplotlib.
  • 'plotly': Force Plotly.
  • 'mpl': Force Matplotlib.
'auto','plotly','mpl'
isometry isometry

Geometry transform for location-to-point mapping.

None
line_kwargs dict

Extra styling forwarded to underlying backend helpers.

None
scatter_kwargs dict

Extra styling forwarded to underlying backend helpers.

None
layout_kwargs dict

Extra styling forwarded to underlying backend helpers.

None

Returns:

Type Description
Matplotlib Axes3D (backend='mpl') or plotly.graph_objects.Figure (backend='plotly').
Source code in toric_spines_sim/viz/arbor.py
def plot_morphology_3d(
    obj,
    *,
    overlays: Optional[dict[str, Sequence[object]]] = None,
    overlay_styles: Optional[dict[str, dict]] = None,
    backend: str = "auto",
    isometry: Optional["A.isometry"] = None,
    line_kwargs: Optional[dict] = None,
    scatter_kwargs: Optional[dict] = None,
    layout_kwargs: Optional[dict] = None,
    config: Optional["VizConfig"] = None,
):
    """Unified 3D plot of an Arbor morphology or cable_cell, with optional overlays.

    Parameters
    ----------
    obj : arbor.morphology | loaded_morphology | cable_cell
        Object whose morphology will be rendered.
    overlays : dict[str, Sequence[location | str]], optional
        Mapping of label -> sequence of overlay items. Each item can be an
        `arbor.location` or an explicit '(location b x)' string.
    overlay_styles : dict[str, dict], optional
        Matplotlib/Plotly styling per overlay label. For Matplotlib, accepts
        {'color': 'r', 'size': 10}. For Plotly, accepts {'color': 'r', 'size': 3}.
    backend : {'auto','plotly','mpl'}
        - 'auto': Plotly in notebooks (if available), otherwise Matplotlib.
        - 'plotly': Force Plotly.
        - 'mpl': Force Matplotlib.
    isometry : arbor.isometry, optional
        Geometry transform for location-to-point mapping.
    line_kwargs, scatter_kwargs, layout_kwargs : dict, optional
        Extra styling forwarded to underlying backend helpers.

    Returns
    -------
    Matplotlib Axes3D (backend='mpl') or plotly.graph_objects.Figure (backend='plotly').
    """
    morph = _as_morphology(obj.morphology if hasattr(obj, "morphology") else obj)
    overlays = overlays or {}
    overlay_styles = overlay_styles or {}
    line_kwargs = line_kwargs or {}
    scatter_kwargs = scatter_kwargs or {}
    layout_kwargs = layout_kwargs or {}

    chosen = _default_backend() if backend == "auto" else backend

    if chosen == "plotly":
        if go is None:
            raise ImportError(
                "Plotly is not installed but backend='plotly' was requested."
            )
        traces = plotly_morphology_traces(morph, **line_kwargs)
        overlay_traces = []
        for label, items in overlays.items():
            locs, pts = _split_overlay_items(morph, items)
            style = overlay_styles.get(label, {})
            color = style.get("color", "red")
            size = style.get("size", 3)
            overlay_traces.append(
                plotly_locations_trace(
                    morph, locs, isometry=isometry, color=color, size=size, name=label
                )
            )
            if pts:
                overlay_traces.append(
                    plotly_points_trace(
                        pts, color=color, size=size, name=f"{label}_pts"
                    )
                )
        fig = (
            __import__("plotly.graph_objects", fromlist=["go"]).Figure(
                data=traces + overlay_traces
            )
            if False
            else None
        )
        # Use our existing helper to build the figure for consistency
        # (recreate layout defaults)
        fig = go.Figure(data=traces + overlay_traces)
        scene = _default_plotly_scene(layout_kwargs)
        fig.update_layout(scene=scene, **layout_kwargs)
        _apply_viz_config(fig, config)
        return fig

    # Matplotlib path
    ax = draw_morphology(morph, **line_kwargs)
    for label, items in overlays.items():
        locs, pts = _split_overlay_items(morph, items)
        style = overlay_styles.get(label, {})
        size = style.get("size", 10)
        color = style.get("color", "r")
        scatter_locations(
            morph,
            locs,
            ax=ax,
            color=color,
            size=size,
            label=label,
            isometry=isometry,
            **scatter_kwargs,
        )
        if pts:
            scatter_points(pts, ax=ax, color=color, size=size, label=f"{label}_pts")
    if config is not None:
        if config.figsize is not None:
            try:
                ax.figure.set_size_inches(*config.figsize)
            except Exception:
                pass
        if config.title is not None:
            try:
                ax.set_title(config.title)
            except Exception:
                pass
    return ax

plot_morphology_frusta_3d(obj, *, n_sides=16, color=COLORS['segment'], alpha=0.8, edgecolor=None, linewidth=0.0, overlays=None, overlay_styles=None, backend='auto', layout_kwargs=None, radius_scale=1.0, caps=False, isometry=None, config=None)

Convenience wrapper: frusta rendering with optional overlays, MPL or Plotly.

Parameters:

Name Type Description Default
obj morphology | loaded_morphology | cable_cell

Object whose morphology will be rendered as frusta.

required
overlays dict[str, Sequence[location | str | (x, y, z)]]

Overlay points/locations drawn on top of the frusta.

None
overlay_styles dict[str, dict]

Styling per overlay label. For Matplotlib: {'color': 'r', 'size': 10}. For Plotly: {'color': 'r', 'size': 3}.

None
backend ('auto', 'plotly', 'mpl')

Choose rendering backend.

'auto','plotly','mpl'
layout_kwargs dict

Additional layout kwargs for Plotly (e.g., scene, title).

None
radius_scale float

Uniform multiplier applied to all segment radii for visualization (default 1.0).

1.0
caps bool

If True, add end-caps to segments.

False
isometry isometry

Used only for mapping overlay locations to points; geometry itself is drawn in native coords.

None

Returns:

Type Description
Matplotlib Axes3D (backend='mpl') or plotly.graph_objects.Figure (backend='plotly').
Source code in toric_spines_sim/viz/arbor.py
def plot_morphology_frusta_3d(
    obj,
    *,
    n_sides: int = 16,
    color: str = COLORS["segment"],
    alpha: float = 0.8,
    edgecolor: Optional[str] = None,
    linewidth: float = 0.0,
    overlays: Optional[dict[str, Sequence[object]]] = None,
    overlay_styles: Optional[dict[str, dict]] = None,
    backend: str = "auto",
    layout_kwargs: Optional[dict] = None,
    radius_scale: float = 1.0,
    caps: bool = False,
    isometry: Optional["A.isometry"] = None,
    config: Optional["VizConfig"] = None,
):
    """Convenience wrapper: frusta rendering with optional overlays, MPL or Plotly.

    Parameters
    ----------
    obj : arbor.morphology | loaded_morphology | cable_cell
        Object whose morphology will be rendered as frusta.
    overlays : dict[str, Sequence[location | str | (x,y,z)]], optional
        Overlay points/locations drawn on top of the frusta.
    overlay_styles : dict[str, dict], optional
        Styling per overlay label. For Matplotlib: {'color': 'r', 'size': 10}.
        For Plotly: {'color': 'r', 'size': 3}.
    backend : {'auto','plotly','mpl'}
        Choose rendering backend.
    layout_kwargs : dict, optional
        Additional layout kwargs for Plotly (e.g., scene, title).
    radius_scale : float
        Uniform multiplier applied to all segment radii for visualization (default 1.0).
    caps : bool
        If True, add end-caps to segments.
    isometry : arbor.isometry, optional
        Used only for mapping overlay locations to points; geometry itself is drawn in native coords.

    Returns
    -------
    Matplotlib Axes3D (backend='mpl') or plotly.graph_objects.Figure (backend='plotly').
    """
    morph = _as_morphology(obj.morphology if hasattr(obj, "morphology") else obj)
    overlays = overlays or {}
    overlay_styles = overlay_styles or {}
    chosen = _default_backend() if backend == "auto" else backend

    if chosen == "plotly":
        if go is None:
            raise ImportError(
                "Plotly is not installed but backend='plotly' was requested."
            )
        mesh = plotly_morphology_frusta_trace(
            morph,
            n_sides=n_sides,
            color=color,
            opacity=alpha,
            radius_scale=radius_scale,
            caps=caps,
        )
        overlay_traces = []
        for label, items in overlays.items():
            locs, pts = _split_overlay_items(morph, items)
            style = overlay_styles.get(label, {})
            c = style.get("color", "red")
            size = style.get("size", 3)
            if locs:
                overlay_traces.append(
                    plotly_locations_trace(
                        morph, locs, isometry=isometry, color=c, size=size, name=label
                    )
                )
            if pts:
                overlay_traces.append(
                    plotly_points_trace(pts, color=c, size=size, name=f"{label}_pts")
                )
        fig = go.Figure(data=[mesh] + overlay_traces)
        layout_kwargs = layout_kwargs or {}
        scene = _default_plotly_scene(layout_kwargs)
        fig.update_layout(scene=scene, **layout_kwargs)
        _apply_viz_config(fig, config)
        return fig

    # Matplotlib path
    ax = draw_morphology_frusta(
        morph,
        n_sides=n_sides,
        color=color,
        alpha=alpha,
        edgecolor=edgecolor,
        linewidth=linewidth,
        radius_scale=radius_scale,
        caps=caps,
    )
    for label, items in overlays.items():
        locs, pts = _split_overlay_items(morph, items)
        style = overlay_styles.get(label, {})
        size = style.get("size", 10)
        color_pt = style.get("color", "r")
        if locs:
            scatter_locations(
                morph,
                locs,
                ax=ax,
                color=color_pt,
                size=size,
                label=label,
                isometry=isometry,
            )
        if pts:
            scatter_points(pts, ax=ax, color=color_pt, size=size, label=f"{label}_pts")
    if config is not None:
        if config.figsize is not None:
            try:
                ax.figure.set_size_inches(*config.figsize)
            except Exception:
                pass
        if config.title is not None:
            try:
                ax.set_title(config.title)
            except Exception:
                pass
    return ax

plot_cable_cell_with_locations(cell, *, morph_color='k', morph_linewidth=1.0, morph_alpha=0.6, overlays=None, overlay_styles=None, ax=None)

Plot an Arbor cable_cell morphology and overlay explicit '(location ...)' placements.

Parameters:

Name Type Description Default
cell cable_cell

The cell to visualize. Its morphology is drawn.

required
overlays dict[str, Sequence[str]]

Optional mapping from a label (e.g., 'syn', 'gj') to a list of explicit location expressions '(location b x)'. These are converted to Arbor locations and scattered with styles from overlay_styles if provided.

None
overlay_styles dict[str, dict]

Matplotlib scatter kwargs per overlay label, e.g., {'syn': {'color': 'r', 'size': 12}}.

None
ax matplotlib 3D axes

If None, a new 3D axes is created.

None

Returns:

Name Type Description
ax 3D axes used for plotting.
Notes

This function focuses on explicit single-location overlays. Complex locsets (e.g., '(uniform ...)') are not evaluated here; pass resolved '(location ...)' strings if you want them included. For uniform placements, consider recording the resolved locations during model construction for visualization.

Source code in toric_spines_sim/viz/arbor.py
def plot_cable_cell_with_locations(
    cell: "A.cable_cell",
    *,
    morph_color: str = "k",
    morph_linewidth: float = 1.0,
    morph_alpha: float = 0.6,
    overlays: Optional[dict[str, Sequence[str]]] = None,
    overlay_styles: Optional[dict[str, dict]] = None,
    ax=None,
):
    """Plot an Arbor cable_cell morphology and overlay explicit '(location ...)' placements.

    Parameters
    ----------
    cell : arbor.cable_cell
        The cell to visualize. Its morphology is drawn.
    overlays : dict[str, Sequence[str]]
        Optional mapping from a label (e.g., 'syn', 'gj') to a list of explicit
        location expressions '(location b x)'. These are converted to Arbor locations
        and scattered with styles from `overlay_styles` if provided.
    overlay_styles : dict[str, dict]
        Matplotlib scatter kwargs per overlay label, e.g., {'syn': {'color': 'r', 'size': 12}}.
    ax : matplotlib 3D axes, optional
        If None, a new 3D axes is created.

    Returns
    -------
    ax : 3D axes used for plotting.

    Notes
    -----
    This function focuses on explicit single-location overlays. Complex locsets
    (e.g., '(uniform ...)') are not evaluated here; pass resolved '(location ...)' strings
    if you want them included. For uniform placements, consider recording the resolved
    locations during model construction for visualization.
    """
    # Draw morphology first
    morph = cell.morphology
    ax = draw_morphology(
        morph, ax=ax, color=morph_color, linewidth=morph_linewidth, alpha=morph_alpha
    )

    if overlays:
        overlay_styles = overlay_styles or {}
        for label, exprs in overlays.items():
            locs = locations_from_location_exprs(exprs)
            style = overlay_styles.get(label, {})
            # Map 'size' -> s for matplotlib scatter
            size = style.pop("size", 10)
            color = style.pop("color", "r")
            ax = scatter_locations(
                morph, locs, ax=ax, color=color, size=size, label=label
            )
    return ax

terminals_from_morphology(morph)

Return terminal locations (branch-end locations) for the given morphology.

Each terminal is represented as location(branch_id, x=1.0) for branches with no children.

Source code in toric_spines_sim/viz/arbor.py
def terminals_from_morphology(morph: "A.morphology") -> List["A.location"]:
    """Return terminal locations (branch-end locations) for the given morphology.

    Each terminal is represented as `location(branch_id, x=1.0)` for branches
    with no children.
    """
    morph = _as_morphology(morph)
    locs: List["A.location"] = []
    for b in range(morph.num_branches):
        if not morph.branch_children(b):
            locs.append(A.location(b, 1.0))
    return locs

locations_to_points(morph, locs, isometry=None)

Map a sequence of locations to 3D points with place_pwlin.

Parameters:

Name Type Description Default
morph morphology
required
locs sequence of arbor.location
required
isometry isometry

If provided, the morphology is transformed before mapping locations.

None

Returns:

Type Description
list[mpoint]
Source code in toric_spines_sim/viz/arbor.py
def locations_to_points(
    morph: "A.morphology",
    locs: Sequence["A.location"],
    isometry: Optional["A.isometry"] = None,
) -> List["A.mpoint"]:
    """Map a sequence of locations to 3D points with place_pwlin.

    Parameters
    ----------
    morph : arbor.morphology
    locs : sequence of arbor.location
    isometry : arbor.isometry, optional
        If provided, the morphology is transformed before mapping locations.

    Returns
    -------
    list[arbor.mpoint]
    """
    morph = _as_morphology(morph)
    pw = (
        A.place_pwlin(morph, isometry) if isometry is not None else A.place_pwlin(morph)
    )
    pts: List["A.mpoint"] = []
    for l in locs:
        pts.append(pw.at(l))  # any corresponding 3D point
    return pts

locations_from_location_exprs(exprs)

Convert a sequence of '(location b x)' expressions into Arbor locations.

Any expressions that do not match the simple '(location ...)' form are ignored.

Source code in toric_spines_sim/viz/arbor.py
def locations_from_location_exprs(exprs: Sequence[str]) -> List["A.location"]:
    """Convert a sequence of '(location b x)' expressions into Arbor locations.

    Any expressions that do not match the simple '(location ...)' form are ignored.
    """
    locs: List["A.location"] = []
    for e in exprs:
        parsed = parse_location_expr(e)
        if parsed is None:
            continue
        b, x = parsed
        locs.append(A.location(b, x))
    return locs

parse_location_expr(expr)

Parse a simple locset expression of the form '(location )'.

Returns (branch, pos) or None if the expression does not match. This is intentionally simple and only supports explicit single-location expressions, which are commonly produced by mapping SWC coordinates via place_pwlin().

Source code in toric_spines_sim/viz/arbor.py
def parse_location_expr(expr: str) -> Optional[Tuple[int, float]]:
    """Parse a simple locset expression of the form '(location <branch> <pos>)'.

    Returns (branch, pos) or None if the expression does not match.
    This is intentionally simple and only supports explicit single-location expressions,
    which are commonly produced by mapping SWC coordinates via place_pwlin().
    """
    m = _LOC_RE.match(expr.strip())
    if not m:
        return None
    return int(m.group("branch")), float(m.group("pos"))

scatter_locations(morph, locs, ax=None, *, color=COLORS['synapse_point'], size=10, label=None, isometry=None)

Scatter-plot a set of locations on top of a morphology by mapping to 3D points.

Returns the axes used for plotting.

Source code in toric_spines_sim/viz/arbor.py
def scatter_locations(
    morph: "A.morphology",
    locs: Sequence["A.location"],
    ax=None,
    *,
    color: str = COLORS["synapse_point"],
    size: float = 10,
    label: Optional[str] = None,
    isometry: Optional["A.isometry"] = None,
):
    """Scatter-plot a set of locations on top of a morphology by mapping to 3D points.

    Returns the axes used for plotting.
    """
    ax = _ensure_axes3d(ax)
    morph = _as_morphology(morph)
    pts = locations_to_points(morph, locs, isometry=isometry)
    xs = [p.x for p in pts]
    ys = [p.y for p in pts]
    zs = [p.z for p in pts]
    ax.scatter(xs, ys, zs, c=color, s=size, label=label)
    if label:
        ax.legend()
    return ax

scatter_points(points, ax=None, *, color=COLORS['synapse_point'], size=10, label=None)

Scatter-plot arbitrary arbor.mpoint coordinates on a 3D axes.

Useful if you already mapped locations to points, or want to overlay custom coordinates.

Source code in toric_spines_sim/viz/arbor.py
def scatter_points(
    points: Sequence[object],
    ax=None,
    *,
    color: str = COLORS["synapse_point"],
    size: float = 10,
    label: Optional[str] = None,
):
    """Scatter-plot arbitrary arbor.mpoint coordinates on a 3D axes.

    Useful if you already mapped locations to points, or want to overlay
    custom coordinates.
    """
    ax = _ensure_axes3d(ax)
    xs: List[float] = []
    ys: List[float] = []
    zs: List[float] = []
    for p in points:
        if hasattr(p, "x") and hasattr(p, "y") and hasattr(p, "z"):
            xs.append(float(p.x))
            ys.append(float(p.y))
            zs.append(float(p.z))
        else:
            try:
                x, y, z = p  # type: ignore[misc]
                xs.append(float(x))
                ys.append(float(y))
                zs.append(float(z))
            except Exception:
                continue
    ax.scatter(xs, ys, zs, c=color, s=size, label=label)
    if label:
        ax.legend()
    return ax

plot_morph_and_locations(morph, locs, *, ax=None, line_kwargs=None, scatter_kwargs=None, isometry=None)

Draw morphology and overlay a set of locations on a single 3D axes.

Parameters:

Name Type Description Default
morph morphology
required
locs sequence[location]
required
ax 3D axes or None
None
line_kwargs dict

Passed to draw_morphology (e.g., color, linewidth, alpha).

None
scatter_kwargs dict

Passed to scatter_locations (e.g., color, size, label).

None
isometry isometry or None

Geometry transform used when mapping locations to 3D points.

None

Returns:

Type Description
The 3D axes used for drawing.
Source code in toric_spines_sim/viz/arbor.py
def plot_morph_and_locations(
    morph: "A.morphology",
    locs: Sequence["A.location"],
    *,
    ax=None,
    line_kwargs: Optional[dict] = None,
    scatter_kwargs: Optional[dict] = None,
    isometry: Optional["A.isometry"] = None,
):
    """Draw morphology and overlay a set of locations on a single 3D axes.

    Parameters
    ----------
    morph : arbor.morphology
    locs : sequence[arbor.location]
    ax : 3D axes or None
    line_kwargs : dict
        Passed to `draw_morphology` (e.g., color, linewidth, alpha).
    scatter_kwargs : dict
        Passed to `scatter_locations` (e.g., color, size, label).
    isometry : arbor.isometry or None
        Geometry transform used when mapping locations to 3D points.

    Returns
    -------
    The 3D axes used for drawing.
    """
    line_kwargs = line_kwargs or {}
    scatter_kwargs = scatter_kwargs or {}

    ax = draw_morphology(morph, ax=ax, **line_kwargs)
    ax = scatter_locations(morph, locs, ax=ax, isometry=isometry, **scatter_kwargs)
    ax.set_xlabel("x (µm)")
    ax.set_ylabel("y (µm)")
    ax.set_zlabel("z (µm)")
    return ax

plotly_morphology_traces(morph, *, color=COLORS['skeleton'], width=2, opacity=1.0, name='morphology')

Return Plotly Scatter3d traces representing the morphology as line segments.

Returns a list of traces, one per segment.

Source code in toric_spines_sim/viz/arbor.py
def plotly_morphology_traces(
    morph: "A.morphology",
    *,
    color: str = COLORS["skeleton"],
    width: float = 2,
    opacity: float = 1.0,
    name: str = "morphology",
):
    """Return Plotly Scatter3d traces representing the morphology as line segments.

    Returns a list of traces, one per segment.
    """
    if go is None:
        raise ImportError(
            "Plotly is not installed. Install with `pip install plotly` to use plotly_* functions."
        )
    morph = _as_morphology(morph)
    traces = []
    for b in range(morph.num_branches):
        segs = morph.branch_segments(b)
        for s in segs:
            p, q = s.prox, s.dist
            traces.append(
                go.Scatter3d(
                    x=[p.x, q.x],
                    y=[p.y, q.y],
                    z=[p.z, q.z],
                    mode="lines",
                    line=dict(color=color, width=width),
                    opacity=opacity,
                    hoverinfo="none",
                    showlegend=False,
                    name=name,
                )
            )
    return traces

plotly_locations_trace(morph, locs, *, isometry=None, color=COLORS['synapse_point'], size=3, name='locations')

Return a Plotly Scatter3d trace for a set of locations mapped to 3D points.

Source code in toric_spines_sim/viz/arbor.py
def plotly_locations_trace(
    morph: "A.morphology",
    locs: Sequence["A.location"],
    *,
    isometry: Optional["A.isometry"] = None,
    color: str = COLORS["synapse_point"],
    size: float = 3,
    name: str = "locations",
):
    """Return a Plotly Scatter3d trace for a set of locations mapped to 3D points."""
    if go is None:
        raise ImportError(
            "Plotly is not installed. Install with `pip install plotly` to use plotly_* functions."
        )
    pts = locations_to_points(morph, locs, isometry=isometry)
    return go.Scatter3d(
        x=[p.x for p in pts],
        y=[p.y for p in pts],
        z=[p.z for p in pts],
        mode="markers",
        marker=dict(size=size, color=color),
        name=name,
    )

plotly_points_trace(points, *, color=COLORS['synapse_point'], size=3, name='points')

Return a Plotly Scatter3d trace for raw 3D points (mpoint or (x,y,z)).

Source code in toric_spines_sim/viz/arbor.py
def plotly_points_trace(
    points: Sequence[object],
    *,
    color: str = COLORS["synapse_point"],
    size: float = 3,
    name: str = "points",
):
    """Return a Plotly Scatter3d trace for raw 3D points (mpoint or (x,y,z))."""
    if go is None:
        raise ImportError(
            "Plotly is not installed. Install with `pip install plotly` to use plotly_* functions."
        )
    xs: List[float] = []
    ys: List[float] = []
    zs: List[float] = []
    for p in points:
        if hasattr(p, "x") and hasattr(p, "y") and hasattr(p, "z"):
            xs.append(float(p.x))
            ys.append(float(p.y))
            zs.append(float(p.z))
        else:
            try:
                x, y, z = p  # type: ignore[misc]
                xs.append(float(x))
                ys.append(float(y))
                zs.append(float(z))
            except Exception:
                continue
    return go.Scatter3d(
        x=xs,
        y=ys,
        z=zs,
        mode="markers",
        marker=dict(size=size, color=color),
        name=name,
    )

plotly_morphology_frusta_trace(morph, *, n_sides=16, color=COLORS['segment'], opacity=1.0, name='morph_frusta', radius_scale=1.0, caps=False)

Return a Plotly Mesh3d trace representing the morphology as frusta.

Each Arbor segment is rendered as a truncated cone between its endpoints using their respective radii. radius_scale multiplies all radii for visualization. Quads are triangulated into two faces for Mesh3d.

Source code in toric_spines_sim/viz/arbor.py
def plotly_morphology_frusta_trace(
    morph: "A.morphology",
    *,
    n_sides: int = 16,
    color: str = COLORS["segment"],
    opacity: float = 1.0,
    name: str = "morph_frusta",
    radius_scale: float = 1.0,
    caps: bool = False,
):
    """Return a Plotly Mesh3d trace representing the morphology as frusta.

    Each Arbor segment is rendered as a truncated cone between its endpoints using
    their respective radii. `radius_scale` multiplies all radii for visualization.
    Quads are triangulated into two faces for Mesh3d.
    """
    if go is None:
        raise ImportError(
            "Plotly is not installed. Install with `pip install plotly` to use plotly_* functions."
        )
    morph = _as_morphology(morph)

    xs: List[float] = []
    ys: List[float] = []
    zs: List[float] = []
    ii: List[int] = []
    jj: List[int] = []
    kk: List[int] = []

    # Precompute circle angles
    twopi = 2.0 * math.pi
    angles = [twopi * k / n_sides for k in range(n_sides)]

    def add_vertex(x: float, y: float, z: float) -> int:
        xs.append(x)
        ys.append(y)
        zs.append(z)
        return len(xs) - 1

    for b in range(morph.num_branches):
        for s in morph.branch_segments(b):
            p, q = s.prox, s.dist  # mpoint endpoints
            r1 = max(1e-3, radius_scale * _mpoint_radius(p))
            r2 = max(1e-3, radius_scale * _mpoint_radius(q))

            dx, dy, dz = (q.x - p.x), (q.y - p.y), (q.z - p.z)
            _, a, bvec = _orthonormal_frame_from_axis(dx, dy, dz)
            ax_, ay_, az_ = a
            bx_, by_, bz_ = bvec

            # Build rings at each end; store their vertex indices
            ring1_idx: List[int] = []
            ring2_idx: List[int] = []
            for th in angles:
                ct = math.cos(th)
                st = math.sin(th)
                rx1 = r1 * (ct * ax_ + st * bx_)
                ry1 = r1 * (ct * ay_ + st * by_)
                rz1 = r1 * (ct * az_ + st * bz_)
                rx2 = r2 * (ct * ax_ + st * bx_)
                ry2 = r2 * (ct * ay_ + st * by_)
                rz2 = r2 * (ct * az_ + st * bz_)
                ring1_idx.append(add_vertex(p.x + rx1, p.y + ry1, p.z + rz1))
                ring2_idx.append(add_vertex(q.x + rx2, q.y + ry2, q.z + rz2))

            # Connect rings with triangles for each quad
            for i in range(n_sides):
                j = (i + 1) % n_sides
                v00 = ring1_idx[i]
                v01 = ring2_idx[i]
                v11 = ring2_idx[j]
                v10 = ring1_idx[j]
                # Two triangles: (v00, v01, v11) and (v00, v11, v10)
                ii.append(v00)
                jj.append(v01)
                kk.append(v11)
                ii.append(v00)
                jj.append(v11)
                kk.append(v10)

            if caps:
                # Optional end-caps as triangle fans
                c1 = add_vertex(p.x, p.y, p.z)
                c2 = add_vertex(q.x, q.y, q.z)
                for i in range(1, n_sides - 1):
                    ii.append(c1)
                    jj.append(ring1_idx[i])
                    kk.append(ring1_idx[i + 1])
                    ii.append(c2)
                    jj.append(ring2_idx[i + 1])
                    kk.append(ring2_idx[i])

    return go.Mesh3d(
        x=xs, y=ys, z=zs, i=ii, j=jj, k=kk, color=color, opacity=opacity, name=name
    )

arbor_samples_to_arrays(samples)

Convert Arbor samples to plain arrays.

Parameters:

Name Type Description Default
samples sequence

Typically the return value from sim.samples(handle) which is a list of (array Nx2, metadata) pairs, where each array row is [time, value].

required

Returns:

Type Description
list of (t, y, meta)

t and y are lists of floats.

Source code in toric_spines_sim/viz/plotting.py
def arbor_samples_to_arrays(samples: Sequence[Tuple[Iterable[Sequence[float]], Any]]):
    """Convert Arbor samples to plain arrays.

    Parameters
    ----------
    samples : sequence
        Typically the return value from `sim.samples(handle)` which is a list
        of (array Nx2, metadata) pairs, where each array row is [time, value].

    Returns
    -------
    list of (t, y, meta)
        t and y are lists of floats.
    """
    out = []
    for data, meta in samples:
        # data is typically a numpy-like Nx2 array; convert robustly
        t = [row[0] for row in data]
        y = [row[1] for row in data]
        out.append((t, y, meta))
    return out

create_hypergrid_dash_app(data_dir)

Create a Dash app for hypergrid result exploration.

Source code in toric_spines_sim/viz/hypergrid_dash.py
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
def create_hypergrid_dash_app(data_dir: str | Path) -> Dash:
    """Create a Dash app for hypergrid result exploration."""
    runs = load_hypergrid_runs(data_dir)
    by_id = {run.run_id: run for run in runs}
    varied = infer_varied_parameters(runs)
    run_options = _run_options(runs, varied)
    initial_run = run_options[0]["value"]
    initial_theme = "light"
    style_map = base_layout_styles(get_theme_tokens(initial_theme))
    app_style = style_map["app_style"]
    card_style = style_map["card_style"]
    card_title_style = style_map["card_title_style"]

    app = Dash(__name__)
    theme_css = f"{dropdown_theme_css('light')}\n{dropdown_theme_css('dark')}"
    app.index_string = app.index_string.replace(
        "</head>", f"<style>{theme_css}</style></head>"
    )
    app.layout = html.Div(
        [
            html.H2("Hypergrid Results Explorer"),
            html.Div([html.B("Data directory: "), html.Code(str(Path(data_dir)))]),
            html.Div(
                [
                    html.H4("View mode", style=card_title_style),
                    dcc.RadioItems(
                        id="view-mode",
                        options=[
                            {"label": " Single run", "value": "single"},
                            {"label": " Compare two runs", "value": "compare"},
                        ],
                        value="single",
                        inline=True,
                        className="themed-radio",
                    ),
                ],
                id="view-mode-card",
                style=card_style,
            ),
            html.Div(
                [
                    html.H4("Parameter filters and stepping", style=card_title_style),
                    html.Div(
                        "Use dropdowns or Prev/Next steppers to move through active parameter values.",
                        style=style_map["helper_text_style"],
                    ),
                    html.Div(
                        [
                            html.Div(
                                [
                                    html.Label(key, className="themed-section-label"),
                                    dcc.Dropdown(
                                        id={"type": "param-filter", "param": key},
                                        options=[{"label": str(v), "value": v} for v in values],
                                        value=[],
                                        multi=True,
                                        placeholder=f"All {key}",
                                        className="themed-dropdown",
                                    ),
                                    html.Div(
                                        [
                                            html.Button(
                                                "Prev",
                                                id={"type": "param-step-prev", "param": key},
                                                n_clicks=0,
                                                style=style_map["button_style"],
                                                className="themed-step-button",
                                            ),
                                            html.Div(
                                                id={"type": "param-step-label", "param": key},
                                                style=style_map["stepper_label_style"],
                                            ),
                                            html.Button(
                                                "Next",
                                                id={"type": "param-step-next", "param": key},
                                                n_clicks=0,
                                                style=style_map["button_style"],
                                                className="themed-step-button",
                                            ),
                                        ],
                                        style={
                                            "display": "flex",
                                            "gap": "8px",
                                            "alignItems": "center",
                                            "marginTop": "8px",
                                        },
                                    ),
                                ],
                                style={"minWidth": "280px", "flex": "1"},
                            )
                            for key, values in varied.items()
                        ],
                        style={"display": "flex", "gap": "12px", "flexWrap": "wrap"},
                    ),
                ],
                id="filters-card",
                style=card_style,
            ),
            html.Div(
                [
                    html.Div(
                        [
                            html.H4("Single-run selection", style=card_title_style),
                            dcc.Dropdown(id="run-single", options=run_options, value=initial_run, className="themed-dropdown"),
                            html.Label("Voltage traces", className="themed-section-label"),
                            dcc.Dropdown(id="single-columns", multi=True, placeholder="Default subset", className="themed-dropdown"),
                            html.Label("Event streams", className="themed-section-label"),
                            dcc.Dropdown(id="single-streams", multi=True, placeholder="All streams", className="themed-dropdown"),
                        ],
                        id="single-controls",
                        style=card_style,
                    ),
                    html.Div(
                        [
                            html.H4("Compare selection", style=card_title_style),
                            html.Label("Run A", className="themed-section-label"),
                            dcc.Dropdown(id="run-a", options=run_options, value=initial_run, className="themed-dropdown"),
                            html.Label("Run B", className="themed-section-label"),
                            dcc.Dropdown(
                                id="run-b",
                                options=run_options,
                                value=run_options[min(1, len(run_options) - 1)]["value"],
                                className="themed-dropdown",
                            ),
                        ],
                        id="compare-controls",
                        style=card_style,
                    ),
                ],
                id="selection-wrapper",
            ),
            html.Div(id="empty-state", style=style_map["empty_state_style"]),
            html.Div(
                [
                    dcc.Graph(id="single-voltage"),
                    dcc.Graph(id="single-events"),
                    html.Label("Rate curves", className="themed-section-label"),
                    dcc.Dropdown(
                        id="single-rate-axons",
                        multi=True,
                        placeholder="All axons",
                        className="themed-dropdown",
                    ),
                    dcc.Graph(id="single-rate"),
                ],
                id="single-graphs-card",
                style=card_style,
            ),
            html.Div(
                [
                    html.Div(
                        [
                            dcc.Graph(id="compare-voltage-a"),
                            dcc.Graph(id="compare-events-a"),
                            html.Label("Rate curves", className="themed-section-label"),
                            dcc.Dropdown(
                                id="compare-rate-axons-a",
                                multi=True,
                                placeholder="All axons",
                                className="themed-dropdown",
                            ),
                            dcc.Graph(id="compare-rate-a"),
                        ],
                        style={"display": "flex", "flexDirection": "column", "gap": "12px"},
                    ),
                    html.Div(
                        [
                            dcc.Graph(id="compare-voltage-b"),
                            dcc.Graph(id="compare-events-b"),
                            html.Label("Rate curves", className="themed-section-label"),
                            dcc.Dropdown(
                                id="compare-rate-axons-b",
                                multi=True,
                                placeholder="All axons",
                                className="themed-dropdown",
                            ),
                            dcc.Graph(id="compare-rate-b"),
                        ],
                        style={"display": "flex", "flexDirection": "column", "gap": "12px"},
                    ),
                ],
                id="compare-graphs",
                style={
                    "display": "grid",
                    "gridTemplateColumns": "1fr 1fr",
                    "gap": "12px",
                    **card_style,
                },
            ),
            html.Div(
                [
                    html.H4("Metadata", style=card_title_style),
                    html.Div(
                        [
                            html.Div(
                                [html.H5("Run (single / A)"), html.Div(id="meta-a")],
                                style={"flex": 1},
                            ),
                            html.Div([html.H5("Run B"), html.Div(id="meta-b")], style={"flex": 1}),
                        ],
                        style={"display": "flex", "gap": "12px"},
                    ),
                ],
                id="metadata-card",
                style=card_style,
            ),
            dcc.Store(id="filtered-run-ids"),
            dcc.Store(id="theme-mode", data=initial_theme),
        ],
        id="app-root",
        style=app_style,
        className=f"theme-{initial_theme}",
    )

    @app.callback(
        Output("app-root", "style"),
        Output("app-root", "className"),
        Output("view-mode-card", "style"),
        Output("filters-card", "style"),
        Output("single-controls", "style"),
        Output("compare-controls", "style"),
        Output("single-graphs-card", "style"),
        Output("compare-graphs", "style"),
        Output("metadata-card", "style"),
        Output("empty-state", "style"),
        Output({"type": "param-step-label", "param": ALL}, "style"),
        Output({"type": "param-step-prev", "param": ALL}, "style"),
        Output({"type": "param-step-next", "param": ALL}, "style"),
        Input("theme-mode", "data"),
        Input("view-mode", "value"),
    )
    def apply_theme(theme_mode, view_mode):
        themed_style_map = base_layout_styles(get_theme_tokens(theme_mode))
        themed_card = themed_style_map["card_style"]
        show_single = view_mode == "single"
        single_style = {**themed_card, "display": "block" if show_single else "none"}
        compare_style = {**themed_card, "display": "block" if not show_single else "none"}
        compare_graph_style = (
            {**themed_card, "display": "grid", "gridTemplateColumns": "1fr 1fr", "gap": "12px"}
            if not show_single
            else {**themed_card, "display": "none"}
        )
        stepper_styles = [dict(themed_style_map["stepper_label_style"]) for _ in varied.keys()]
        button_styles = [dict(themed_style_map["button_style"]) for _ in varied.keys()]
        return (
            themed_style_map["app_style"],
            f"theme-{(theme_mode or 'light').lower()}",
            themed_card,
            themed_card,
            single_style,
            compare_style,
            single_style,
            compare_graph_style,
            themed_card,
            themed_style_map["empty_state_style"],
            stepper_styles,
            button_styles,
            button_styles,
        )

    @app.callback(
        Output({"type": "param-filter", "param": ALL}, "value"),
        Input({"type": "param-step-prev", "param": ALL}, "n_clicks"),
        Input({"type": "param-step-next", "param": ALL}, "n_clicks"),
        State({"type": "param-filter", "param": ALL}, "value"),
        State({"type": "param-filter", "param": ALL}, "id"),
    )
    def step_parameter_values(_prev_clicks, _next_clicks, current_values, filter_ids):
        if not filter_ids:
            return []
        if not ctx.triggered_id or not isinstance(ctx.triggered_id, dict):
            return current_values

        triggered = ctx.triggered_id
        key = triggered.get("param")
        direction = "prev" if triggered.get("type") == "param-step-prev" else "next"

        output_values = list(current_values)
        key_to_index = {item["param"]: idx for idx, item in enumerate(filter_ids)}
        idx = key_to_index.get(key)
        if idx is None:
            return output_values

        allowed_values = varied.get(key, [])
        if not allowed_values:
            return output_values

        selected = output_values[idx] or []
        if len(selected) == 1 and selected[0] in allowed_values:
            current_index = allowed_values.index(selected[0])
        else:
            current_index = 0

        if direction == "prev":
            next_index = max(0, current_index - 1)
        else:
            next_index = min(len(allowed_values) - 1, current_index + 1)

        output_values[idx] = [allowed_values[next_index]]
        return output_values

    @app.callback(
        Output({"type": "param-step-label", "param": ALL}, "children"),
        Output({"type": "param-step-prev", "param": ALL}, "disabled"),
        Output({"type": "param-step-next", "param": ALL}, "disabled"),
        Input({"type": "param-filter", "param": ALL}, "value"),
        State({"type": "param-filter", "param": ALL}, "id"),
    )
    def update_stepper_labels(values, ids):
        labels: list[str] = []
        prev_disabled: list[bool] = []
        next_disabled: list[bool] = []
        if not ids:
            return labels, prev_disabled, next_disabled

        for item, selected in zip(ids, values):
            key = item["param"]
            allowed_values = varied.get(key, [])
            selected = selected or []
            if len(selected) == 1 and selected[0] in allowed_values:
                current_index = allowed_values.index(selected[0])
                label = f"{key}: {selected[0]}"
            elif len(selected) == 0:
                current_index = 0
                label = f"{key}: All (step at {allowed_values[0]})" if allowed_values else f"{key}: All"
            else:
                current_index = 0
                label = f"{key}: Multiple selected"

            labels.append(label)
            prev_disabled.append(current_index <= 0)
            next_disabled.append(current_index >= len(allowed_values) - 1 if allowed_values else True)

        return labels, prev_disabled, next_disabled

    @app.callback(
        Output("filtered-run-ids", "data"),
        Output("empty-state", "children"),
        Input({"type": "param-filter", "param": ALL}, "value"),
        State({"type": "param-filter", "param": ALL}, "id"),
    )
    def update_filtered_ids(values: list[list[Any]], ids: list[dict[str, str]]):
        filter_values = {item["param"]: (val or []) for item, val in zip(ids, values)}
        filtered = _filter_runs(runs, filter_values)
        if not filtered:
            return [], "No runs match current filters."
        return [run.run_id for run in filtered], ""

    @app.callback(
        Output("run-single", "options"),
        Output("run-a", "options"),
        Output("run-b", "options"),
        Output("run-single", "value"),
        Output("run-a", "value"),
        Output("run-b", "value"),
        Input("filtered-run-ids", "data"),
        State("run-single", "value"),
        State("run-a", "value"),
        State("run-b", "value"),
    )
    def update_run_selectors(filtered_ids, current_single, current_a, current_b):
        valid_runs = [by_id[rid] for rid in filtered_ids] if filtered_ids else []
        options = _run_options(valid_runs, varied)
        if not options:
            return [], [], [], None, None, None

        valid_ids = {opt["value"] for opt in options}

        def keep_or_first(value):
            return value if value in valid_ids else options[0]["value"]

        single = keep_or_first(current_single)
        run_a = keep_or_first(current_a)
        run_b_default = options[min(1, len(options) - 1)]["value"]
        run_b = current_b if current_b in valid_ids else run_b_default
        return options, options, options, single, run_a, run_b

    @app.callback(
        Output("single-columns", "options"),
        Output("single-columns", "value"),
        Output("single-streams", "options"),
        Output("single-streams", "value"),
        Output("single-rate-axons", "options"),
        Output("single-rate-axons", "value"),
        Input("run-single", "value"),
        State("single-columns", "value"),
        State("single-streams", "value"),
        State("single-rate-axons", "value"),
    )
    def update_single_controls(run_id, selected_columns, selected_streams, selected_axons):
        if not run_id:
            return [], [], [], [], [], []
        run = by_id[run_id]
        column_options = [{"label": c, "value": c} for c in run.voltage_columns]
        stream_options = [{"label": e["label"], "value": e["stream_id"]} for e in run.events]
        axon_options = _rate_curve_axon_options(run)
        default_columns = selected_columns or _default_voltage_columns(run, max_traces=20)
        default_streams = selected_streams or []
        default_axons = selected_axons or []
        return (
            column_options,
            default_columns,
            stream_options,
            default_streams,
            axon_options,
            default_axons,
        )

    @app.callback(
        Output("compare-rate-axons-a", "options"),
        Output("compare-rate-axons-a", "value"),
        Output("compare-rate-axons-b", "options"),
        Output("compare-rate-axons-b", "value"),
        Input("run-a", "value"),
        Input("run-b", "value"),
        State("compare-rate-axons-a", "value"),
        State("compare-rate-axons-b", "value"),
    )
    def update_compare_rate_controls(run_a, run_b, selected_a, selected_b):
        options_a = _rate_curve_axon_options(by_id[run_a]) if run_a else []
        options_b = _rate_curve_axon_options(by_id[run_b]) if run_b else []
        return options_a, selected_a or [], options_b, selected_b or []

    @app.callback(
        Output("single-voltage", "figure"),
        Output("single-events", "figure"),
        Output("single-rate", "figure"),
        Output("compare-voltage-a", "figure"),
        Output("compare-events-a", "figure"),
        Output("compare-rate-a", "figure"),
        Output("compare-voltage-b", "figure"),
        Output("compare-events-b", "figure"),
        Output("compare-rate-b", "figure"),
        Output("meta-a", "children"),
        Output("meta-b", "children"),
        Input("view-mode", "value"),
        Input("run-single", "value"),
        Input("single-columns", "value"),
        Input("single-streams", "value"),
        Input("single-rate-axons", "value"),
        Input("run-a", "value"),
        Input("run-b", "value"),
        Input("compare-rate-axons-a", "value"),
        Input("compare-rate-axons-b", "value"),
        Input("theme-mode", "data"),
    )
    def update_figures(
        view_mode,
        run_single,
        columns,
        streams,
        rate_axons,
        run_a,
        run_b,
        rate_axons_a,
        rate_axons_b,
        theme_mode,
    ):
        empty = go.Figure()

        if view_mode == "single":
            if not run_single:
                return empty, empty, empty, empty, empty, empty, empty, empty, empty, "", ""
            run = by_id[run_single]
            voltage_cols = columns or _default_voltage_columns(run, max_traces=20)
            x_range = _voltage_time_range(run, voltage_cols)
            single_v = _voltage_figure(
                run, columns, varied, theme_mode=theme_mode, x_range=x_range
            )
            single_e = _events_figure(run, streams, theme_mode=theme_mode, x_range=x_range)
            single_r = _rate_curves_figure(run, rate_axons, theme_mode=theme_mode)
            current_style_map = base_layout_styles(get_theme_tokens(theme_mode))
            return (
                single_v,
                single_e,
                single_r,
                empty,
                empty,
                empty,
                empty,
                empty,
                empty,
                _metadata_block(run, current_style_map),
                "",
            )

        if not run_a or not run_b:
            return empty, empty, empty, empty, empty, empty, empty, empty, empty, "", ""
        left = by_id[run_a]
        right = by_id[run_b]
        current_style_map = base_layout_styles(get_theme_tokens(theme_mode))
        diff_keys = _differing_parameter_keys(left, right)
        cols_a = _default_voltage_columns(left, max_traces=20)
        cols_b = _default_voltage_columns(right, max_traces=20)
        x_range_a = _voltage_time_range(left, cols_a)
        x_range_b = _voltage_time_range(right, cols_b)
        return (
            empty,
            empty,
            empty,
            _voltage_figure(left, cols_a, varied, theme_mode=theme_mode, x_range=x_range_a),
            _events_figure(left, None, theme_mode=theme_mode, x_range=x_range_a),
            _rate_curves_figure(left, rate_axons_a, theme_mode=theme_mode),
            _voltage_figure(right, cols_b, varied, theme_mode=theme_mode, x_range=x_range_b),
            _events_figure(right, None, theme_mode=theme_mode, x_range=x_range_b),
            _rate_curves_figure(right, rate_axons_b, theme_mode=theme_mode),
            _metadata_block(left, current_style_map, highlight_keys=diff_keys),
            _metadata_block(right, current_style_map, highlight_keys=diff_keys),
        )

    return app

create_simulation_dash_app(data, *, fps=10, title='Simulation Dashboard', theme='light', cache_dir=None, clientside_max_frames=600)

Create a Dash app with synchronized 3D animation, traces, and raster.

Source code in toric_spines_sim/viz/simulation_dash.py
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
def create_simulation_dash_app(
    data: SimulationDashboardData,
    *,
    fps: int = 10,
    title: str = "Simulation Dashboard",
    theme: str = "light",
    cache_dir: str | Path | None = None,
    clientside_max_frames: int = 600,
) -> Dash:
    """Create a Dash app with synchronized 3D animation, traces, and raster."""
    theme_mode = theme.lower()
    style_map = base_layout_styles(get_theme_tokens(theme_mode))
    card_style = style_map["card_style"]
    card_title_style = style_map["card_title_style"]
    button_style = style_map["button_style"]

    n_frames = data.n_frames
    slider_max = max(0, n_frames - 1)
    marks = _slider_marks(data.frame_cache.time_ms)

    playback = load_or_build_playback_cache(
        data,
        theme_mode,
        cache_dir=cache_dir,
        clientside_max_frames=clientside_max_frames,
    )
    use_clientside = playback.clientside_bundle is not None
    playback_fps = fps if use_clientside else min(fps, 10)
    if not use_clientside and fps > playback_fps:
        logger.warning(
            "Using server playback fallback for %d frames; capping FPS "
            "from %d to %d to avoid callback backlog",
            n_frames,
            fps,
            playback_fps,
        )
    interval_ms = max(16, int(1000 / max(1, playback_fps)))

    app = Dash(__name__)
    theme_css = (
        f"{dropdown_theme_css('light')}\n"
        f"{dropdown_theme_css('dark')}\n"
        f"{DASHBOARD_LAYOUT_CSS}"
    )
    app.index_string = app.index_string.replace(
        "</head>", f"<style>{theme_css}</style></head>"
    )

    app.layout = html.Div(
        [
            html.H2(title, className="dashboard-header"),
            html.Div(
                [
                    html.Span(f"T = {data.metadata.get('T_ms', data.t_max_ms):.1f} ms"),
                    html.Span(" · "),
                    html.Span(f"{n_frames} frames"),
                    html.Span(" · "),
                    html.Span(f"{data.metadata.get('n_synapses', 0)} synapses"),
                ],
                style=style_map["helper_text_style"],
            ),
            html.Div(
                [
                    dcc.Graph(
                        id="graph-3d",
                        className="dashboard-graph-3d",
                        config={
                            "displayModeBar": True,
                            "scrollZoom": True,
                            "responsive": True,
                        },
                    ),
                ],
                className="dashboard-panel dashboard-panel-3d",
                style=card_style,
            ),
            html.Div(
                [
                    html.Div(
                        [
                            html.H4("Voltage traces", style=card_title_style),
                            html.Label("Traces", className="themed-section-label"),
                            dcc.Dropdown(
                                id="probe-columns",
                                options=data.trace_options(),
                                value=data.default_probe_columns,
                                multi=True,
                                className="themed-dropdown",
                            ),
                            dcc.Graph(
                                id="graph-voltage",
                                className="dashboard-graph-voltage",
                                config={
                                    "displayModeBar": True,
                                    "responsive": True,
                                },
                            ),
                        ],
                        className="dashboard-panel-lower dashboard-panel-voltage",
                        style=card_style,
                    ),
                    html.Div(
                        [
                            html.H4("Input events", style=card_title_style),
                            dcc.Graph(
                                id="graph-raster",
                                className="dashboard-graph-raster",
                                config={
                                    "displayModeBar": True,
                                    "responsive": True,
                                },
                            ),
                        ],
                        className="dashboard-panel-lower dashboard-panel-raster",
                        style=card_style,
                    ),
                ],
                className="dashboard-plots-grid",
            ),
            html.Div(
                [
                    html.H4("Timeline", style=card_title_style),
                    html.Div(
                        [
                            html.Button("▶ Play", id="play-btn", n_clicks=0, style=button_style),
                            html.Button("⏸ Pause", id="pause-btn", n_clicks=0, style=button_style),
                            html.Button("⏮ Reset", id="reset-btn", n_clicks=0, style=button_style),
                            html.Div(id="time-readout", style={"marginLeft": "8px"}),
                        ],
                        className="dashboard-controls",
                    ),
                    dcc.Slider(
                        id="time-slider",
                        min=0,
                        max=slider_max,
                        step=1,
                        value=0,
                        marks=marks,
                        tooltip={"placement": "top", "always_visible": False},
                    ),
                ],
                className="dashboard-panel dashboard-timeline",
                style=card_style,
            ),
            dcc.Store(id="frame-idx", data=0),
            dcc.Store(id="playing", data=False),
            dcc.Store(id="figures-ready", data=False),
            dcc.Store(id="camera-guard-ready", data=False),
            dcc.Store(id="theme-mode", data=theme_mode),
            dcc.Store(id="frame-bundle", data=playback.clientside_bundle),
            dcc.Store(
                id="voltage-trace-map",
                data=playback.voltage_trace_index_map.get(
                    _probe_cache_key(DEFAULT_PROBE_COLUMNS), {}
                ),
            ),
            dcc.Interval(id="tick", interval=interval_ms, n_intervals=0, disabled=True),
        ],
        id="app-root",
        style=style_map["app_style"],
        className=f"theme-{theme_mode} dashboard-stack",
    )

    # Initial load: send the full static figures once, then flag ready.
    @app.callback(
        Output("graph-3d", "figure"),
        Output("graph-voltage", "figure"),
        Output("graph-raster", "figure"),
        Output("time-readout", "children"),
        Output("figures-ready", "data"),
        Input("frame-idx", "data"),
        State("figures-ready", "data"),
    )
    def initial_load_figures(frame_idx, figures_ready):
        if figures_ready:
            raise PreventUpdate
        idx = max(0, min(int(frame_idx or 0), slider_max))
        current_t = float(data.frame_cache.time_ms[idx])
        readout = html.B(
            f"t = {current_t:.2f} ms  (frame {idx + 1}/{n_frames})"
        )
        return (
            playback.template_3d,
            playback.voltage_at(idx, DEFAULT_PROBE_COLUMNS),
            playback.raster_figure,
            readout,
            True,
        )

    clientside_callback(
        CLIENTSIDE_INSTALL_CAMERA_GUARD,
        Output("camera-guard-ready", "data"),
        Input("figures-ready", "data"),
        prevent_initial_call=True,
    )

    if use_clientside:
        # Immediate pause/reset: disable the interval and stop playing
        # synchronously, before any queued tick callbacks can run.
        clientside_callback(
            CLIENTSIDE_PAUSE_OR_RESET,
            Output("playing", "data", allow_duplicate=True),
            Output("tick", "disabled", allow_duplicate=True),
            Input("pause-btn", "n_clicks"),
            Input("reset-btn", "n_clicks"),
            prevent_initial_call=True,
        )

        # Clientside transport: single source of truth for playback state.
        clientside_callback(
            CLIENTSIDE_PLAYBACK_TRANSPORT,
            Output("frame-idx", "data"),
            Output("playing", "data"),
            Output("tick", "disabled", allow_duplicate=True),
            Input("play-btn", "n_clicks"),
            Input("pause-btn", "n_clicks"),
            Input("reset-btn", "n_clicks"),
            Input("time-slider", "drag_value"),
            Input("tick", "n_intervals"),
            State("playing", "data"),
            State("frame-idx", "data"),
            State("frame-bundle", "data"),
            prevent_initial_call=True,
        )

        # Clientside frame update: restyle/relayout the existing figures.
        clientside_callback(
            CLIENTSIDE_FRAME_UPDATE,
            Output("time-readout", "children"),
            Output("time-slider", "value"),
            Input("frame-idx", "data"),
            State("frame-bundle", "data"),
            State("voltage-trace-map", "data"),
            prevent_initial_call=True,
        )

        # Clientside probe selection: toggle visibility of existing
        # traces and markers.
        clientside_callback(
            CLIENTSIDE_PROBE_VISIBILITY,
            Output("graph-voltage", "figure"),
            Input("probe-columns", "value"),
            State("voltage-trace-map", "data"),
            prevent_initial_call=True,
        )
    else:
        # Server fallback transport: slider sync, interval disable, and the
        # frame-advance state machine.
        clientside_callback(
            CLIENTSIDE_SERVER_SLIDER_SYNC,
            Output("time-slider", "value"),
            Input("frame-idx", "data"),
        )

        clientside_callback(
            CLIENTSIDE_SERVER_DISABLE_TICK,
            Output("tick", "disabled", allow_duplicate=True),
            Input("pause-btn", "n_clicks"),
            Input("reset-btn", "n_clicks"),
            Input("time-slider", "drag_value"),
            prevent_initial_call=True,
        )

        @app.callback(
            Output("frame-idx", "data"),
            Output("playing", "data"),
            Output("tick", "disabled", allow_duplicate=True),
            Input("play-btn", "n_clicks"),
            Input("pause-btn", "n_clicks"),
            Input("reset-btn", "n_clicks"),
            Input("time-slider", "drag_value"),
            Input("time-slider", "value"),
            Input("tick", "n_intervals"),
            State("playing", "data"),
            State("frame-idx", "data"),
            prevent_initial_call=True,
        )
        def transport_control(
            _play_clicks,
            _pause_clicks,
            _reset_clicks,
            slider_drag,
            slider_value,
            _n_intervals,
            playing,
            frame_idx,
        ):
            triggered = ctx.triggered_id
            triggered_prop = (
                ctx.triggered[0]["prop_id"] if ctx.triggered else ""
            )
            frame_idx = int(frame_idx or 0)
            playing = bool(playing)

            if triggered == "play-btn":
                if frame_idx >= slider_max:
                    frame_idx = 0
                return frame_idx, True, False

            if triggered == "pause-btn":
                return frame_idx, False, True

            if triggered == "reset-btn":
                return 0, False, True

            if triggered == "time-slider":
                if triggered_prop == "time-slider.value":
                    # Programmatic slider sync updates value, not drag_value.
                    if playing:
                        raise PreventUpdate
                    target = int(slider_value or 0)
                else:
                    target = int(
                        slider_drag
                        if slider_drag is not None
                        else slider_value or 0
                    )
                if target == frame_idx:
                    raise PreventUpdate
                return target, False, True

            if triggered == "tick":
                if not playing:
                    raise PreventUpdate
                if frame_idx >= slider_max:
                    return slider_max, False, True
                # Advance only; avoid re-asserting playing/disabled.
                return frame_idx + 1, no_update, no_update

            raise PreventUpdate

        # Server fallback: Patch 3D, voltage markers, and raster cursor.
        @app.callback(
            Output("graph-3d", "figure", allow_duplicate=True),
            Output("graph-voltage", "figure", allow_duplicate=True),
            Output("graph-raster", "figure", allow_duplicate=True),
            Output("time-readout", "children", allow_duplicate=True),
            Output("voltage-trace-map", "data"),
            Input("frame-idx", "data"),
            Input("probe-columns", "value"),
            State("figures-ready", "data"),
            prevent_initial_call=True,
        )
        def update_figures_server(frame_idx, probe_columns, figures_ready):
            if not figures_ready:
                raise PreventUpdate
            t0 = time.perf_counter()
            triggered = ctx.triggered_id
            idx = max(0, min(int(frame_idx or 0), slider_max))
            current_t = float(data.frame_cache.time_ms[idx])
            readout = html.B(
                f"t = {current_t:.2f} ms  (frame {idx + 1}/{n_frames})"
            )

            if triggered == "probe-columns":
                fig_v = playback.voltage_at(idx, probe_columns)
                trace_map = playback.voltage_trace_index_map[
                    _probe_cache_key(probe_columns)
                ]
                return (
                    no_update,
                    fig_v,
                    no_update,
                    readout,
                    trace_map,
                )

            fig_3d = playback.patch_3d(idx)
            fig_v = playback.voltage_marker_patch_at(idx, probe_columns)
            fig_r = playback.raster_at(idx)

            elapsed_ms = (time.perf_counter() - t0) * 1000
            if elapsed_ms > 50:
                logger.debug(
                    "Server figure callback took %.1f ms (frame %d)",
                    elapsed_ms,
                    idx,
                )
            return fig_3d, fig_v, fig_r, readout, no_update

    return app

load_or_build_playback_cache(data, theme_mode, cache_dir=None, clientside_max_frames=600)

Load playback cache from disk or build and optionally persist it.

Source code in toric_spines_sim/viz/simulation_dash.py
def load_or_build_playback_cache(
    data: SimulationDashboardData,
    theme_mode: str,
    cache_dir: str | Path | None = None,
    clientside_max_frames: int = 600,
) -> DashboardPlaybackCache:
    """Load playback cache from disk or build and optionally persist it."""
    if cache_dir is not None:
        cache_path = Path(cache_dir) / f"dashboard_{dashboard_cache_key(data, theme_mode)}.pkl"
        loaded = load_playback_cache(cache_path)
        if loaded is not None:
            return loaded
    else:
        cache_path = None

    cache = build_dashboard_playback_cache(
        data, theme_mode, clientside_max_frames=clientside_max_frames
    )
    if cache_path is not None:
        save_playback_cache(cache, cache_path)
    return cache

prepare_simulation_dashboard_data(results, swc_filepath, parameters, *, axon_synapses=None, synapse_colors=None, animation_kwargs=None)

Build dashboard data from simulation results.

Source code in toric_spines_sim/viz/simulation_dash.py
def prepare_simulation_dashboard_data(
    results: SimulationResults,
    swc_filepath: str | Path,
    parameters: dict[str, Any],
    *,
    axon_synapses: list[list[int]] | None = None,
    synapse_colors: Mapping[str, Tuple[str, str]] | None = None,
    animation_kwargs: dict[str, Any] | None = None,
) -> SimulationDashboardData:
    """Build dashboard data from simulation results."""
    swc_path = Path(swc_filepath)
    anim_kwargs = dict(animation_kwargs or {})
    anim_kwargs.setdefault("colorscale", "Plasma")
    anim_kwargs.setdefault("show_synapses", True)
    anim_kwargs.setdefault("show_axes", True)
    anim_kwargs.setdefault("colorbar_title", "Voltage (mV)")

    animation = Animation(results, swc_filepath=swc_path)
    frame_cache = animation.prepare_frame_cache(
        synapse_colors=synapse_colors,
        **anim_kwargs,
    )

    v_spine = results.integrate_voltages_by_tag(
        parameters["spine_tag"], method="average"
    )
    v_sink = results.integrate_voltages_by_tag(
        parameters["sink_tag"], method="average"
    )
    neck_probe = _resolve_neck_probe(results, swc_path)
    v_neck = results.voltage_traces[neck_probe]

    region_traces = {
        "Spine": TraceSeries(
            "Spine",
            v_spine.as_units("ms").index.values,
            v_spine.values,
            **REGION_TRACE_STYLE["Spine"],
        ),
        "Neck": TraceSeries(
            "Neck",
            v_neck.as_units("ms").index.values,
            v_neck.values,
            **REGION_TRACE_STYLE["Neck"],
        ),
        "Sink": TraceSeries(
            "Sink",
            v_sink.as_units("ms").index.values,
            v_sink.values,
            **REGION_TRACE_STYLE["Sink"],
        ),
    }

    probe_traces: dict[str, TraceSeries] = {}
    palette = plt.rcParams["axes.prop_cycle"].by_key()["color"]
    for idx, col in enumerate(results.voltage_traces.columns):
        tsd = results.voltage_traces[col]
        from matplotlib import colors as mcolors

        probe_traces[str(col)] = TraceSeries(
            str(col),
            tsd.as_units("ms").index.values,
            tsd.values,
            color=mcolors.to_hex(palette[idx % len(palette)]),
        )

    syn_to_axon = _synapse_to_axon_map(axon_synapses) if axon_synapses else {}
    raster_streams: list[RasterStream] = []
    for stream_idx in sorted(results.input_events.keys()):
        syn_idx = int(stream_idx)
        ts = results.input_events[syn_idx]
        times = tuple(float(t) for t in ts.as_units("ms").index.values)
        if syn_to_axon:
            color = _axon_color_hex(syn_to_axon.get(syn_idx, syn_idx % 10))
        else:
            color = "#374151"
        raster_streams.append(
            RasterStream(
                syn_idx=syn_idx,
                label=f"syn_{syn_idx}",
                times_ms=times,
                color=color,
            )
        )

    t_max_ms = float(frame_cache.time_ms[-1]) if len(frame_cache.time_ms) else 0.0

    return SimulationDashboardData(
        frame_cache=frame_cache,
        region_traces=region_traces,
        probe_traces=probe_traces,
        raster_streams=raster_streams,
        t_max_ms=t_max_ms,
        metadata={
            "T_ms": parameters.get("T_ms"),
            "n_frames": frame_cache.n_frames,
            "n_synapses": len(raster_streams),
            "swc": str(swc_path),
        },
    )

figure_mesh_and_skeleton(mesh_path, polylines_path, *, mesh_opacity=0.35)

Build a Plotly figure overlaying a mesh with its skeleton polylines.

Source code in toric_spines_sim/viz/mesh_compare.py
def figure_mesh_and_skeleton(
    mesh_path: PathLike,
    polylines_path: PathLike,
    *,
    mesh_opacity: float = 0.35,
):
    """Build a Plotly figure overlaying a mesh with its skeleton polylines."""
    go = _require_plotly()
    mesh_path = Path(mesh_path)
    polylines_path = Path(polylines_path)
    mesh = _load_trimesh(mesh_path)
    polylines = read_polylines_txt(polylines_path)

    fig = go.Figure()
    fig.add_trace(_mesh_surface_trace(mesh, opacity=mesh_opacity, name=mesh_path.name))
    for trace in _polyline_traces(polylines):
        fig.add_trace(trace)
    fig.update_layout(
        **_default_layout(f"{mesh_path.stem}: mesh + skeleton")
    )
    return fig

figure_mesh_and_swc(mesh_path, swc_path, *, mesh_opacity=0.35, show_centroid=False, plot_endcaps=False, cable_opacity=0.8, cable_color='lightblue', sides=16, neck_points=None, neck_point_size=6.0, neck_point_color='#e74c3c')

Build a Plotly figure overlaying a mesh with a fitted cable model.

Uses swctools.plot_model (same path as mascaf demos) so the SWC is rendered as frusta with optional terminal endcaps, not just the centroid graph.

Parameters:

Name Type Description Default
neck_points Sequence[Sequence[float]] | None

Optional XYZ points (same units as the mesh/SWC) drawn as markers, typically pixel-space neckpoints from *_neckpoint.txt.

None
Source code in toric_spines_sim/viz/mesh_compare.py
def figure_mesh_and_swc(
    mesh_path: PathLike,
    swc_path: PathLike,
    *,
    mesh_opacity: float = 0.35,
    show_centroid: bool = False,
    plot_endcaps: bool = False,
    cable_opacity: float = 0.8,
    cable_color: str = "lightblue",
    sides: int = 16,
    neck_points: Sequence[Sequence[float]] | None = None,
    neck_point_size: float = 6.0,
    neck_point_color: str = "#e74c3c",
):
    """Build a Plotly figure overlaying a mesh with a fitted cable model.

    Uses ``swctools.plot_model`` (same path as mascaf demos) so the SWC is
    rendered as frusta with optional terminal endcaps, not just the centroid
    graph.

    Parameters
    ----------
    neck_points
        Optional XYZ points (same units as the mesh/SWC) drawn as markers,
        typically pixel-space neckpoints from ``*_neckpoint.txt``.
    """
    from swctools import SWCModel, plot_model

    mesh_path = Path(mesh_path)
    swc_path = Path(swc_path)
    mesh = _load_trimesh(mesh_path)
    model = SWCModel.from_swc_file(str(swc_path))

    fig = plot_model(
        swc_model=model,
        slider=False,
        title=f"{mesh_path.stem}: mesh + cable",
        show_axes=False,
        show_frusta=True,
        show_centroid=show_centroid,
        plot_endcaps=plot_endcaps,
        opacity=cable_opacity,
        color=cable_color,
        sides=sides,
        width=1200,
        height=900,
    )
    mesh_trace = _mesh_surface_trace(mesh, opacity=mesh_opacity, name=mesh_path.name)
    go = _require_plotly()
    traces = [mesh_trace, *fig.data]
    if neck_points:
        pts = np.asarray(neck_points, dtype=float).reshape(-1, 3)
        traces.append(
            go.Scatter3d(
                x=pts[:, 0],
                y=pts[:, 1],
                z=pts[:, 2],
                mode="markers",
                marker=dict(
                    size=neck_point_size,
                    color=neck_point_color,
                    symbol="diamond",
                    line=dict(width=1, color="#922b21"),
                ),
                name="neckpoint",
                showlegend=True,
            )
        )
    combined = go.Figure(data=traces, layout=fig.layout)
    title = f"{mesh_path.stem}: mesh + cable"
    if neck_points:
        title = f"{mesh_path.stem}: mesh + cable + neckpoint(s)"
    combined.update_layout(
        title=title,
        scene=dict(aspectmode="data"),
    )
    return combined

read_polylines_txt(path)

Parse a pymcfs/mascaf polylines text file.

Each non-empty line is N x1 y1 z1 ... xN yN zN.

Source code in toric_spines_sim/viz/mesh_compare.py
def read_polylines_txt(path: PathLike) -> list[np.ndarray]:
    """Parse a pymcfs/mascaf polylines text file.

    Each non-empty line is ``N x1 y1 z1 ... xN yN zN``.
    """
    polylines: list[np.ndarray] = []
    text = Path(path).read_text()
    for line_number, raw in enumerate(text.splitlines(), start=1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        parts = line.split()
        try:
            n_points = int(parts[0])
        except ValueError as exc:
            raise ValueError(
                f"Invalid polylines line {line_number}: expected leading integer count"
            ) from exc
        coords = parts[1:]
        if len(coords) != 3 * n_points:
            raise ValueError(
                f"Invalid polylines line {line_number}: expected {3 * n_points} "
                f"coordinates for N={n_points}, got {len(coords)}"
            )
        values = np.asarray([float(v) for v in coords], dtype=float).reshape(n_points, 3)
        polylines.append(values)
    return polylines