Files
copy_stand/tools/package_multimaterial_3mf.py
2026-08-22 11:31:10 -07:00

118 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""Combine two aligned OpenSCAD 3MF meshes into a Bambu-friendly multipart 3MF."""
from __future__ import annotations
import argparse
import copy
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
ET.register_namespace("", CORE)
CONTENT_TYPES_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
</Types>
'''
RELATIONSHIPS_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
</Relationships>
'''
def load_mesh(path: Path) -> ET.Element:
with zipfile.ZipFile(path) as archive:
root = ET.fromstring(archive.read("3D/3dmodel.model"))
mesh = root.find(f".//{{{CORE}}}mesh")
if mesh is None:
raise ValueError(f"{path} does not contain a 3MF mesh")
return copy.deepcopy(mesh)
def model_xml(bases: list[Path], accents: list[Path], title: str) -> bytes:
root = ET.Element(f"{{{CORE}}}model", {
"unit": "millimeter",
"{http://www.w3.org/XML/1998/namespace}lang": "en-US",
})
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Title"}).text = title
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Generator"}).text = "copy-stand"
resources = ET.SubElement(root, f"{{{CORE}}}resources")
materials = ET.SubElement(resources, f"{{{CORE}}}basematerials", {"id": "1"})
ET.SubElement(materials, f"{{{CORE}}}base", {"name": "Black ASA", "displaycolor": "#101014"})
ET.SubElement(materials, f"{{{CORE}}}base", {"name": "Blue ASA inlay", "displaycolor": "#0D47CC"})
parts: list[tuple[int, str, Path, int]] = []
next_id = 2
for index, source in enumerate(bases):
parts.append((next_id, f"Black ASA segment {index + 1} - Filament 1", source, 0))
next_id += 1
for index, source in enumerate(accents):
parts.append((next_id, f"Blue ASA inlay {index + 1} - Filament 2", source, 1))
next_id += 1
for object_id, name, source, material_index in parts:
obj = ET.SubElement(resources, f"{{{CORE}}}object", {
"id": str(object_id), "name": name, "type": "model",
"pid": "1", "pindex": str(material_index),
})
obj.append(load_mesh(source))
assembly_id = next_id
assembly = ET.SubElement(resources, f"{{{CORE}}}object", {
"id": str(assembly_id), "name": title, "type": "model",
})
components = ET.SubElement(assembly, f"{{{CORE}}}components")
for object_id, _name, _source, _material_index in parts:
ET.SubElement(components, f"{{{CORE}}}component", {"objectid": str(object_id)})
build = ET.SubElement(root, f"{{{CORE}}}build")
ET.SubElement(build, f"{{{CORE}}}item", {"objectid": str(assembly_id)})
return ET.tostring(root, encoding="utf-8", xml_declaration=True), assembly_id, parts
def model_settings_xml(title: str, assembly_id: int,
parts: list[tuple[int, str, Path, int]]) -> bytes:
root = ET.Element("config")
obj = ET.SubElement(root, "object", {"id": str(assembly_id)})
ET.SubElement(obj, "metadata", {"key": "name", "value": title})
for part_id, name, _source, material_index in parts:
extruder = material_index + 1
part = ET.SubElement(obj, "part", {"id": str(part_id), "subtype": "normal_part"})
ET.SubElement(part, "metadata", {"key": "name", "value": name})
ET.SubElement(part, "metadata", {"key": "extruder", "value": str(extruder)})
ET.SubElement(root, "assemble")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def write_entry(archive: zipfile.ZipFile, name: str, data: bytes) -> None:
info = zipfile.ZipInfo(name)
info.create_system = 3
info.external_attr = 0o100644 << 16
info.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(info, data)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--base", action="append", type=Path, required=True)
parser.add_argument("--accent", action="append", type=Path, default=[])
parser.add_argument("--title", required=True)
args = parser.parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
xml, assembly_id, parts = model_xml(args.base, args.accent, args.title)
with zipfile.ZipFile(args.output, "w") as archive:
write_entry(archive, "[Content_Types].xml", CONTENT_TYPES_XML)
write_entry(archive, "_rels/.rels", RELATIONSHIPS_XML)
write_entry(archive, "3D/3dmodel.model", xml)
write_entry(archive, "Metadata/model_settings.config",
model_settings_xml(args.title, assembly_id, parts))
if __name__ == "__main__":
main()