diff --git a/3MF/agent-harness/3MF.md b/3MF/agent-harness/3MF.md index 343173250..e3a7e05a5 100644 --- a/3MF/agent-harness/3MF.md +++ b/3MF/agent-harness/3MF.md @@ -59,4 +59,13 @@ The tool preserves all non-mesh content during repack: - BambuStudio: project_settings.config, model_settings.config, plate thumbnails - PrusaSlicer: slic3r_pe configs, layer height profiles +Mesh triangle attributes are also preserved when the corresponding triangle is +kept. This includes material/property attributes such as `pid`, `p1`, `p2`, and +`p3`, plus unknown vendor attributes on `` elements. + +Component-only objects and build/component `transform` attributes are preserved +as untouched XML when writing files loaded from an existing 3MF archive. The +current geometry operations do not resolve component transforms into mesh +coordinates and cannot edit component-instance transforms directly. + Output files can be reopened in the original slicer without configuration loss. diff --git a/3MF/agent-harness/cli_anything/threemf/README.md b/3MF/agent-harness/cli_anything/threemf/README.md index 5bd9eff98..edf41e9c5 100644 --- a/3MF/agent-harness/cli_anything/threemf/README.md +++ b/3MF/agent-harness/cli_anything/threemf/README.md @@ -68,6 +68,11 @@ All non-mesh content (slicer settings, thumbnails, plate configurations) is preserved during file repack. Output files can be reopened in BambuStudio or PrusaSlicer without configuration loss. +Triangle-level mesh attributes are preserved for triangles that survive an edit, +including `pid`, `p1`, `p2`, `p3`, and unknown vendor attributes. Component-only +objects and component/build `transform` attributes are kept as original XML, but +this CLI does not currently apply or edit component-instance transforms. + ## Architecture ``` diff --git a/3MF/agent-harness/cli_anything/threemf/core/parser.py b/3MF/agent-harness/cli_anything/threemf/core/parser.py index bb6fb8395..285359a09 100644 --- a/3MF/agent-harness/cli_anything/threemf/core/parser.py +++ b/3MF/agent-harness/cli_anything/threemf/core/parser.py @@ -55,12 +55,17 @@ class MeshData: name: The ``name`` attribute (may be empty). vertices: (N, 3) float64 array of vertex coordinates. triangles: (M, 3) int32 array of triangle vertex-indices. + triangle_attributes: Per-triangle XML attributes other than + ``v1``, ``v2``, and ``v3``. This preserves 3MF + material/property attributes such as ``pid``, ``p1``, + ``p2``, and ``p3`` during mesh edits. """ object_id: str name: str vertices: np.ndarray # (N, 3) float64 triangles: np.ndarray # (M, 3) int32 + triangle_attributes: tuple[dict[str, str], ...] = () def __post_init__(self) -> None: # Validate shapes -- run only once at construction time. @@ -72,6 +77,14 @@ class MeshData: raise ValueError( f"triangles must be (M, 3); got {self.triangles.shape}" ) + if self.triangle_attributes and ( + len(self.triangle_attributes) != self.triangles.shape[0] + ): + raise ValueError( + "triangle_attributes length must match triangle count; " + f"got {len(self.triangle_attributes)} attributes for " + f"{self.triangles.shape[0]} triangles" + ) @dataclass(frozen=True) @@ -158,11 +171,19 @@ def _parse_mesh_element( return None tri_list: list[tuple[int, int, int]] = [] + triangle_attributes: list[dict[str, str]] = [] for t in tris_elem.iterfind(_tag("triangle", ns)): v1 = int(t.get("v1", "0")) v2 = int(t.get("v2", "0")) v3 = int(t.get("v3", "0")) tri_list.append((v1, v2, v3)) + triangle_attributes.append( + { + key: value + for key, value in t.attrib.items() + if key not in {"v1", "v2", "v3"} + } + ) if not tri_list: return None @@ -174,6 +195,7 @@ def _parse_mesh_element( name=name, vertices=vertices, triangles=triangles, + triangle_attributes=tuple(triangle_attributes), ) @@ -355,13 +377,23 @@ def _build_mesh_element(md: MeshData, ns: str) -> ET.Element: # Triangles tris_el = ET.SubElement(mesh_el, _tag("triangles", ns)) - for row in md.triangles: + for index, row in enumerate(md.triangles): + attrs = ( + dict(md.triangle_attributes[index]) + if md.triangle_attributes + else {} + ) + attrs.update( + { + "v1": str(row[0]), + "v2": str(row[1]), + "v3": str(row[2]), + } + ) ET.SubElement( tris_el, _tag("triangle", ns), - v1=str(row[0]), - v2=str(row[1]), - v3=str(row[2]), + attrs, ) return mesh_el diff --git a/3MF/agent-harness/cli_anything/threemf/core/repair.py b/3MF/agent-harness/cli_anything/threemf/core/repair.py index 5a2e95f13..9eede18f5 100644 --- a/3MF/agent-harness/cli_anything/threemf/core/repair.py +++ b/3MF/agent-harness/cli_anything/threemf/core/repair.py @@ -43,7 +43,13 @@ def repair_mesh(mesh_data: MeshData) -> tuple[MeshData, dict]: ) # Step 3 -- remove degenerate faces - triangles, degen_count = remove_degenerate_faces(triangles) + valid_faces = _nondegenerate_face_mask(triangles) + degen_count = int(triangles.shape[0] - np.count_nonzero(valid_faces)) + triangles = triangles[valid_faces] + triangle_attributes = _filter_triangle_attributes( + mesh_data.triangle_attributes, + valid_faces, + ) # Step 4 -- remove unreferenced vertices vertices, triangles, unref_count = remove_unreferenced_vertices( @@ -58,7 +64,12 @@ def repair_mesh(mesh_data: MeshData) -> tuple[MeshData, dict]: "final_triangle_count": int(triangles.shape[0]), } - repaired = replace(mesh_data, vertices=vertices, triangles=triangles) + repaired = replace( + mesh_data, + vertices=vertices, + triangles=triangles, + triangle_attributes=triangle_attributes, + ) return repaired, report @@ -115,9 +126,7 @@ def remove_degenerate_faces( if triangles.shape[0] == 0: return triangles, 0 - v0, v1, v2 = triangles[:, 0], triangles[:, 1], triangles[:, 2] - valid = (v0 != v1) & (v1 != v2) & (v0 != v2) - + valid = _nondegenerate_face_mask(triangles) filtered = triangles[valid] removed = int(triangles.shape[0] - filtered.shape[0]) return filtered, removed @@ -183,10 +192,18 @@ def fix_normals(mesh_data: MeshData) -> MeshData: ) trimesh.repair.fix_normals(tm) + fixed_triangles = np.array(tm.faces) + triangle_attributes = ( + mesh_data.triangle_attributes + if len(mesh_data.triangle_attributes) == fixed_triangles.shape[0] + else () + ) + return replace( mesh_data, vertices=np.array(tm.vertices), - triangles=np.array(tm.faces), + triangles=fixed_triangles, + triangle_attributes=triangle_attributes, ) @@ -210,6 +227,26 @@ def _validate_mesh(mesh_data: MeshData) -> None: raise ValueError("mesh_data.triangles must have shape (M, 3)") +def _nondegenerate_face_mask(triangles: np.ndarray) -> np.ndarray: + if triangles.shape[0] == 0: + return np.zeros((0,), dtype=bool) + v0, v1, v2 = triangles[:, 0], triangles[:, 1], triangles[:, 2] + return (v0 != v1) & (v1 != v2) & (v0 != v2) + + +def _filter_triangle_attributes( + triangle_attributes: tuple[dict[str, str], ...], + mask: np.ndarray, +) -> tuple[dict[str, str], ...]: + if not triangle_attributes: + return () + return tuple( + dict(attrs) + for attrs, keep in zip(triangle_attributes, mask) + if bool(keep) + ) + + def _empty_report() -> dict: return { "vertices_merged": 0, diff --git a/3MF/agent-harness/cli_anything/threemf/tests/test_core.py b/3MF/agent-harness/cli_anything/threemf/tests/test_core.py index 86b687eea..7ab681164 100644 --- a/3MF/agent-harness/cli_anything/threemf/tests/test_core.py +++ b/3MF/agent-harness/cli_anything/threemf/tests/test_core.py @@ -10,17 +10,22 @@ Covers: remove_unreferenced_vertices, fix_normals) - cli_anything.threemf.core.modifier (resize_holes, resize_single_hole) -All tests use synthetic numpy data -- no real .3mf files are required. +Tests use synthetic numpy data and generated in-memory 3MF fixtures -- no +external 3MF files are required. """ from __future__ import annotations +import zipfile +from xml.etree import ElementTree as ET + import numpy as np import pytest from cli_anything.threemf.core.parser import MeshData, ThreeMFData from cli_anything.threemf.utils import threemf_backend as backend from cli_anything.threemf.core import inspector, repair, modifier +from cli_anything.threemf.core import parser as parser_mod from cli_anything.threemf.core.inspector import DetectedHole, InspectParams from cli_anything.threemf.core.modifier import resize_holes, resize_single_hole @@ -76,6 +81,57 @@ def _make_threemf_data(mesh: MeshData | None = None) -> ThreeMFData: ) +def _write_triangle_attr_3mf(path, triangles_xml: str | None = None) -> None: + if triangles_xml is None: + triangles_xml = """ + + + """ + model_xml = f""" + + + + + + + + + + + +{triangles_xml} + + + + + + + + + + + + + +""" + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", b"fixture") + zf.writestr("3D/3dmodel.model", model_xml.encode("utf-8")) + + +def _model_root(path) -> ET.Element: + with zipfile.ZipFile(path, "r") as zf: + return ET.fromstring(zf.read("3D/3dmodel.model")) + + +def _triangle_attrs(path) -> list[dict[str, str]]: + root = _model_root(path) + triangles = root.findall(f".//{{{parser_mod.NS_CORE}}}triangle") + return [dict(triangle.attrib) for triangle in triangles] + + def _make_circle_points( cx: float = 0.0, cy: float = 0.0, r: float = 5.0, n: int = 60 ) -> np.ndarray: @@ -148,6 +204,19 @@ class TestParser: """Triangles array dtype is int32.""" assert _make_cube_mesh().triangles.dtype == np.int32 + def test_mesh_data_triangle_attributes_length_must_match_faces(self) -> None: + """Per-triangle XML metadata must stay aligned with face rows.""" + v = np.zeros((3, 3), dtype=np.float64) + t = np.array([[0, 1, 2]], dtype=np.int32) + with pytest.raises(ValueError, match="triangle_attributes"): + MeshData( + object_id="1", + name="x", + vertices=v, + triangles=t, + triangle_attributes=({"pid": "1"}, {"pid": "2"}), + ) + def test_mesh_data_is_frozen_on_object_id(self) -> None: """MeshData raises FrozenInstanceError when object_id is reassigned.""" mesh = _make_cube_mesh() @@ -261,6 +330,95 @@ class TestParser: ) assert data.raw_entries["thumbnail.png"] == payload + def test_parse_resize_write_preserves_triangle_attributes_and_component_transforms(self, tmp_path) -> None: + """A 3MF fixture keeps triangle material/vendor attrs after vertex edits and write.""" + source = tmp_path / "attrs.3mf" + output = tmp_path / "resized.3mf" + _write_triangle_attr_3mf(source) + + data = parser_mod.parse_3mf(str(source)) + mesh = data.meshes[0] + assert mesh.triangle_attributes[0] == { + "pid": "7", + "p1": "1", + "p2": "2", + "p3": "3", + "custom": "kept", + } + + hole = DetectedHole( + hole_id=0, + center=(0.0, 0.0), + diameter=2.0, + axis_min=-0.1, + axis_max=0.1, + axis=2, + confidence=1.0, + vertex_count=4, + ) + resized_mesh, report = resize_single_hole(mesh, hole, target_diameter=4.0) + assert report["vertices_moved"] == 4 + new_data = ThreeMFData( + meshes=(resized_mesh,), + unit=data.unit, + model_path=data.model_path, + metadata=data.metadata, + raw_entries=data.raw_entries, + source_path=data.source_path, + ) + + parser_mod.write_3mf(new_data, str(output)) + + attrs = _triangle_attrs(output) + assert attrs[0]["pid"] == "7" + assert attrs[0]["p1"] == "1" + assert attrs[0]["p2"] == "2" + assert attrs[0]["p3"] == "3" + assert attrs[0]["custom"] == "kept" + vendor_key = "{urn:vendor:test}tag" + assert attrs[1][vendor_key] == "alpha" + + root = _model_root(output) + component = root.find(f".//{{{parser_mod.NS_CORE}}}component") + build_item = root.find(f".//{{{parser_mod.NS_CORE}}}item") + assert component is not None + assert build_item is not None + assert component.get("transform") == "1 0 0 0 1 0 0 0 1 10 20 30" + assert build_item.get("transform") == "1 0 0 0 1 0 0 0 1 5 0 0" + + def test_repair_write_preserves_attributes_for_surviving_triangles(self, tmp_path) -> None: + """Repair drops metadata only for faces removed from the mesh.""" + source = tmp_path / "degenerate.3mf" + output = tmp_path / "repaired.3mf" + triangles_xml = """ + + + + """ + _write_triangle_attr_3mf(source, triangles_xml=triangles_xml) + + data = parser_mod.parse_3mf(str(source)) + repaired, report = repair.repair_mesh(data.meshes[0]) + assert report["degenerate_faces_removed"] == 1 + assert [attrs["pid"] for attrs in repaired.triangle_attributes] == ["10", "20"] + + parser_mod.write_3mf( + ThreeMFData( + meshes=(repaired,), + unit=data.unit, + model_path=data.model_path, + metadata=data.metadata, + raw_entries=data.raw_entries, + source_path=data.source_path, + ), + str(output), + ) + + attrs = _triangle_attrs(output) + assert [item["pid"] for item in attrs] == ["10", "20"] + assert attrs[1]["custom"] == "survives" + assert all(item.get("removed") is None for item in attrs) + # =========================================================================== # TestBackend -- threemf_backend geometry utilities