Files
3d_globe/lib/moon_model/three_mf.rb
T

127 lines
5.8 KiB
Ruby

# frozen_string_literal: true
require "cgi"
require "zip"
module MoonModel
class ThreeMF
CONTENT_TYPES = <<~XML.freeze
<?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>
XML
RELATIONSHIPS = <<~XML.freeze
<?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>
XML
def self.write(path, meshes, metadata: {}, palette: [])
raise ArgumentError, "3MF needs at least one mesh" if meshes.empty?
meshes.each { |mesh| raise ArgumentError, "invalid mesh #{mesh.name}" unless mesh.valid? }
materials = ordered_materials(meshes, palette)
model = build_model(meshes, metadata, materials)
model_settings = build_bambu_model_settings(meshes, metadata, materials)
FileUtils.mkdir_p(File.dirname(path))
Zip::File.open(path, create: true) do |zip|
zip.get_output_stream("[Content_Types].xml") { |io| io.write(CONTENT_TYPES) }
zip.get_output_stream("_rels/.rels") { |io| io.write(RELATIONSHIPS) }
zip.get_output_stream("3D/3dmodel.model") { |io| io.write(model) }
zip.get_output_stream("Metadata/model_settings.config") { |io| io.write(model_settings) }
end
path
end
def self.build_model(meshes, metadata, materials = ordered_materials(meshes, []))
material_index = materials.each_with_index.to_h { |item, index| [[item["name"], item["hex"]], index] }
labels = mesh_labels(meshes, materials)
xml = +%(<?xml version="1.0" encoding="UTF-8"?>\n)
xml << %(<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">\n)
metadata.each { |key, value| xml << %(<metadata name="#{escape(key)}">#{escape(value)}</metadata>\n) }
xml << "<resources>\n"
unless materials.empty?
xml << %(<basematerials id="1">\n)
materials.each { |item| xml << %(<base name="#{escape(item["name"])}" displaycolor="#{escape(item["hex"].upcase)}"/>\n) }
xml << "</basematerials>\n"
end
meshes.each_with_index do |mesh, index|
id = index + 2
attrs = mesh.material ? %( pid="1" pindex="#{material_index.fetch([mesh.material["name"], mesh.material["hex"]])}") : ""
xml << %(<object id="#{id}" name="#{escape(labels[index])}" type="model"#{attrs}><mesh><vertices>\n)
mesh.vertices.each { |x, y, z| xml << %(<vertex x="#{fmt(x)}" y="#{fmt(y)}" z="#{fmt(z)}"/>\n) }
xml << "</vertices><triangles>\n"
mesh.triangles.each { |a, b, c| xml << %(<triangle v1="#{a}" v2="#{b}" v3="#{c}"/>\n) }
xml << "</triangles></mesh></object>\n"
end
assembly_id = meshes.length + 2
if meshes.length > 1
xml << %(<object id="#{assembly_id}" name="#{escape(wrapper_name(metadata))}" type="model"><components>\n)
meshes.each_index { |index| xml << %(<component objectid="#{index + 2}"/>\n) }
xml << "</components></object>\n"
end
xml << "</resources><build>\n"
xml << %(<item objectid="#{meshes.length > 1 ? assembly_id : 2}"/>\n)
xml << "</build></model>\n"
xml
end
def self.ordered_materials(meshes, palette)
used = meshes.filter_map(&:material).uniq { |item| [item["name"], item["hex"]] }
(palette + used).uniq { |item| [item["name"], item["hex"].upcase] }
end
def self.mesh_labels(meshes, materials)
meshes.map do |mesh|
unless mesh.material
next "#{humanize(mesh.name)} — material unassigned"
end
slot = materials.index { |item| item["name"] == mesh.material["name"] && item["hex"].casecmp?(mesh.material["hex"]) } + 1
role = if mesh.name.end_with?("_structure")
"Structure"
elsif mesh.name == "assembly_keys"
"Assembly keys"
else
"Surface"
end
"#{role} — #{mesh.material["name"]} (#{mesh.material["hex"].upcase}) — Filament #{slot}"
end
end
def self.build_bambu_model_settings(meshes, metadata, materials)
labels = mesh_labels(meshes, materials)
object_id = meshes.length > 1 ? meshes.length + 2 : 2
xml = +%(<?xml version="1.0" encoding="UTF-8"?>\n<config>\n)
xml << %( <object id="#{object_id}">\n)
xml << %( <metadata key="name" value="#{escape(wrapper_name(metadata))}"/>\n)
meshes.each_with_index do |mesh, index|
xml << %( <part id="#{index + 2}" subtype="normal_part">\n)
xml << %( <metadata key="name" value="#{escape(labels[index])}"/>\n)
if mesh.material
slot = materials.index { |item| item["name"] == mesh.material["name"] && item["hex"].casecmp?(mesh.material["hex"]) } + 1
xml << %( <metadata key="extruder" value="#{slot}"/>\n)
end
xml << " </part>\n"
end
xml << " </object>\n <assemble/>\n</config>\n"
xml
end
def self.wrapper_name(metadata)
title = metadata.fetch("Title", "Moon")
part = metadata["Part"]
part && !part.empty? ? "#{title} — #{part}" : title
end
def self.humanize(value) = value.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
def self.escape(value) = CGI.escapeHTML(value.to_s)
def self.fmt(value) = format("%.6f", value).sub(/\.?0+\z/, "")
private_class_method :escape, :fmt, :ordered_materials, :mesh_labels, :build_bambu_model_settings,
:wrapper_name, :humanize
end
end