import pandas as pd
import numpy as np
from .analysis import _df_column_name_validation
from .emulate.operations import Operation, ProcessData
from .emulate._dataframe import insert_positive_start_cumsum
from .emulate.additive_part import AdditivePart
[docs]
def plot_gcode_dataframe(df, x="x", y="y", z="z", tag=None, **kwargs):
"""Generates and displays a 3D plot of a G-code toolpath from a DataFrame.
Args:
df (pd.DataFrame): DataFrame containing processed G-code. It is expected
to have columns for coordinates and an optional 'tags' column.
x (str, optional): The name of the column containing the X-axis data. Defaults to "x".
y (str, optional): The name of the column containing the Y-axis data. Defaults to "y".
z (str, optional): The name of the column containing the Z-axis data. Defaults to "z".
tag (str, optional): A tag to filter the DataFrame. If provided, only rows
where this tag is present in the 'tags' column will be plotted.
Defaults to None.
**kwargs: Additional keyword arguments to pass to `plotly.express.line_3d`.
Returns:
plotly.graph_objects.Figure: The Plotly Figure object.
"""
# NOTE: Using a lazy load import here. Not the best practice, but it keeps plotly as an optional dependency
import plotly.express as px
if tag is None:
df_plot = df
else:
# Vectorized string search: find rows where tag appears in tags column
# Use str.contains() which is much faster than iterrows()
tag_mask = df["tags"].str.contains(tag, na=False, regex=False)
index = df[tag_mask].index
if len(index) == 0:
print("No data was found")
return None
# Make a copy and add 1 so lines are completed
index_2 = index.copy()
index_2 += 1
# Union the index and index_2
index = index.union(index_2)
if index[-1] >= len(df):
index = index[:-1]
df_plot = df.loc[index]
fig = px.line_3d(df_plot, x=x, y=y, z=z, **kwargs)
fig.update_layout(scene_aspectmode="data", showlegend=False)
return fig
[docs]
def plot_operation(
operation: Operation,
command_type: type = None,
**kwargs,
):
""" "Generates and displays a 3D plot from a processed Operation object.
This function visualizes the toolpath defined by an Operation. It can either
plot the entire path as a single continuous line or plot only the segments
that correspond to a specific command type (e.g., `Move`) as
separate polylines.
Args:
operation (Operation): The processed Operation object to be plotted.
command_type (type, optional): A command class used to filter the plot.
If provided, only toolpath segments generated by this command type
(or its subclasses) will be shown. Defaults to None.
**kwargs: Additional keyword arguments to pass to `plotly.express.line_3d`.
Returns:
plotly.graph_objects.Figure: The Plotly Figure object.
Raises:
ValueError: If the operation has not been processed or if the number of
commands does not match the number of process data points.
"""
# NOTE: Using a lazy load import here. Not the best practice, but it keeps plotly as an optional dependency
import plotly.express as px
# Ensure the Operation has been processed
n_commands = len(operation.commands)
n_process_data = len(operation.process_data)
if not n_commands == n_process_data:
raise ValueError(
"The number of commands does not match the number of process data points."
)
elif operation.processed_by_machine is None:
raise ValueError("The operation must be processed by a machine.")
# Create a DataFrame from the operation more efficiently:
# Extract fields directly to arrays instead of calling to_dict() for each record
elapsed_times = np.array([data.elapsed_time for data in operation.process_data])
locations = np.array(
[
data.location if data.location is not None else (np.nan, np.nan, np.nan)
for data in operation.process_data
]
)
tool_directions = np.array(
[
(
data.tool_direction
if data.tool_direction is not None
else (np.nan, np.nan, np.nan)
)
for data in operation.process_data
]
)
relative_movements = np.array(
[
(
data.relative_movement
if data.relative_movement is not None
else (np.nan, np.nan, np.nan)
)
for data in operation.process_data
]
)
distances = np.array([data.distance for data in operation.process_data])
feed_rates = np.array([data.feed_rate for data in operation.process_data])
# Build DataFrame from pre-materialized arrays
df = pd.DataFrame(
{
"elapsed_time": elapsed_times,
"location": [tuple(loc) for loc in locations],
"tool_direction": [tuple(td) for td in tool_directions],
"relative_movement": [tuple(rm) for rm in relative_movements],
"distance": distances,
"feed_rate": feed_rates,
}
)
df[["x", "y", "z"]] = pd.DataFrame(locations, index=df.index)
if command_type is not None:
df["cmd_match"] = [
int(isinstance(cmd, command_type)) for cmd in operation.commands
]
df = insert_positive_start_cumsum(df, "cmd_match", "line")
is_start_point = (df["cmd_match"] == 0) & (df["line"].shift(-1) > 0)
df.loc[is_start_point, "line"] = df["line"].shift(-1)[is_start_point]
else:
df["line"] = 0
fig = px.line_3d(
df,
x="x",
y="y",
z="z",
title=f'{operation.name}{f": {command_type.__name__} Commands" if command_type is not None else ""}',
line_group="line", # This tells Plotly to draw a separate line for each unique line value
**kwargs,
)
fig.update_layout(scene_aspectmode="data", showlegend=False)
return fig
[docs]
def plot_event_series(
event_series: pd.DataFrame, color_by="e", colorscale="Viridis", **kwargs
):
"""Creates a 3D plot of an Event Series colored by a continuous scalar value.
Args:
event_series (pd.DataFrame): DataFrame containing the event series data. Must
include columns for 'x', 'y', 'z', 'e', 'time'.
color_by (str, optional): The name of the column used to color the line.
Defaults to "e".
colorscale (str, optional): The name of the Plotly colorscale to use for
the gradient. Defaults to "Viridis".
show (bool, optional): If True, the plot is displayed immediately. Else return the figure.
Defaults to True.
**kwargs: Additional keyword arguments to pass to `fig.update_layout`.
Returns:
plotly.graph_objects.Figure: The Plotly Figure object.
"""
import plotly.express as px
import plotly.graph_objects as go
_df_column_name_validation(event_series, ("x", "y", "z", "e", "time"))
df = event_series.copy()
colorbar_title = color_by.capitalize()
fig = go.Figure(
data=[
go.Scatter3d(
x=df["x"],
y=df["y"],
z=df["z"],
mode="lines", # This tells Scatter3d to connect the points
line=dict(
color=df[color_by],
colorscale=colorscale,
width=6,
showscale=True,
colorbar=dict(title=colorbar_title),
),
**kwargs,
)
]
)
fig.update_layout(scene_aspectmode="data")
return fig
[docs]
def plot_additive_part(
part: AdditivePart, layer_range=None, layer_index=None, **kwargs
):
"""Generates and displays a 3D plot from a AdditivePart object.
This function visualizes the depoisition toolpath defined in an AdditivePart for a provided
range of layers, or a specified layer index.
Args:
part (AdditivePart): The AdditivePart object to be plotted.
layer_range (tuple, optional): A range of layer indices to plot.
layer_index (int, optional): A specified layer index to plot. Takes precidence over layer_range.
**kwargs: Additional keyword arguments to pass to `plotly.express.line_3d`.
Returns:
plotly.graph_objects.Figure: The Plotly Figure object.
"""
import plotly.express as px
import logging
if layer_index is not None:
layer_index = int(layer_index)
layer_range = (layer_index, layer_index + 1)
layer_range = part._validate_layer_range(layer_range)
all_points = []
path_group = []
filament_idx = 1
for layer in part.generate_layers(layer_range):
for filament in layer.filaments:
locs = filament.locations
if len(locs) == 0:
continue
all_points.append(locs)
path_group.extend([filament_idx] * len(locs))
filament_idx += 1
if not all_points:
logging.warning("No deposition paths found for the specified layer range.")
return None
all_points = np.vstack(all_points)
df = pd.DataFrame(all_points, columns=["x", "y", "z"])
df["filament"] = path_group
df["filament"] = df["filament"].astype(str)
fig = px.line_3d(
df,
x="x",
y="y",
z="z",
line_group="filament", # This breaks the line into separate groups
title=f"Additive Part - Layers ({layer_range[0]}, {layer_range[1]})",
**kwargs,
)
fig.update_layout(scene_aspectmode="data")
return fig