"""Abstraction of model in point cloud."""
from __future__ import annotations
from functools import cached_property, lru_cache, reduce
from statistics import mean
from typing import Dict, List, Optional, Tuple
from panda3d.core import LVector2d, LVector3d
from layerview.gcode import commands
from layerview.gcode.gcode import Gcode
from layerview.simulation.machine import Machine
from layerview.visualization.point_cloud.boundaries import BoundingBox3D
from layerview.visualization.point_cloud.errors import (
LateEffectorDescentError,
PostPrimingDescentError,
)
from layerview.visualization.point_cloud.interpolation import CircularArcInterpolator
from layerview.visualization.point_cloud.layer import Layer
from layerview.visualization.point_cloud.path import Path
[docs]class Model:
"""Represents a point cloud model."""
def __init__(
self,
layer_to_z: Dict[Layer, float],
nozzle_diam: float,
priming_layer: Optional[Layer] = None,
priming_layer_z: Optional[float] = None,
):
"""
Parameters
----------
layer_to_z : Dict[Layer, float]
Dictionary mapping each layer to a Z position.
nozzle_diam : float
Nozzle diameter.
priming_layer : Optional[Layer]
Priming layer.
priming_layer_z : Optional[float]
Z position of priming layer.
"""
self._layer_to_z: Dict[Layer, float] = layer_to_z
self.index_to_layer: Dict[int, Layer] = {
index + 1: layer
for index, layer in enumerate(
[k for k, v in sorted(layer_to_z.items(), key=lambda item: item[1])]
)
}
self._nozzle_diam: float = nozzle_diam
self._priming_layer_z: Optional[float] = priming_layer_z
self._priming_layer: Optional[Layer] = priming_layer
[docs] def get_layer_z(self, index: int) -> float:
"""Return Z position of layer at specified index.
Parameters
----------
index : int
Index of layer to return the Z position for.
Returns
-------
float
Z position of layer at specified index.
"""
return self._layer_to_z[self.index_to_layer[index]]
[docs] def get_layer_height(self, index: int) -> float:
"""Return height (top-bottom) of layer at specified index.
The calculated height is rounded to 3 decimal places.
Parameters
----------
index : int
Index of layer to return the height for.
Returns
-------
float
Height of layer at specified index.
"""
target_layer_z = self._layer_to_z[self.index_to_layer[index]]
if index == 1:
return target_layer_z
diff = (
self._layer_to_z[self.index_to_layer[index]]
- self._layer_to_z[self.index_to_layer[index - 1]]
)
# Round to 3 decimal places
return round(diff, 3)
[docs] def get_boundaries(self, with_priming: bool) -> BoundingBox3D:
"""Return this Model's boundaries.
Parameters
----------
with_priming : bool
If True, the priming layer is taken into account (if present).
Otherwise, the priming layer is ignored.
Returns
-------
BoundingBox3D
This Model's boundaries.
"""
if with_priming:
return self._boundaries_with_priming
return self._boundaries_without_priming
@cached_property
def _boundaries_with_priming(self) -> BoundingBox3D:
"""Return this Model's boundaries with the priming layer."""
if not self._priming_layer:
return self._boundaries_without_priming
# Calc new boundaries with priming layer in mind
point_min = self._boundaries_without_priming.point_min.fmin(
LVector3d(self._priming_layer.boundaries.point_min, 0)
)
point_max = self._boundaries_without_priming.point_max.fmax(
LVector3d(self._priming_layer.boundaries.point_max, 0)
)
return BoundingBox3D(point_min=point_min, point_max=point_max)
@cached_property
def _boundaries_without_priming(self) -> BoundingBox3D:
"""Return this Model's boundaries without the priming layer."""
if self.index_to_layer:
layer_points_min, layer_points_max = zip(
*[
(layer.boundaries.point_min, layer.boundaries.point_max)
for layer in self._layer_to_z.keys()
]
)
layers_min = reduce(lambda a, b: a.fmin(b), layer_points_min)
layers_max = reduce(lambda a, b: a.fmax(b), layer_points_max)
point_min = LVector3d(
x=layers_min.x,
y=layers_min.y,
z=0,
)
point_max = LVector3d(
x=layers_max.x,
y=layers_max.y,
z=max(self._layer_to_z.values()),
)
return BoundingBox3D(point_min=point_min, point_max=point_max)
# No layers
return BoundingBox3D.null_object()
@cached_property
def info(self) -> ModelInfo:
"""Return ModelInfo for this Model."""
return ModelInfo.from_model(model=self)
@property
def priming_layer(self) -> Optional[Layer]:
"""Return priming layer for this Model."""
return self._priming_layer
@property
def nozzle_diam(self) -> float:
"""Return nozzle diameter for this Model."""
return self._nozzle_diam
@property
def priming_layer_z(self) -> Optional[float]:
"""Return priming layer Z position for this Model."""
return self._priming_layer_z
[docs]class LayerInfo:
"""Represents information about a layer."""
def __init__(self):
self.temperature_min: Optional[float] = None
self.temperature_max: Optional[float] = None
self.feedrate_min: Optional[float] = None
self.feedrate_max: Optional[float] = None
[docs]class ModelInfo:
"""Represents information about a model."""
def __init__(self):
self._index_to_layer_info: Dict[int, LayerInfo] = {}
self._index_to_z: Dict[int, float] = {}
self.boundaries: Optional[BoundingBox3D] = None
self.boundaries_without_priming: Optional[BoundingBox3D] = None
[docs] @staticmethod
def from_model(model: Model) -> ModelInfo:
"""Builds ModelInfo from specified Model instance.
Parameters
----------
model : Model
Model for which the ModelInfo is built.
Returns
-------
ModelInfo
Built ModelInfo instance.
"""
info = ModelInfo()
# Fill layer info
for index, layer in model.index_to_layer.items():
layer_info = LayerInfo()
layer_info.temperature_min = layer.temperature_min
layer_info.temperature_max = layer.temperature_max
layer_info.feedrate_min = layer.feedrate_min
layer_info.feedrate_max = layer.feedrate_max
info._index_to_layer_info[index] = layer_info
# Fill model info
info.boundaries = model.get_boundaries(with_priming=True)
info.boundaries_without_priming = model.get_boundaries(with_priming=False)
info._index_to_z = {
index: model.get_layer_z(index) for index in model.index_to_layer.keys()
}
return info
@property
def index_to_layer_info(self) -> Dict[int, LayerInfo]:
"""Return dict that maps layer index to corresponding LayerInfo instance.
Returns
-------
LayerInfo
Maps layer index to corresponding LayerInfo instance.
"""
return self._index_to_layer_info
[docs] def get_layer_info(self, index: int) -> LayerInfo:
"""Return LayerInfo for layer at specified index.
Parameters
----------
index : int
Index of layer to return the LayerInfo for.
Returns
-------
LayerInfo
LayerInfo for layer at specified index.
"""
return self._index_to_layer_info[index]
[docs] def get_layer_z(self, index: int) -> float:
"""Return Z position of layer at specified index.
Parameters
----------
index : int
Index of layer to return the Z position for.
Returns
-------
float
Z position of layer at specified index.
"""
return self._index_to_z[index]
[docs] def get_layer_height(self, index: int) -> float:
"""Return height (aka thickness) of layer at specified index.
Parameters
----------
index : int
Index of layer to return the height for.
Returns
-------
float
Height of layer at specified index.
"""
if index == 1:
return self._index_to_z[index]
return self._index_to_z[index] - self._index_to_z[index - 1]
@property
def layer_count(self) -> int:
"""Return total layer count."""
return len(self._index_to_layer_info)
@property
def layer_num_min(self) -> int:
"""Return minimum layer number (index)."""
return min(self._index_to_layer_info.keys())
@property
def layer_num_max(self) -> int:
"""Return maximum layer number (index)."""
return max(self._index_to_layer_info.keys())
@property
def layer_range(self) -> Tuple[int, int]:
"""Return available layer number (index) range, inclusive."""
return self.layer_num_min, self.layer_num_max
@property
def feedrate_min(self) -> float:
"""Return minimum feedrate."""
return min(
[
layer_info.feedrate_min
for layer_info in self._index_to_layer_info.values()
]
)
@property
def feedrate_max(self) -> float:
"""Return maximum feedrate."""
return max(
[
layer_info.feedrate_max
for layer_info in self._index_to_layer_info.values()
]
)
@property
def temperature_min(self) -> float:
"""Return maximum nozzle temperature."""
return min(
[
layer_info.temperature_min
for layer_info in self._index_to_layer_info.values()
]
)
@property
def temperature_max(self) -> float:
"""Return maximum nozzle temperature."""
return max(
[
layer_info.temperature_max
for layer_info in self._index_to_layer_info.values()
]
)
@property
def layer_height_min(self) -> float:
"""Return minimum layer height (aka thickness)."""
return min(self._heights)
@property
def layer_height_max(self) -> float:
"""Return maximum layer height (aka thickness)."""
return max(self._heights)
@property
def _heights(self) -> List[float]:
"""Return list of layer heights (aka thicknesses)."""
return [
self.get_layer_height(index) for index in self._index_to_layer_info.keys()
]
[docs]class ModelBuilder:
"""Point cloud model builder."""
def __init__(self):
self.machine = Machine(skip_unknown=True)
self.z_to_layer: Dict[float, Layer] = {}
self._last_layer_z: float = 0
self._is_after_post_priming_descent: bool = False
self.priming_layer: Optional[Layer] = None
self.priming_layer_z: Optional[float] = None
self.layer_to_feedrates: Dict[Layer, List[float]] = {}
self.layer_to_temperatures: Dict[Layer, List[float]] = {}
[docs] @staticmethod
def build_model(gcode: Gcode, nozzle_diam: float) -> Model:
"""Build 3D model, based on the provided Gcode and nozzle diameter.
Parameters
----------
gcode : Gcode
Gcode object to generate the point_cloud model from.
nozzle_diam : float
Nozzle diameter.
Returns
-------
model : Model
Generated point_cloud Model.
"""
builder = ModelBuilder()
for command in gcode:
builder.machine.handle_command(command=command)
if isinstance(command, commands.Move):
builder._handle_move(command)
for path in [
path for layer in builder.z_to_layer.values() for path in layer.paths
]:
path.add_padding(length=nozzle_diam / 2)
# Calc and set layer statistics
for layer in builder.z_to_layer.values():
temperatures = builder.layer_to_temperatures[layer]
layer.temperature_min = min(temperatures)
layer.temperature_max = max(temperatures)
layer.temperature_avg = mean(temperatures)
feedrates = builder.layer_to_feedrates[layer]
layer.feedrate_min = min(feedrates)
layer.feedrate_max = max(feedrates)
layer.feedrate_avg = mean(temperatures)
model = Model(
layer_to_z={layer: z for z, layer in builder.z_to_layer.items()},
nozzle_diam=nozzle_diam,
priming_layer=builder.priming_layer,
priming_layer_z=builder.priming_layer_z,
)
return model
[docs] def handle_command(self, command: commands.Command):
"""Handle command.
Parameters
----------
command : commands.Command
Command to handle.
"""
self.machine.handle_command(command)
if isinstance(command, commands.Move):
self._handle_move(command)
[docs] def _handle_move(self, command: commands.Move):
"""Handle Move command.
Parameters
----------
command : commands.Move
Move command to handle.
Raises
------
TypeError
If `command` is not a Move instance.
"""
# Get position
pos_prev = self.machine.state_previous.position_abs
pos_cur = self.machine.state_current.position_abs
if not isinstance(command, commands.Move):
raise TypeError(
f"Expected instance of {commands.Move}, got {type(command)}."
)
# Check if command is a layer printing move
if (
# Stayed on layer during move?
pos_prev.z == pos_cur.z
# Extruder value increased?
and self.machine.state_current.extruder_abs
> self.machine.state_previous.extruder_abs
# Provided X or Y value?
and any([command.x, command.y])
):
# Add points
target_layer_z = pos_cur.z
target_layer = self.get_layer_at_z(layer_z=target_layer_z)
self._last_layer_z = target_layer_z
source = pos_prev.xy
destination = pos_cur.xy
if isinstance(command, commands.LineMove):
self.add_segment_to_layer(
layer=target_layer, source=source, destination=destination
)
elif isinstance(command, commands.ArcMove):
pivot = LVector2d(command.i, command.j)
is_clockwise = isinstance(command, commands.G2)
points_interpolated = CircularArcInterpolator.interpolate(
src=source,
dst=destination,
pivot=pivot,
is_clockwise=is_clockwise,
max_err=0.05,
)
for i in range(len(points_interpolated) - 1):
self.add_segment_to_layer(
layer=target_layer,
source=points_interpolated[i],
destination=points_interpolated[i + 1],
)
# Save feedrate and temperature
self.layer_to_temperatures[target_layer].append(
self.machine.state_current.temp_extruder
)
self.layer_to_feedrates[target_layer].append(
self.machine.state_current.feedrate
)
[docs] @lru_cache(maxsize=128)
def get_layer_at_z(self, layer_z: float) -> Layer:
"""Return layer at specified Z position.
Parameters
----------
layer_z : float
Z position of layer to return.
Returns
-------
Layer
Layer at specified Z position.
Raises
------
PostPrimingDescentError
If a post priming effector descent occurs.
LateEffectorDescentError
If a late effector descent occurs.
"""
target_layer = self.z_to_layer.get(layer_z)
if not target_layer:
if layer_z < self._last_layer_z:
# Z Descent occurred
if self._is_after_post_priming_descent:
# Is after post priming descent.
raise PostPrimingDescentError(
cur_layer_z=layer_z, prev_layer_z=self._last_layer_z
)
elif len(self.z_to_layer) > 1:
# Is NOT after post priming descent, but descended after layer>1
raise LateEffectorDescentError(
cur_layer_z=layer_z,
prev_layer_z=self._last_layer_z,
prev_layer_count=len(self.z_to_layer),
)
# Set layer at _max_layer_z as priming layer.
self.priming_layer = self.z_to_layer.pop(self._last_layer_z)
self.priming_layer_z = self._last_layer_z
self._is_after_post_priming_descent = True
# Create new layer
target_layer = Layer()
self.z_to_layer[layer_z] = target_layer
# Statistics collection
self.layer_to_temperatures[target_layer] = []
self.layer_to_feedrates[target_layer] = []
return target_layer
[docs] @staticmethod
def add_segment_to_layer(layer: Layer, source: LVector2d, destination: LVector2d):
"""Add segment to specified layer.
If layer does not contain any previous paths or the last path's point is not
equal to the provided source point, a new path is initialized.
Otherwise the destination point is appended to the end of layer's last path.
Parameters
----------
layer : Layer
Layer to add the segment to.
source : LVector2d
Segment's source point.
destination : LVector2d
Segment's destination point.
"""
if not layer.paths or source != layer.paths[-1][-1]:
# Create new path
layer.paths.append(Path(point_first=source, point_second=destination))
else:
# Continue last path
layer.paths[-1].append(destination)