fix: preserve 3mf triangle attributes

This commit is contained in:
yuhao
2026-05-13 08:19:15 +00:00
parent 5e6e9d4daa
commit 19e9004271
5 changed files with 252 additions and 11 deletions
+9
View File
@@ -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 `<triangle>` 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.
@@ -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
```
@@ -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
@@ -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,
@@ -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 = """
<triangle v1="0" v2="1" v3="2" pid="7" p1="1" p2="2" p3="3" custom="kept"/>
<triangle v1="0" v2="2" v3="3" pid="8" p1="4" p2="5" p3="6" vendor:tag="alpha"/>
"""
model_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<model unit="millimeter"
xmlns="{parser_mod.NS_CORE}"
xmlns:vendor="urn:vendor:test">
<resources>
<object id="1" name="attr-mesh" type="model">
<mesh>
<vertices>
<vertex x="1" y="0" z="0"/>
<vertex x="0" y="1" z="0"/>
<vertex x="-1" y="0" z="0"/>
<vertex x="0" y="-1" z="0"/>
</vertices>
<triangles>
{triangles_xml}
</triangles>
</mesh>
</object>
<object id="component-only" type="model">
<components>
<component objectid="1" transform="1 0 0 0 1 0 0 0 1 10 20 30"/>
</components>
</object>
</resources>
<build>
<item objectid="component-only" transform="1 0 0 0 1 0 0 0 1 5 0 0"/>
</build>
</model>
"""
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 = """
<triangle v1="0" v2="1" v3="2" pid="10" p1="11" p2="12" p3="13"/>
<triangle v1="0" v2="0" v3="1" pid="99" p1="99" p2="99" p3="99" removed="true"/>
<triangle v1="3" v2="1" v3="2" pid="20" p1="21" p2="22" p3="23" custom="survives"/>
"""
_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