# 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) mesh.vertices.each { |x, y, z| xml << %(\n) } xml << "\n" mesh.triangles.each { |a, b, c| xml << %(\n) } xml << "\n" end assembly_id = meshes.length + 2 if meshes.length > 1 xml << %(\n) meshes.each_index { |index| xml << %(\n) } 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) xml << %( \n) meshes.each_with_index do |mesh, index| xml << %( \n) xml << %( \n) if mesh.material slot = materials.index { |item| item["name"] == mesh.material["name"] && item["hex"].casecmp?(mesh.material["hex"]) } + 1 xml << %( \n) end xml << " \n" end 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