# frozen_string_literal: true
require "cgi"
require "zip"
module MoonModel
class ThreeMF
CONTENT_TYPES = <<~XML.freeze
XML
RELATIONSHIPS = <<~XML.freeze
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 = +%(\n)
xml << %(\n)
metadata.each { |key, value| xml << %(#{escape(value)}\n) }
xml << "\n"
unless materials.empty?
xml << %(\n)
materials.each { |item| xml << %(\n) }
xml << "\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 << %(\n"
end
assembly_id = meshes.length + 2
if meshes.length > 1
xml << %(\n"
end
xml << "\n"
xml << %( \n)
xml << "\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 = +%(\n\n)
xml << %( \n \n\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