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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 21 additions & 14 deletions examples/2_curved_slicing/ex2_curved_slicing.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,19 @@
from compas_slicer.slicers import InterpolationSlicer
from compas_slicer.visualization import should_visualize, visualize_slicer

DATA_PATH = Path(__file__).parent / 'data_Y_shape'
DATA_PATH = Path(__file__).parent / "data_Y_shape"
OUTPUT_PATH = utils.get_output_directory(DATA_PATH)


def main(visualize: bool = False):
start_time = time.time()

# Load initial_mesh
mesh = Mesh.from_obj(DATA_PATH / 'mesh.obj')
mesh = Mesh.from_obj(DATA_PATH / "mesh.obj")

# Load targets (boundaries)
low_boundary_vs = utils.load_from_json(DATA_PATH, 'boundaryLOW.json')
high_boundary_vs = utils.load_from_json(DATA_PATH, 'boundaryHIGH.json')
low_boundary_vs = utils.load_from_json(DATA_PATH, "boundaryLOW.json")
high_boundary_vs = utils.load_from_json(DATA_PATH, "boundaryHIGH.json")
create_mesh_boundary_attributes(mesh, low_boundary_vs, high_boundary_vs)

avg_layer_height = 2.0
Expand All @@ -39,10 +39,13 @@ def main(visualize: bool = False):

preprocessor = InterpolationSlicingPreprocessor(mesh, config, DATA_PATH)
preprocessor.create_compound_targets()
g_eval = preprocessor.create_gradient_evaluation(norm_filename='gradient_norm.json', g_filename='gradient.json',
target_1=preprocessor.target_LOW,
target_2=preprocessor.target_HIGH)
preprocessor.find_critical_points(g_eval, output_filenames=['minima.json', 'maxima.json', 'saddles.json'])
g_eval = preprocessor.create_gradient_evaluation(
norm_filename="gradient_norm.json",
g_filename="gradient.json",
target_1=preprocessor.target_LOW,
target_2=preprocessor.target_HIGH,
)
preprocessor.find_critical_points(g_eval, output_filenames=["minima.json", "maxima.json", "saddles.json"])

# Slicing
slicer = InterpolationSlicer(mesh, preprocessor, config)
Expand All @@ -51,7 +54,7 @@ def main(visualize: bool = False):
simplify_paths_rdp(slicer, threshold=0.25)
seams_smooth(slicer, smooth_distance=3)
slicer.printout_info()
utils.save_to_json(slicer.to_data(), OUTPUT_PATH, 'curved_slicer.json')
utils.save_to_json(slicer.to_data(), OUTPUT_PATH, "curved_slicer.json")

# Print organizer
print_organizer = InterpolationPrintOrganizer(slicer, config, DATA_PATH)
Expand All @@ -60,21 +63,25 @@ def main(visualize: bool = False):
smooth_printpoints_up_vectors(print_organizer, strength=0.5, iterations=10)
smooth_printpoints_layer_heights(print_organizer, strength=0.5, iterations=5)

set_linear_velocity_by_range(print_organizer, param_func=lambda ppt: ppt.layer_height,
parameter_range=[avg_layer_height*0.5, avg_layer_height*2.0],
velocity_range=[150, 70], bound_remapping=False)
set_linear_velocity_by_range(
print_organizer,
param_func=lambda ppt: ppt.layer_height,
parameter_range=[avg_layer_height * 0.5, avg_layer_height * 2.0],
velocity_range=[150, 70],
bound_remapping=False,
)
set_extruder_toggle(print_organizer, slicer)
add_safety_printpoints(print_organizer, z_hop=10.0)

# Save printpoints dictionary to json file
printpoints_data = print_organizer.output_printpoints_dict()
utils.save_to_json(printpoints_data, OUTPUT_PATH, 'out_printpoints.json')
utils.save_to_json(printpoints_data, OUTPUT_PATH, "out_printpoints.json")

end_time = time.time()
print("Total elapsed time", round(end_time - start_time, 2), "seconds")

if visualize:
visualize_slicer(slicer, mesh)
visualize_slicer(slicer, mesh, mesh_colorfield="scalar_field")


if __name__ == "__main__":
Expand Down
48 changes: 24 additions & 24 deletions examples/6_attributes_transfer/example_6_attributes_transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
from compas_slicer.utilities.attributes_transfer import transfer_mesh_attributes_to_printpoints
from compas_slicer.visualization import should_visualize, visualize_slicer

