mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-28 23:27:04 +08:00
fix: preserve 3mf triangle attributes
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user