From a19eff42f08cad404e48bc960fd0838b118774b5 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 17 Dec 2025 11:37:35 +0100 Subject: [PATCH] add mesh colorfield visualization support - visualize_slicer now accepts mesh_colorfield param for vertex/face attribute coloring - auto-detect vertex vs face attributes, use viridis colormap - fix HeatGeodesicSolver to accept (V, F) tuple - update examples to use scalar_field/overhang coloring closes #179 --- .../2_curved_slicing/ex2_curved_slicing.py | 35 ++++++++------ .../example_6_attributes_transfer.py | 48 +++++++++---------- pyproject.toml | 4 +- .../preprocessing_utils/geodesics.py | 20 ++++---- .../visualization/visualization.py | 26 ++++++++-- 5 files changed, 78 insertions(+), 55 deletions(-) diff --git a/examples/2_curved_slicing/ex2_curved_slicing.py b/examples/2_curved_slicing/ex2_curved_slicing.py index 30f83fef..bfc01009 100644 --- a/examples/2_curved_slicing/ex2_curved_slicing.py +++ b/examples/2_curved_slicing/ex2_curved_slicing.py @@ -18,7 +18,7 @@ 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) @@ -26,11 +26,11 @@ 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 @@ -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) @@ -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) @@ -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__": diff --git a/examples/6_attributes_transfer/example_6_attributes_transfer.py b/examples/6_attributes_transfer/example_6_attributes_transfer.py index 366fc861..f1835af1 100644 --- a/examples/6_attributes_transfer/example_6_attributes_transfer.py +++ b/examples/6_attributes_transfer/example_6_attributes_transfer.py @@ -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): @@ -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) @@ -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()) diff --git a/pyproject.toml b/pyproject.toml index 625d692b..f248ed38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/compas_slicer/pre_processing/preprocessing_utils/geodesics.py b/src/compas_slicer/pre_processing/preprocessing_utils/geodesics.py index fa736d40..4fba5bdf 100644 --- a/src/compas_slicer/pre_processing/preprocessing_utils/geodesics.py +++ b/src/compas_slicer/pre_processing/preprocessing_utils/geodesics.py @@ -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. @@ -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] @@ -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 @@ -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 @@ -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( diff --git a/src/compas_slicer/visualization/visualization.py b/src/compas_slicer/visualization/visualization.py index 5f488ce8..bde2647c 100644 --- a/src/compas_slicer/visualization/visualization.py +++ b/src/compas_slicer/visualization/visualization.py @@ -1,4 +1,5 @@ """Visualization utilities for compas_slicer using compas_viewer.""" + from __future__ import annotations import sys @@ -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. @@ -46,17 +48,35 @@ def visualize_slicer( If True, display the mesh. mesh_opacity : float Opacity for mesh display (0-1). + mesh_colorfield : str + 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()} + smin, smax = min(scalars.values()), max(scalars.values()) + 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 + ) + else: + scalars = {f: mesh.face_attribute(f, mesh_colorfield) for f in mesh.faces()} + 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) @@ -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()