DATA_PATH = Path(__file__).parent / 'data'
DATA_PATH = Path(__file__).parent / "data"
OUTPUT_PATH = slicer_utils.get_output_directory(DATA_PATH)
MODEL = 'distorted_v_closed_low_res.obj'
MODEL = "distorted_v_closed_low_res.obj"


def main(visualize: bool = False):
Expand All @@ -26,37 +26,37 @@ def main(visualize: bool = False):
# Vertex attributes can only be entities that can be meaningfully multiplied with a float (ex. float, np.array ...)

# overhand attribute - Scalar value (per face)
mesh.update_default_face_attributes({'overhang': 0.0})
mesh.update_default_face_attributes({"overhang": 0.0})
for f_key, data in mesh.faces(data=True):
face_normal = mesh.face_normal(f_key, unitized=True)
data['overhang'] = Vector(0.0, 0.0, 1.0).dot(face_normal)
data["overhang"] = Vector(0.0, 0.0, 1.0).dot(face_normal)

# face looking towards the positive y axis - Boolean value (per face)
mesh.update_default_face_attributes({'positive_y_axis': False})
mesh.update_default_face_attributes({"positive_y_axis": False})
for f_key, data in mesh.faces(data=True):
face_normal = mesh.face_normal(f_key, unitized=True)
is_positive_y = Vector(0.0, 1.0, 0.0).dot(face_normal) > 0 # boolean value
data['positive_y_axis'] = is_positive_y
data["positive_y_axis"] = is_positive_y

# distance from plane - Scalar value (per vertex)
mesh.update_default_vertex_attributes({'dist_from_plane': 0.0})
mesh.update_default_vertex_attributes({"dist_from_plane": 0.0})
plane = (Point(0.0, 0.0, -30.0), Vector(0.0, 0.5, 0.5))
for v_key, data in mesh.vertices(data=True):
v_coord = mesh.vertex_coordinates(v_key, axes='xyz')
data['dist_from_plane'] = distance_point_plane(v_coord, plane)
v_coord = mesh.vertex_coordinates(v_key, axes="xyz")
data["dist_from_plane"] = distance_point_plane(v_coord, plane)

# direction towards point - Vector value (per vertex)
mesh.update_default_vertex_attributes({'direction_to_pt': 0.0})
mesh.update_default_vertex_attributes({"direction_to_pt": 0.0})
pt = Point(4.0, 1.0, 0.0)
for v_key, data in mesh.vertices(data=True):
v_coord = mesh.vertex_coordinates(v_key, axes='xyz')
data['direction_to_pt'] = np.array(normalize_vector(Vector.from_start_end(v_coord, pt)))
v_coord = mesh.vertex_coordinates(v_key, axes="xyz")
data["direction_to_pt"] = np.array(normalize_vector(Vector.from_start_end(v_coord, pt)))

# --------------- Slice mesh
slicer = PlanarSlicer(mesh, layer_height=5.0)
slicer.slice_model()
simplify_paths_rdp(slicer, threshold=1.0)
slicer_utils.save_to_json(slicer.to_data(), OUTPUT_PATH, 'slicer_data.json')
slicer_utils.save_to_json(slicer.to_data(), OUTPUT_PATH, "slicer_data.json")

# --------------- Create printpoints
print_organizer = PlanarPrintOrganizer(slicer)
Expand All @@ -67,25 +67,25 @@ def main(visualize: bool = False):

# --------------- Save printpoints to json (only json-serializable attributes are saved)
printpoints_data = print_organizer.output_printpoints_dict()
utils.save_to_json(printpoints_data, OUTPUT_PATH, 'out_printpoints.json')
utils.save_to_json(printpoints_data, OUTPUT_PATH, "out_printpoints.json")

# --------------- Print the info to see the attributes of the printpoints (you can also visualize them on gh)
print_organizer.printout_info()

# --------------- Save printpoints attributes for visualization
overhangs_list = print_organizer.get_printpoints_attribute(attr_name='overhang')
positive_y_axis_list = print_organizer.get_printpoints_attribute(attr_name='positive_y_axis')
dist_from_plane_list = print_organizer.get_printpoints_attribute(attr_name='dist_from_plane')
direction_to_pt_list = print_organizer.get_printpoints_attribute(attr_name='direction_to_pt')
overhangs_list = print_organizer.get_printpoints_attribute(attr_name="overhang")
positive_y_axis_list = print_organizer.get_printpoints_attribute(attr_name="positive_y_axis")
dist_from_plane_list = print_organizer.get_printpoints_attribute(attr_name="dist_from_plane")
direction_to_pt_list = print_organizer.get_printpoints_attribute(attr_name="direction_to_pt")

