From 2bc11ecd823140b4569a1ee43b8442e1a3d73a03 Mon Sep 17 00:00:00 2001 From: Aaron White Date: Sun, 4 May 2025 05:59:08 -0400 Subject: [PATCH 1/4] Add support for output in .3mf. Add ability for specifying extruders in .3mf. Add ability to flip text to face bed (embedded). --- src/gflabel/cli.py | 106 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 3 deletions(-) diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 4809c6f..d703c94 100755 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -9,13 +9,17 @@ import sys from argparse import ArgumentParser from pathlib import Path -from typing import Any, Sequence +from typing import Any, Sequence, Optional import build123d as bd import pint import rich import rich.table +import tempfile +import shutil +import zipfile + # from build123d import * from build123d import ( BuildPart, @@ -152,6 +156,60 @@ def base_name_to_subclass(name: str) -> type[LabelBase]: ) return bases[name] +def write_slic3r_pe_model_config(obj_name: str, + triangles: Sequence[int], + body_extruder: Optional[int] = None, + text_extruder: Optional[int] = None, + ) -> str: + """Create flimsy rendering of slic3r_pe_model.config""" + xmlstring = """\n""" + xmlstring += """\n""" + total_tris = 0 + + for i, num_tris in enumerate(triangles): + # It appears that each object is written to the 3mf twice? + # so we need to pick 1 (base) + 3,5,7 (for text stuff) + xmlstring += f"""\n""" + xmlstring += f""" \n""" + # This allows extruder id's to pass through when "NO" is selected in the "multipart-part object" dialog is prusaslicer + if i == 0 and body_extruder is not None and isinstance(body_extruder, int): + xmlstring += f""" \n""" + elif i > 0 and text_extruder is not None and isinstance(text_extruder, int): + xmlstring += f""" \n""" + xmlstring += f""" \n""" + xmlstring += f""" \n""" + # This allows extruder id's to pass through when "YES" is selected in the "multipart-part object" dialog is prusaslicer + # ... once/if my pull request is actioned -- https://github.com/prusa3d/PrusaSlicer/pull/14525 + if i == 0 and body_extruder is not None and isinstance(body_extruder, int): + xmlstring += f""" \n""" + elif i > 0 and text_extruder is not None and isinstance(text_extruder, int): + xmlstring += f""" \n""" + xmlstring += f""" \n""" + xmlstring += f"""\n""" + total_tris += num_tris + xmlstring += f"""\n""" + return xmlstring + +def add_file_to_3mf(threemf_file: Path, file_to_add: Path, path_in_zip: Path) -> None: + """overwrites existing threemf_file with equivalent w/ file_to_add added in position path_in_zip""" + # Create temporary .zip file and copy the contents of .3mf file to it (so zipfile library doesn't complain) + with tempfile.NamedTemporaryFile(suffix=".zip") as renamed_3mf_as_zip: + shutil.copy(threemf_file, str(renamed_3mf_as_zip.name)) + # Create temporary .zip file which will contain original contents + the file we're adding + with tempfile.NamedTemporaryFile(suffix=".zip") as modified_3mf_as_zip: + # Open the .zip equipvalent of the renamed .3mf file, and the final result + with zipfile.ZipFile(renamed_3mf_as_zip.name, 'r') as zin: + with zipfile.ZipFile(modified_3mf_as_zip.name, 'w') as zout: + # Copy all content of .3mf zip file + for item in zin.infolist(): + buffer = zin.read(item.filename) + # if item.filename == "3D/3dmodel.model": + # Path("current_3dmodel.model").write_bytes(buffer) + zout.writestr(item, buffer) + # Add designated file + zout.write(file_to_add, arcname=str(path_in_zip)) + # zout.write("slicer.config", arcname="Metadata/Slic3r_PE_model.config") + shutil.copy(modified_3mf_as_zip.name, f"{threemf_file}") def run(argv: list[str] | None = None): # Handle the old way of specifying base @@ -205,7 +263,28 @@ def run(argv: list[str] | None = None): help="Disable the 'Overheight' system. This allows some symbols to oversize, meaning that the rest of the line will first shrink before they are shrunk.", action="store_true", ) - + parser.add_argument( + "--place-labeltext-on-plate", + dest="place_labeltext_on_plate", + help="reorient body such that embedded text is facing buildplate (down) [style=embedded only]", + action="store_true", + ) + parser.add_argument( + "--3mf-text-extruder", + dest="threemf_text_extruder", + help="Which extruder to associate with text volumes in .3mf", + action="store", + type=int, + default=None, + ) + parser.add_argument( + "--3mf-body-extruder", + dest="threemf_body_extruder", + help="Which extruder to associate with body volume in .3mf", + action="store", + type=int, + default=None, + ) parser.add_argument("labels", nargs="+", metavar="LABEL") parser.add_argument( "-d", @@ -419,8 +498,9 @@ def run(argv: list[str] | None = None): if args.style == LabelStyle.EMBEDDED: # We want to make new volumes for the label, making it flush embedded_label = extrude(label_sketch.sketch, amount=-args.depth) - embedded_label.label = "Label" assembly = Compound([part.part, embedded_label]) + if args.place_labeltext_on_plate: + assembly = assembly.mirror(bd.Plane.XY) else: assembly = Compound(part.part) @@ -444,6 +524,26 @@ def run(argv: list[str] | None = None): logger.info(f"Writing SVG {output}") exporter.add_shape(label_sketch.sketch, layer="Shapes") exporter.write(output) + elif output.endswith(".3mf"): + exporter = bd.Mesher() + exporter.add_shape(assembly) + logger.info(f"Writing 3MF {output}") + exporter.write(output) + if args.threemf_body_extruder is not None or args.threemf_text_extruder is not None: + pe_model_config_text = write_slic3r_pe_model_config(obj_name=Path(output).stem, + triangles=exporter.triangle_counts, + body_extruder=args.threemf_body_extruder, + text_extruder=args.threemf_text_extruder, + ) + + with tempfile.NamedTemporaryFile(suffix=".config") as slicer_config: + # There may be a built-in way to include a text file (metadata) as part of Mesher(above) + # but i haven't yet figured out how to do it. + Path(slicer_config.name).write_text(pe_model_config_text, encoding="utf-8") + logger.info(f"Updating 3MF {output} w/ Slic3r_PE_model.config") + add_file_to_3mf(threemf_file=output, + file_to_add=slicer_config.name, + path_in_zip=Path("Metadata/Slic3r_PE_model.config")) else: logger.error(f"Error: Do not understand output format '{args.output}'") From b45ed9df525df2e3b2efed12e72a5a3d34c3c468 Mon Sep 17 00:00:00 2001 From: Aaron White Date: Mon, 5 May 2025 18:43:38 -0400 Subject: [PATCH 2/4] fix: text is mirrored (reversed). use rotate instead to get desired effect. --- src/gflabel/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index d703c94..402408f 100755 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -500,7 +500,7 @@ def run(argv: list[str] | None = None): embedded_label = extrude(label_sketch.sketch, amount=-args.depth) assembly = Compound([part.part, embedded_label]) if args.place_labeltext_on_plate: - assembly = assembly.mirror(bd.Plane.XY) + assembly = assembly.rotate(axis=bd.Axis.X, angle=180) else: assembly = Compound(part.part) From cd45b49f0673c05df6681d2f3eefb346ad9be11c Mon Sep 17 00:00:00 2001 From: Aaron White Date: Wed, 7 May 2025 14:13:27 -0400 Subject: [PATCH 3/4] fix: use lib3mf-native way to include attachments in 3mf. --- src/gflabel/cli.py | 41 +++++------------------------------------ 1 file changed, 5 insertions(+), 36 deletions(-) diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 402408f..de56196 100755 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -16,10 +16,6 @@ import rich import rich.table -import tempfile -import shutil -import zipfile - # from build123d import * from build123d import ( BuildPart, @@ -190,27 +186,6 @@ def write_slic3r_pe_model_config(obj_name: str, xmlstring += f"""\n""" return xmlstring -def add_file_to_3mf(threemf_file: Path, file_to_add: Path, path_in_zip: Path) -> None: - """overwrites existing threemf_file with equivalent w/ file_to_add added in position path_in_zip""" - # Create temporary .zip file and copy the contents of .3mf file to it (so zipfile library doesn't complain) - with tempfile.NamedTemporaryFile(suffix=".zip") as renamed_3mf_as_zip: - shutil.copy(threemf_file, str(renamed_3mf_as_zip.name)) - # Create temporary .zip file which will contain original contents + the file we're adding - with tempfile.NamedTemporaryFile(suffix=".zip") as modified_3mf_as_zip: - # Open the .zip equipvalent of the renamed .3mf file, and the final result - with zipfile.ZipFile(renamed_3mf_as_zip.name, 'r') as zin: - with zipfile.ZipFile(modified_3mf_as_zip.name, 'w') as zout: - # Copy all content of .3mf zip file - for item in zin.infolist(): - buffer = zin.read(item.filename) - # if item.filename == "3D/3dmodel.model": - # Path("current_3dmodel.model").write_bytes(buffer) - zout.writestr(item, buffer) - # Add designated file - zout.write(file_to_add, arcname=str(path_in_zip)) - # zout.write("slicer.config", arcname="Metadata/Slic3r_PE_model.config") - shutil.copy(modified_3mf_as_zip.name, f"{threemf_file}") - def run(argv: list[str] | None = None): # Handle the old way of specifying base if any(x.startswith("--base") for x in (argv or sys.argv)): @@ -527,23 +502,17 @@ def run(argv: list[str] | None = None): elif output.endswith(".3mf"): exporter = bd.Mesher() exporter.add_shape(assembly) - logger.info(f"Writing 3MF {output}") - exporter.write(output) if args.threemf_body_extruder is not None or args.threemf_text_extruder is not None: pe_model_config_text = write_slic3r_pe_model_config(obj_name=Path(output).stem, triangles=exporter.triangle_counts, body_extruder=args.threemf_body_extruder, text_extruder=args.threemf_text_extruder, ) - - with tempfile.NamedTemporaryFile(suffix=".config") as slicer_config: - # There may be a built-in way to include a text file (metadata) as part of Mesher(above) - # but i haven't yet figured out how to do it. - Path(slicer_config.name).write_text(pe_model_config_text, encoding="utf-8") - logger.info(f"Updating 3MF {output} w/ Slic3r_PE_model.config") - add_file_to_3mf(threemf_file=output, - file_to_add=slicer_config.name, - path_in_zip=Path("Metadata/Slic3r_PE_model.config")) + attachment = exporter.model.AddAttachment("Metadata/Slic3r_PE_model.config", "application/xml") + # ReadFromBuffer - "Read from Buffer into attachment file" + attachment.ReadFromBuffer(pe_model_config_text.encode("utf-8")) + logger.info(f"Writing 3MF {output}") + exporter.write(output) else: logger.error(f"Error: Do not understand output format '{args.output}'") From 999202a4f7d4ef849da78e0c9d01650534b67e2e Mon Sep 17 00:00:00 2001 From: Aaron White Date: Wed, 7 May 2025 18:16:11 -0400 Subject: [PATCH 4/4] enhance: yield 3mf with one object, many volumes --- src/gflabel/cli.py | 163 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 130 insertions(+), 33 deletions(-) diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index de56196..a31d41a 100755 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -7,9 +7,14 @@ import logging import os import sys +import math +import ctypes +import copy +import warnings from argparse import ArgumentParser from pathlib import Path -from typing import Any, Sequence, Optional +from typing import Any, Sequence, Optional, Tuple, List +from xml.dom import minidom import build123d as bd import pint @@ -36,6 +41,8 @@ extrude, ) +from build123d.geometry import TOLERANCE + from . import fragments from .bases import LabelBase from .bases.cullenect import CullenectBase @@ -152,39 +159,63 @@ def base_name_to_subclass(name: str) -> type[LabelBase]: ) return bases[name] -def write_slic3r_pe_model_config(obj_name: str, - triangles: Sequence[int], +def write_slic3r_pe_model_config(volume_offsets: Sequence[Tuple[int, int]], + obj_name: str, body_extruder: Optional[int] = None, text_extruder: Optional[int] = None, ) -> str: - """Create flimsy rendering of slic3r_pe_model.config""" - xmlstring = """\n""" - xmlstring += """\n""" - total_tris = 0 - - for i, num_tris in enumerate(triangles): - # It appears that each object is written to the 3mf twice? - # so we need to pick 1 (base) + 3,5,7 (for text stuff) - xmlstring += f"""\n""" - xmlstring += f""" \n""" - # This allows extruder id's to pass through when "NO" is selected in the "multipart-part object" dialog is prusaslicer - if i == 0 and body_extruder is not None and isinstance(body_extruder, int): - xmlstring += f""" \n""" - elif i > 0 and text_extruder is not None and isinstance(text_extruder, int): - xmlstring += f""" \n""" - xmlstring += f""" \n""" - xmlstring += f""" \n""" - # This allows extruder id's to pass through when "YES" is selected in the "multipart-part object" dialog is prusaslicer - # ... once/if my pull request is actioned -- https://github.com/prusa3d/PrusaSlicer/pull/14525 - if i == 0 and body_extruder is not None and isinstance(body_extruder, int): - xmlstring += f""" \n""" - elif i > 0 and text_extruder is not None and isinstance(text_extruder, int): - xmlstring += f""" \n""" - xmlstring += f""" \n""" - xmlstring += f"""\n""" - total_tris += num_tris - xmlstring += f"""\n""" - return xmlstring + """Create rendering of Slic3r_PE_model.config""" + doc = minidom.Document() + root = doc.createElement("config") + doc.appendChild(root) + + obj = doc.createElement("object") + obj.setAttribute(attname="id", value="1") + obj.setAttribute(attname="instances_count", value=str(len(volume_offsets))) + root.appendChild(obj) + + obj_metadata = doc.createElement("metadata") + for k, v in dict(type="object", + key="name", + value=str(obj_name)).items(): + obj_metadata.setAttribute(attname=k, value=v) + obj.appendChild(obj_metadata) + + for volume_id, vol_range in enumerate(volume_offsets): + volume = doc.createElement("volume") + volume.setAttribute(attname="firstid", value=str(vol_range[0])) + volume.setAttribute(attname="lastid", value=str(vol_range[1])) + volume_metadata_name = doc.createElement("metadata") + for k, v in dict(type="volume", + key="name", + value=f"{obj_name}_body" if volume_id == 0 else f"{obj_name}_text_{volume_id}").items(): + volume_metadata_name.setAttribute(attname=k, value=v) + volume.appendChild(volume_metadata_name) + if volume_id == 0 and body_extruder is not None and isinstance(body_extruder, int): + vol_metadata_extruder = doc.createElement("metadata") + for k, v in dict(type="volume", + key="extruder", + value=str(body_extruder)).items(): + vol_metadata_extruder.setAttribute(attname=k, value=v) + volume.appendChild(vol_metadata_extruder) + elif volume_id > 0 and text_extruder is not None and isinstance(text_extruder, int): + vol_metadata_extruder = doc.createElement("metadata") + for k, v in dict(type="volume", + key="extruder", + value=str(text_extruder)).items(): + vol_metadata_extruder.setAttribute(attname=k, value=v) + volume.appendChild(vol_metadata_extruder) + obj.appendChild(volume) + return doc.toprettyxml(indent=" ", encoding="UTF-8").decode("utf-8") + +def round_mesh_vertices(tolerance: float, verts: List[Tuple[float, float, float]]) -> List[Tuple[float, float, float]]: + """round list of vertices to a given tolerance""" + digits = -int(round(math.log(tolerance, 10), 1)) + ocp_mesh_vertices = [ + (round(x, digits), round(y, digits), round(z, digits)) + for x, y, z in verts + ] + return ocp_mesh_vertices def run(argv: list[str] | None = None): # Handle the old way of specifying base @@ -501,10 +532,76 @@ def run(argv: list[str] | None = None): exporter.write(output) elif output.endswith(".3mf"): exporter = bd.Mesher() - exporter.add_shape(assembly) + + # -- Below, we'll recreate exporter.add_shape(), modifying some behavior throughout. -- + volume_offsets: List[int] = [] # Capture how we'll partition parts in the object + unique_vertices: Sequence[Tuple[float, float, float]] = [] # No need to place duplicate vertices in 3dmodel.model + triangles_3mf: Sequence[bd.Lib3MF.Triangle] = [] # Create triangle point list + + for b3d_shape in assembly.solids(): + ocp_mesh_vertices, triangles = bd.Mesher._mesh_shape(ocp_mesh=copy.deepcopy(b3d_shape), + linear_deflection=0.001, + angular_deflection=0.1, + ) + # Skip invalid meshes + if len(ocp_mesh_vertices) < 3 or not triangles: + warnings.warn(f"Degenerate shape {b3d_shape} - skipped", + stacklevel=2, + ) + continue + + # -- Below represents portions of exporter._create_3mf_mesh() -- + # Round off the vertices to avoid vertices within tolerance being + # considered as different vertices + ocp_mesh_vertices = round_mesh_vertices(tolerance=TOLERANCE, verts=ocp_mesh_vertices) + + # Create 3mf mesh inputs - Find any verts from ocp_mesh_vertices which don't exist in + # unique_vertices. Apppend the result to unique vertices. + unique_vertices.extend(list(set(ocp_mesh_vertices).difference(set(unique_vertices)))) + vert_table = { + i: unique_vertices.index(pnt) for i, pnt in enumerate(ocp_mesh_vertices) + } + + index_start = len(triangles_3mf) # Need to capture before modifying triangles_3mf + for vertex_indices in triangles: + mapped_indices = [ + vert_table[i] for i in [vertex_indices[i] for i in range(3)] + ] + # Remove degenerate triangles + if len(set(mapped_indices)) != 3: + continue + c_array = (ctypes.c_uint * 3)(*mapped_indices) + triangles_3mf.append(bd.Lib3MF.Triangle(c_array)) + + # Record start/end id of triangles for later use + volume_offsets.append((index_start, len(triangles_3mf) - 1)) + + # Create vertex list of 3MF positions + vertices_3mf: Sequence[bd.Lib3MF.Position] = [] + for pnt in unique_vertices: + c_array = (ctypes.c_float * 3)(*pnt) + vertices_3mf.append(bd.Lib3MF.Position(c_array)) + # mesh_3mf.AddVertex Should AddVertex be used to save memory? + + # Build the mesh + mesh_3mf: bd.Lib3MF.MeshObject = exporter.model.AddMeshObject() + mesh_3mf.SetGeometry(vertices_3mf, triangles_3mf) + + # Add the mesh properties + mesh_3mf.SetType(bd.Mesher._map_b3d_mesh_type_3mf[bd.MeshType.MODEL]) + if b3d_shape.label: + mesh_3mf.SetName(b3d_shape.label) + + # Add color + exporter._add_color(b3d_shape, mesh_3mf) + + # Add mesh to model + exporter.meshes.append(mesh_3mf) + exporter.model.AddBuildItem(mesh_3mf, exporter.wrapper.GetIdentityTransform()) + if args.threemf_body_extruder is not None or args.threemf_text_extruder is not None: pe_model_config_text = write_slic3r_pe_model_config(obj_name=Path(output).stem, - triangles=exporter.triangle_counts, + volume_offsets=volume_offsets, body_extruder=args.threemf_body_extruder, text_extruder=args.threemf_text_extruder, )