utils.save_to_json(overhangs_list, OUTPUT_PATH, 'overhangs_list.json')
utils.save_to_json(positive_y_axis_list, OUTPUT_PATH, 'positive_y_axis_list.json')
utils.save_to_json(dist_from_plane_list, OUTPUT_PATH, 'dist_from_plane_list.json')
utils.save_to_json(utils.point_list_to_dict(direction_to_pt_list), OUTPUT_PATH, 'direction_to_pt_list.json')
utils.save_to_json(overhangs_list, OUTPUT_PATH, "overhangs_list.json")
utils.save_to_json(positive_y_axis_list, OUTPUT_PATH, "positive_y_axis_list.json")
utils.save_to_json(dist_from_plane_list, OUTPUT_PATH, "dist_from_plane_list.json")
utils.save_to_json(utils.point_list_to_dict(direction_to_pt_list), OUTPUT_PATH, "direction_to_pt_list.json")

if visualize:
visualize_slicer(slicer, mesh)
visualize_slicer(slicer, mesh, mesh_opacity=0.6, mesh_colorfield="overhang")


if __name__ == '__main__':
if __name__ == "__main__":
main(visualize=should_visualize())
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ classifiers = [
dependencies = [
"attrs>=25.0",
"compas>=2.15",
"compas_cgal>=0.9",
"compas_cgal>=0.9.1",
"loguru>=0.7",
"networkx>=3.6",
"networkx>=3.2",
"numpy>=2.0",
"progressbar2>=4.5",
"pyclipper>=1.4",
Expand Down
20 changes: 8 additions & 12 deletions src/compas_slicer/pre_processing/preprocessing_utils/geodesics.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,14 @@
from compas.datastructures import Mesh


__all__ = ['get_heat_geodesic_distances',
'get_custom_HEAT_geodesic_distances',
'GeodesicsCache']
__all__ = ["get_heat_geodesic_distances", "get_custom_HEAT_geodesic_distances", "GeodesicsCache"]


# CGAL heat method solver cache (for precomputation reuse)
_cgal_solver_cache: dict[int, object] = {}


def get_heat_geodesic_distances(
mesh: Mesh, vertices_start: list[int]
) -> NDArray[np.floating]:
def get_heat_geodesic_distances(mesh: Mesh, vertices_start: list[int]) -> NDArray[np.floating]:
"""
Calculate geodesic distances using CGAL heat method.

Expand All @@ -55,7 +51,9 @@ def get_heat_geodesic_distances(
mesh_hash = hash((len(list(mesh.vertices())), len(list(mesh.faces()))))
if mesh_hash not in _cgal_solver_cache:
_cgal_solver_cache.clear() # Clear old solvers
_cgal_solver_cache[mesh_hash] = HeatGeodesicSolver(mesh)
V = mesh.vertices_attributes("xyz")
F = [mesh.face_vertices(f) for f in mesh.faces()]
_cgal_solver_cache[mesh_hash] = HeatGeodesicSolver((V, F))

solver = _cgal_solver_cache[mesh_hash]

Expand Down Expand Up @@ -90,9 +88,7 @@ def clear(self) -> None:
self._cache.clear()
self._mesh_hash = None

def get_distances(
self, mesh: Mesh, sources: list[int], method: str = 'heat'
) -> NDArray[np.floating]:
def get_distances(self, mesh: Mesh, sources: list[int], method: str = "heat") -> NDArray[np.floating]:
"""Get geodesic distances from sources, using cache when possible.

Parameters
Expand Down Expand Up @@ -166,7 +162,7 @@ class GeodesicsSolver:
"""

def __init__(self, mesh: Mesh, OUTPUT_PATH: str) -> None:
logger.info('GeodesicsSolver')
logger.info("GeodesicsSolver")
self.mesh = mesh
self.OUTPUT_PATH = OUTPUT_PATH

Expand Down Expand Up @@ -227,7 +223,7 @@ def diffuse_heat(
# reverse values (to make sources at 0, increasing outward)
u = ([np.max(u)] * len(u)) - u

utils.save_to_json([float(value) for value in u], self.OUTPUT_PATH, 'diffused_heat.json')
utils.save_to_json([float(value) for value in u], self.OUTPUT_PATH, "diffused_heat.json")
return u

def get_geodesic_distances(
Expand Down
26 changes: 23 additions & 3 deletions src/compas_slicer/visualization/visualization.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Visualization utilities for compas_slicer using compas_viewer."""

from __future__ import annotations

import sys
Expand Down Expand Up @@ -33,6 +34,7 @@ def visualize_slicer(
mesh: Mesh | None = None,
show_mesh: bool = True,
mesh_opacity: float = 0.3,
mesh_colorfield: str = "z",
) -> None:
"""Visualize slicer toolpaths in compas_viewer.

Expand All @@ -46,17 +48,35 @@ def visualize_slicer(
If True, display the mesh.
mesh_opacity : float
Opacity for mesh display (0-1).
mesh_colorfield : str
Comment on lines 48 to +51
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states that mesh_colorfield is a "Vertex attribute name" but the implementation actually supports both vertex and face attributes through auto-detection. The documentation should reflect this dual capability to avoid confusion.

Copilot uses AI. Check for mistakes.
Vertex attribute name to use for mesh coloring.

"""
from compas.colors import Color
from compas.colors import ColorMap
from compas.geometry import Polyline
from compas_viewer import Viewer

viewer = Viewer()

# Add mesh if provided
# Add mesh if provided, colored by vertex or face attribute
if mesh and show_mesh:
viewer.scene.add(mesh, opacity=mesh_opacity)
cmap = ColorMap.from_mpl("viridis")

# check if colorfield is vertex or face attribute
first_vertex = next(mesh.vertices())
if mesh.vertex_attribute(first_vertex, mesh_colorfield) is not None:
scalars = {v: mesh.vertex_attribute(v, mesh_colorfield) for v in mesh.vertices()}
Comment on lines +68 to +69
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the mesh has no vertices or faces, the min and max operations on empty dictionaries will raise a ValueError. Consider adding a check to ensure scalars is not empty before computing smin and smax, or handle the case where the mesh might be empty.

Copilot uses AI. Check for mistakes.
smin, smax = min(scalars.values()), max(scalars.values())
Comment on lines +69 to +70
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When all scalar values are identical (smin == smax), the colormap will encounter a division by zero or produce undefined behavior when normalizing values. Consider handling this edge case by either using a single color or adding a small epsilon to avoid division issues.

Copilot uses AI. Check for mistakes.
vertexcolor = {v: cmap(s, minval=smin, maxval=smax) for v, s in scalars.items()}
viewer.scene.add(
mesh, vertexcolor=vertexcolor, opacity=mesh_opacity, use_vertexcolors=True, show_lines=False
)
Comment on lines +73 to +74
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the mesh has no faces, the min and max operations on empty dictionaries will raise a ValueError. Consider adding a check to ensure scalars is not empty before computing smin and smax, or handle the case where the mesh might be empty.

Copilot uses AI. Check for mistakes.
else:
Comment on lines +74 to +75
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When all scalar values are identical (smin == smax), the colormap will encounter a division by zero or produce undefined behavior when normalizing values. Consider handling this edge case by either using a single color or adding a small epsilon to avoid division issues.

Copilot uses AI. Check for mistakes.
scalars = {f: mesh.face_attribute(f, mesh_colorfield) for f in mesh.faces()}
Comment on lines +62 to +76
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new mesh colorfield visualization feature lacks test coverage for edge cases such as invalid attribute names, empty meshes, or uniform scalar values. While the feature is exercised in example integration tests, consider adding unit tests that specifically verify error handling and edge case behavior for the colorfield parameter.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +76
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no error handling when the specified mesh_colorfield doesn't exist as either a vertex or face attribute. If a user provides an invalid attribute name, the code will fail at line 73 when trying to retrieve face attributes without a clear error message. Consider adding explicit validation and providing a helpful error message when the attribute is not found in either vertices or faces.

Copilot uses AI. Check for mistakes.
smin, smax = min(scalars.values()), max(scalars.values())
facecolor = {f: cmap(s, minval=smin, maxval=smax) for f, s in scalars.items()}
viewer.scene.add(mesh, facecolor=facecolor, opacity=mesh_opacity, show_lines=False)

# Add paths as polylines with color gradient by layer
n_layers = len(slicer.layers)
Expand Down Expand Up @@ -87,5 +107,5 @@ def plot_networkx_graph(G: nx.Graph) -> None:
import matplotlib.pyplot as plt

plt.subplot(121)
nx.draw(G, with_labels=True, font_weight='bold', node_color=range(len(list(G.nodes()))))
nx.draw(G, with_labels=True, font_weight="bold", node_color=range(len(list(G.nodes()))))
plt.show()
Loading