commit 518fe64b137e64897414d45c632d63155f642661 Author: Nick Estes Date: Tue Aug 11 14:41:33 2026 -0700 Initial MoonModel implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0dc93c --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Generated models and reports +/build/ +/moon/ +*.3mf + +# Local dependency and tool state +/vendor/bundle/ +/.bundle/ +/.agents/ +/.codex/ + +# Test and language caches +/coverage/ +/tmp/ +__pycache__/ +*.py[cod] +*.gem + +# Local environment, editor, and operating-system files +.env +.env.* +!.env.example +.DS_Store +*.swp +*~ diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..4ecf7cd --- /dev/null +++ b/Gemfile @@ -0,0 +1,9 @@ +source "https://rubygems.org" + +gem "highline", "~> 3.1" +gem "rubyzip", "~> 2.4" + +group :development do + gem "minitest", "~> 5.25" + gem "rake", "~> 13.2" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..d0f86e3 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,23 @@ +GEM + remote: https://rubygems.org/ + specs: + highline (3.1.2) + reline + io-console (0.8.2) + minitest (5.27.0) + rake (13.4.2) + reline (0.6.3) + io-console (~> 0.5) + rubyzip (2.4.1) + +PLATFORMS + x86_64-linux-gnu + +DEPENDENCIES + highline (~> 3.1) + minitest (~> 5.25) + rake (~> 13.2) + rubyzip (~> 2.4) + +BUNDLED WITH + 2.4.19 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d88c08f --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# MoonModel + +MoonModel creates hollow, terrain-accurate lunar globes as generic, material-aware 3MF files. It supports a one-piece display Moon, an eight-section large Moon, arbitrary named filament colors, nozzle-aware tonal dithering, optional coordinate lines, magnetic-box configuration, and a separate orbital-triskelion stand. + +## Setup and use + +```sh +bundle install +bundle exec ruby bin/moon_model +``` + +The executable begins with `require "bundler/setup"`, so all application imports use the locked Bundler environment. To reproduce a run: + +```sh +bundle exec ruby bin/moon_model --config config/example.yml --output build/example +``` + +The wizard first asks for a model name, then asks about the printer/tooling envelope, intended use, palette, grid, size or scale, vertical exaggeration, and assembly hardware. Named models are written to `build//`. Selecting an existing generated name offers to revise it, using its saved configuration as the defaults for a fresh wizard run. It displays maximum one-piece/eight-section sizes in millimeters and inches and a terrain-relief cheat sheet before generation. + +## Output + +One-piece runs create `moon.3mf`. Segmented runs create eight print files, an assembled preview, and an assembly-key file. The optional one-piece, support-free orbital stand is emitted as `orbital_triskelion_stand.3mf` without a material assignment; its footprint scales with the Moon and is capped to the usable printer bed. Its saddles use conservative terrain-radius bounds so the Moon can be rotated freely while retaining a configurable 10 mm default clearance above the central hub. Every run includes its reusable YAML configuration, a slicer/spool manifest, and assembly instructions. Revisions are generated in a temporary directory and atomically replace the prior generated directory only after successful validation; unrelated directories are never overwritten. `--output DIR` continues to override the named default location. + +Colors are closed surface inlay volumes over a continuous structural shell. Palette entry order defines filament slots 1 through N. The 3MF records portable material names/display colors and Bambu Studio part names/slot assignments; confirm the numbered slots against physical AMS or external-spool filaments. The structural shell and first-color surface remain separate bodies assigned to the same filament 1, so an N-color Moon may show N+1 bodies. The file has no fixed palette limit, though the printer and filament system will impose practical limits. + +## Modeling notes + +- The first palette entry is the structural material. +- Reflectance is mapped across the palette by perceptual luminance and deterministic spatial dithering. +- Grid lines override reflectance. They are contrasting inlays for multicolor output and debossed for one-color output. +- A requested final-envelope diameter is rescaled after exaggeration so it fits. Datum/scale mode preserves the lunar datum diameter and reports the larger resulting envelope. +- One-piece spherical prints require slicer-generated adhesion/support. Section files are moved above the build plane but may still need slicer-specific support decisions. + +See [data/SOURCES.md](data/SOURCES.md) for data provenance and [ROADMAP.md](ROADMAP.md) for milestones and acceptance gates. + +Developer visual QA renders front, back, left, right, top, bottom, and isometric views: + +```sh +blender --background --python script/render_previews.py -- build/example/moon.3mf build/example/previews +``` diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..1213b2f --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,25 @@ +# MoonModel roadmap + +## v0.1 — Generator foundation + +- [x] Bundler/HighLine wizard and reusable YAML configuration +- [x] Reduced local terrain/reflectance format and reproducible GDAL preparation +- [x] Hollow terrain mesh and generic 3MF packaging +- [x] One-piece/eight-section automatic sizing +- [x] Arbitrary material palettes, tonal dithering, coordinate grid, stand, manifests +- [x] Automated unit/package tests and multi-angle developer previews + +## v0.2 — Slicer and physical qualification + +- [ ] Record Bambu Studio and PrusaSlicer import/slice results for the multipart color fixture +- [ ] Print inlay, keyed-seam, and magnet-pocket tolerance coupons across 0.2/0.4/0.6 mm nozzles +- [ ] Tune the generated tapered keys and equatorial guide sockets from coupon measurements +- [ ] Tune the generated inward-opening round magnet bosses and add validated polarity marks + +## v1.0 — Release qualification + +- [ ] Print one complete small one-piece Moon and one eight-section assembly +- [ ] Validate large-model memory/file-size behavior at the 1,024-segment cap +- [ ] Publish slicer screenshots, physical assembly photos, verified printer profiles, and checksummed sample configs + +The runtime remains Ruby and generic 3MF. Blender is used for visual QA; OpenSCAD is optional for small mechanical coupons only. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..36753ce --- /dev/null +++ b/Rakefile @@ -0,0 +1,9 @@ +require "rake/testtask" + +Rake::TestTask.new do |task| + task.libs << "lib" + task.libs << "test" + task.pattern = "test/**/*_test.rb" +end + +task default: :test diff --git a/bin/moon_model b/bin/moon_model new file mode 100755 index 0000000..0144662 --- /dev/null +++ b/bin/moon_model @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "moon_model" + +exit MoonModel::CLI.run(ARGV) diff --git a/bin/prepare_lunar_data b/bin/prepare_lunar_data new file mode 100755 index 0000000..08a57ca --- /dev/null +++ b/bin/prepare_lunar_data @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "moon_model/data_preparer" + +exit MoonModel::DataPreparer.run(ARGV) diff --git a/config/example.yml b/config/example.yml new file mode 100644 index 0000000..59deb7b --- /dev/null +++ b/config/example.yml @@ -0,0 +1,30 @@ +printer: + name: Bambu Lab X2D + tooling: both_nozzles + build_volume_mm: [235.5, 256.0, 256.0] +nozzle_mm: 0.4 +edge_clearance_mm: 2.0 +purpose: display +assembly: auto +size: + input: diameter + value: 120 + unit: mm + meaning: final_envelope +vertical_exaggeration: 5 +palette: + - {name: Charcoal, hex: "#353535"} + - {name: Lunar gray, hex: "#9B9B94"} + - {name: Highlight, hex: "#E0DED5"} +grid: + enabled: false + interval_degrees: 30 +stand: + enabled: true + style: orbital_triskelion + base_clearance_mm: 10.0 + material: +resolution: + longitude_segments: 192 +output: + name: example_moon diff --git a/config/example_box.yml b/config/example_box.yml new file mode 100644 index 0000000..3c29c78 --- /dev/null +++ b/config/example_box.yml @@ -0,0 +1,22 @@ +printer: + name: Bambu Lab X2D + tooling: both_nozzles + build_volume_mm: [235.5, 256.0, 256.0] +nozzle_mm: 0.4 +edge_clearance_mm: 2.0 +purpose: box +assembly: eight_piece +size: {input: diameter, value: 160, unit: mm, meaning: final_envelope} +vertical_exaggeration: 5 +palette: + - {name: Mare charcoal, hex: "#303238"} + - {name: Highland silver, hex: "#CBC9BF"} +grid: + enabled: true + interval_degrees: 30 + line_width_mm: 1.2 + color_name: Highland silver +magnets: {diameter_mm: 6, thickness_mm: 3, count: 8} +stand: {enabled: true, style: orbital_triskelion, base_clearance_mm: 10.0, material: null} +resolution: {longitude_segments: 360} +output: {name: example_magnetic_box} diff --git a/data/SOURCES.md b/data/SOURCES.md new file mode 100644 index 0000000..f153b6b --- /dev/null +++ b/data/SOURCES.md @@ -0,0 +1,21 @@ +# Lunar source data + +`moon_surface.bin.gz` is a printer-scale, deterministic reduction of two Lunar Reconnaissance Orbiter Camera products: + +- Terrain: **WAC GLD100 with LOLA 30 m DTM poleward of 75°**, represented locally by `WAC_GLD100_V1.0_GLOBAL_with_LOLA_30M_POLE.0032km.cub`. +- Tone: **LROC WAC normalized reflectance**, incidence 30°, emission 0°, phase 30°, represented locally by four 8-bit cloud-optimized GeoTIFF quadrants. + +The reduced asset uses the terrain grid (3,412 × 1,706). Elevation is stored as signed metre offsets from the 1,737,400 m lunar datum; reflectance is bilinearly resampled onto that grid and stored as unsigned bytes. The embedded header records source filenames and SHA-256 checksums. + +The GLD100 record is public domain and requests citation of F. Scholten, J. Oberst, K.-D. Matz, T. Roatsch, M. Wählisch, E. J. Speyerer, M. S. Robinson, and the LROC Team. See: + +- https://astrogeology.usgs.gov/search/map/moon_lroc_wac_dtm_gld100_118m +- https://lroc.im-ldi.com/images/downloads + +Rebuild with: + +```sh +bundle exec ruby bin/prepare_lunar_data +``` + +The preparation command requires GDAL; ordinary model generation does not. diff --git a/data/moon_surface.bin.gz b/data/moon_surface.bin.gz new file mode 100644 index 0000000..7d50564 Binary files /dev/null and b/data/moon_surface.bin.gz differ diff --git a/lib/moon_model.rb b/lib/moon_model.rb new file mode 100644 index 0000000..1c0ff27 --- /dev/null +++ b/lib/moon_model.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "json" +require "yaml" +require "fileutils" +require "tmpdir" +require "time" + +require "moon_model/version" +require "moon_model/config" +require "moon_model/data_set" +require "moon_model/mesh" +require "moon_model/color" +require "moon_model/sizing" +require "moon_model/stand" +require "moon_model/geometry" +require "moon_model/three_mf" +require "moon_model/generator" +require "moon_model/wizard" +require "moon_model/cli" + +module MoonModel + ROOT = File.expand_path("..", __dir__) +end diff --git a/lib/moon_model/cli.rb b/lib/moon_model/cli.rb new file mode 100644 index 0000000..7d62821 --- /dev/null +++ b/lib/moon_model/cli.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "optparse" + +module MoonModel + class CLI + def self.run(argv) + options = {} + parser = OptionParser.new do |opts| + opts.banner = "Usage: moon_model [--config FILE] [--output DIR]" + opts.on("-c", "--config FILE", "Generate non-interactively from YAML") { |v| options[:config] = v } + opts.on("-o", "--output DIR", "Output directory (default: build/)") { |v| options[:output] = v } + opts.on("--version", "Print version") { puts VERSION; return 0 } + opts.on("-h", "--help", "Show help") { puts opts; return 0 } + end + parser.parse!(argv) + config = options[:config] ? Config.new(YAML.safe_load_file(options[:config], aliases: false)) : Wizard.new.run + result = Generator.new(config, output_dir: options[:output]).generate + puts "Generated #{result[:files].length} files in #{result[:output_dir]}" + puts "Datum diameter: #{format("%.2f", result[:statistics]["diameter_mm"])} mm; final envelope: #{format("%.2f", result[:statistics]["final_envelope_mm"])} mm; relief: #{format("%.3f", result[:statistics]["relief_range_mm"])} mm" + 0 + rescue OptionParser::ParseError, ArgumentError, Errno::ENOENT, Psych::Exception => e + warn "moon_model: #{e.message}" + 2 + end + end +end diff --git a/lib/moon_model/color.rb b/lib/moon_model/color.rb new file mode 100644 index 0000000..104c0d8 --- /dev/null +++ b/lib/moon_model/color.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module MoonModel + module Color + module_function + + def normalize_hex(value) = value.upcase + + def luminance(hex) + rgb = hex.delete_prefix("#").scan(/../).map { |part| part.to_i(16) / 255.0 } + linear = rgb.map { |v| v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055)**2.4 } + 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] + end + + def ordered_palette(palette) = palette.sort_by { |item| luminance(item["hex"]) } + + def choose(reflectance, palette, lat_index, lon_index) + return palette.first if palette.length == 1 + + value = [[reflectance, 0.0].max, 1.0].min * (palette.length - 1) + low = value.floor + high = [low + 1, palette.length - 1].min + threshold = value - low + noise = hash01(lat_index, lon_index) + palette[noise < threshold ? high : low] + end + + # Stable spatial threshold; avoids traversal-order seams at longitude 0. + def hash01(a, b) + n = ((a * 73_856_093) ^ (b * 19_349_663)) & 0xffff_ffff + n ^= n >> 13 + n = (n * 1_274_126_177) & 0xffff_ffff + n.to_f / 0xffff_ffff + end + end +end diff --git a/lib/moon_model/config.rb b/lib/moon_model/config.rb new file mode 100644 index 0000000..1508c94 --- /dev/null +++ b/lib/moon_model/config.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module MoonModel + class Config + MOON_DATUM_DIAMETER_MM = 3_474_800_000.0 + X2D = { + "name" => "Bambu Lab X2D", + "tooling" => "both_nozzles", + "build_volume_mm" => [235.5, 256.0, 256.0], + "main_only_volume_mm" => [256.0, 256.0, 260.0] + }.freeze + + DEFAULTS = { + "printer" => X2D, + "nozzle_mm" => 0.4, + "edge_clearance_mm" => 2.0, + "purpose" => "display", + "assembly" => "auto", + "size" => { "input" => "diameter", "value" => 120.0, "unit" => "mm", "meaning" => "final_envelope" }, + "vertical_exaggeration" => 1.0, + "palette" => [{ "name" => "Moon gray", "hex" => "#A9A9A9" }], + "grid" => { "enabled" => false, "interval_degrees" => 30.0 }, + "shell" => {}, + "magnets" => { "diameter_mm" => 6.0, "thickness_mm" => 3.0, "count" => 6 }, + "stand" => { "enabled" => false, "style" => "orbital_triskelion", "base_clearance_mm" => 10.0, "material" => nil }, + "resolution" => { "longitude_segments" => nil }, + "output" => { "name" => "moon" } + }.freeze + + attr_reader :data + + def initialize(values = {}) + @data = deep_merge(Marshal.load(Marshal.dump(DEFAULTS)), stringify(values)) + derive! + validate! + end + + def [](key) = data.fetch(key.to_s) + + def to_h = Marshal.load(Marshal.dump(data)) + + def save(path) + File.write(path, YAML.dump(to_h)) + end + + def diameter_mm + size = self["size"] + return MOON_DATUM_DIAMETER_MM / Float(size["value"]) if size["input"] == "scale" + + value = Float(size["value"]) + size["unit"] == "in" ? value * 25.4 : value + end + + def build_volume + self["printer"]["build_volume_mm"].map(&:to_f) + end + + def segmented? + self["purpose"] == "box" || self["assembly"] == "eight_piece" || + (self["assembly"] == "auto" && diameter_mm > maximum_diameter("one_piece")) + end + + def maximum_diameter(mode) + usable = build_volume.map { |v| v - 2.0 * self["edge_clearance_mm"] } + mode == "one_piece" ? usable.min : 2.0 * usable.min + end + + private + + def derive! + nozzle = Float(data["nozzle_mm"]) + shell = data["shell"] + shell["wall_mm"] ||= 4.0 * nozzle + shell["inlay_depth_mm"] ||= 2.0 * nozzle + shell["minimum_feature_mm"] ||= 2.0 * nozzle + shell["joint_clearance_mm"] ||= [0.2, 0.5 * nozzle].max + data["grid"]["line_width_mm"] ||= 2.0 * nozzle + data["grid"]["depth_mm"] ||= nozzle + data["size"]["meaning"] = "datum" if data["size"]["input"] == "scale" + end + + def validate! + raise ArgumentError, "nozzle must be positive" unless self["nozzle_mm"].to_f.positive? + raise ArgumentError, "build volume requires three positive dimensions" unless build_volume.length == 3 && build_volume.all?(&:positive?) + raise ArgumentError, "purpose must be display or box" unless %w[display box].include?(self["purpose"]) + raise ArgumentError, "assembly must be auto, one_piece, or eight_piece" unless %w[auto one_piece eight_piece].include?(self["assembly"]) + raise ArgumentError, "unsupported stand style" unless self["stand"]["style"] == "orbital_triskelion" + raise ArgumentError, "stand base clearance must be nonnegative" if Float(self["stand"]["base_clearance_mm"]).negative? + raise ArgumentError, "palette must contain at least one color" if self["palette"].empty? + self["palette"].each do |color| + raise ArgumentError, "invalid color #{color.inspect}" unless color["name"].to_s.match?(/\S/) && color["hex"].to_s.match?(/\A#[0-9a-fA-F]{6}\z/) + end + raise ArgumentError, "diameter/scale must be positive" unless diameter_mm.positive? + raise ArgumentError, "vertical exaggeration must be nonnegative" if self["vertical_exaggeration"].to_f.negative? + end + + def deep_merge(left, right) + left.merge(right) { |_key, a, b| a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : b } + end + + def stringify(value) + case value + when Hash then value.to_h { |key, val| [key.to_s, stringify(val)] } + when Array then value.map { |val| stringify(val) } + else value + end + end + end +end diff --git a/lib/moon_model/data_preparer.rb b/lib/moon_model/data_preparer.rb new file mode 100644 index 0000000..4d46ebe --- /dev/null +++ b/lib/moon_model/data_preparer.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "json" +require "open3" +require "optparse" +require "tmpdir" +require "zlib" +require "digest" +require "fileutils" +require_relative "data_set" + +module MoonModel + class DataPreparer + MAGIC = "MOONDATA1\n".b + DEFAULT_DTM = "/scratch0/lunaserv/layer-new/luna/wac_dtm_numeric/WAC_GLD100_V1.0_GLOBAL_with_LOLA_30M_POLE.0032km.cub" + DEFAULT_REFLECTANCE = "/scratch0/lunaserv/layer-new/luna/wac_normalized_reflectance/*.tif" + + def self.run(argv) + options = { dtm: DEFAULT_DTM, reflectance: DEFAULT_REFLECTANCE, + output: File.expand_path("../../data/moon_surface.bin.gz", __dir__) } + OptionParser.new do |opts| + opts.banner = "Usage: prepare_lunar_data [options]" + opts.on("--dtm FILE") { |v| options[:dtm] = v } + opts.on("--reflectance GLOB") { |v| options[:reflectance] = v } + opts.on("-o", "--output FILE") { |v| options[:output] = v } + end.parse!(argv) + new(**options).run + 0 + rescue StandardError => e + warn "prepare_lunar_data: #{e.message}" + 2 + end + + def initialize(dtm:, reflectance:, output:) + @dtm = dtm + @reflectance = reflectance + @output = output + end + + def run + sources = Dir.glob(@reflectance).sort + raise "DTM not found: #{@dtm}" unless File.file?(@dtm) + raise "no reflectance files matched #{@reflectance}" if sources.empty? + + info = JSON.parse(capture("gdalinfo", "-json", @dtm)) + width, height = info.fetch("size") + transform = info.fetch("geoTransform") + xmin = transform[0]; ymax = transform[3] + xmax = xmin + transform[1] * width + ymin = ymax + transform[5] * height + + Dir.mktmpdir("moon-data-") do |tmp| + elevation_path = File.join(tmp, "elevation.bin") + reflectance_path = File.join(tmp, "reflectance.bin") + vrt = File.join(tmp, "reflectance.vrt") + system!("gdal_translate", "-q", "-of", "ENVI", "-ot", "Float32", @dtm, elevation_path) + wrapped_sources = sources.map.with_index do |source, index| + source_info = JSON.parse(capture("gdalinfo", "-json", source)) + source_transform = source_info.fetch("geoTransform") + source_width, source_height = source_info.fetch("size") + sx0 = source_transform[0]; sy1 = source_transform[3] + sx1 = sx0 + source_transform[1] * source_width + sy0 = sy1 + source_transform[5] * source_height + next source unless sx0.negative? + + # The western COGs are encoded as -180..0 while GLD100 is 0..360. + shifted = File.join(tmp, "wrapped_#{index}.vrt") + circumference = 2.0 * (sx1 - sx0) + system!("gdal_translate", "-q", "-of", "VRT", "-a_ullr", + (sx0 + circumference).to_s, sy1.to_s, (sx1 + circumference).to_s, sy0.to_s, + source, shifted) + shifted + end + system!("gdalbuildvrt", "-q", vrt, *wrapped_sources) + system!("gdalwarp", "-q", "-overwrite", "-of", "ENVI", "-ot", "Byte", "-r", "bilinear", + "-te", xmin.to_s, ymin.to_s, xmax.to_s, ymax.to_s, "-ts", width.to_s, height.to_s, + vrt, reflectance_path) + elevations = File.binread(elevation_path).unpack("e*").map do |radius| + [[(radius - DataSet::DATUM_RADIUS_M).round, -32_768].max, 32_767].min + end + reflectance = File.binread(reflectance_path, width * height) + raise "unexpected reflectance byte count" unless reflectance.bytesize == width * height + histogram = Array.new(256, 0) + reflectance.each_byte { |value| histogram[value] += 1 } + percentiles = [0.01, 0.99].map do |p| + target = (p * (reflectance.bytesize - 1)).round + cumulative = 0 + histogram.index { |count| cumulative += count; cumulative > target } + end + minmax = elevations.minmax + header = { + "format_version" => 1, "title" => "Reduced LROC WAC GLD100/LOLA terrain and normalized reflectance", + "width" => width, "height" => height, "longitude" => "0 degrees east, wrapping through 360", + "latitude" => "+90 degrees north to -90 degrees south", "datum_radius_m" => DataSet::DATUM_RADIUS_M, + "elevation_encoding" => "little-endian signed int16 metres relative to datum", + "reflectance_encoding" => "uint8 normalized reflectance", "elevation_offset_range_m" => minmax, + "reflectance_percentiles" => percentiles, + "sources" => [File.basename(@dtm)] + sources.map { |path| File.basename(path) }, + "source_sha256" => ([@dtm] + sources).to_h { |path| [File.basename(path), Digest::SHA256.file(path).hexdigest] } + } + write_asset(header, elevations.pack("s<*"), reflectance) + end + puts "Wrote #{@output}" + end + + private + + def write_asset(header, elevations, reflectance) + encoded = JSON.generate(header) + FileUtils.mkdir_p(File.dirname(@output)) + Zlib::GzipWriter.open(@output) do |gzip| + gzip.mtime = 0 + gzip.write(MAGIC) + gzip.write([encoded.bytesize].pack("L<")) + gzip.write(encoded) + gzip.write(elevations) + gzip.write(reflectance) + end + end + + def capture(*command) + output, status = Open3.capture2e(*command) + raise "command failed: #{command.join(" ")}\n#{output}" unless status.success? + output + end + + def system!(*command) + success = system(*command) + raise "command failed: #{command.join(" ")}" unless success + end + end +end diff --git a/lib/moon_model/data_set.rb b/lib/moon_model/data_set.rb new file mode 100644 index 0000000..91ea9bf --- /dev/null +++ b/lib/moon_model/data_set.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "zlib" + +module MoonModel + class DataSet + MAGIC = "MOONDATA1\n".b + DATUM_RADIUS_M = 1_737_400.0 + + attr_reader :width, :height, :metadata + + def self.default_path + File.expand_path("../../data/moon_surface.bin.gz", __dir__) + end + + def initialize(path = self.class.default_path) + raise ArgumentError, "lunar data asset not found: #{path}" unless File.file?(path) + + raw = Zlib::GzipReader.open(path, &:read) + raise ArgumentError, "unrecognized lunar data asset" unless raw.start_with?(MAGIC) + + offset = MAGIC.bytesize + header_length = raw.byteslice(offset, 4).unpack1("L<") + offset += 4 + @metadata = JSON.parse(raw.byteslice(offset, header_length)) + offset += header_length + @width = metadata.fetch("width") + @height = metadata.fetch("height") + elevation_bytes = width * height * 2 + @elevation = raw.byteslice(offset, elevation_bytes).unpack("s<*") + @reflectance = raw.byteslice(offset + elevation_bytes, width * height).bytes + end + + def elevation_m(latitude, longitude) + DATUM_RADIUS_M + sample(@elevation, latitude, longitude) + end + + def reflectance(latitude, longitude) + value = sample(@reflectance, latitude, longitude) + low, high = metadata.fetch("reflectance_percentiles", [0.0, 255.0]) + [[(value - low) / (high - low), 0.0].max, 1.0].min + end + + def relief_range_m + minmax = metadata["elevation_offset_range_m"] + minmax ? minmax[1] - minmax[0] : @elevation.minmax.then { |a, b| b - a } + end + + private + + def sample(values, latitude, longitude) + x = (longitude % 360.0) / 360.0 * width + y = (90.0 - [[latitude, 90.0].min, -90.0].max) / 180.0 * (height - 1) + x0 = x.floor % width + x1 = (x0 + 1) % width + y0 = y.floor + y1 = [y0 + 1, height - 1].min + tx = x - x.floor + ty = y - y.floor + a = values[y0 * width + x0] * (1.0 - tx) + values[y0 * width + x1] * tx + b = values[y1 * width + x0] * (1.0 - tx) + values[y1 * width + x1] * tx + a * (1.0 - ty) + b * ty + end + end +end diff --git a/lib/moon_model/generator.rb b/lib/moon_model/generator.rb new file mode 100644 index 0000000..5e5eb3f --- /dev/null +++ b/lib/moon_model/generator.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +module MoonModel + class Generator + attr_reader :config, :data_set, :output_dir + + def self.safe_name(name) + value = name.to_s.strip + raise ArgumentError, "model name cannot be empty" if value.empty? || %w[. ..].include?(value) + safe = value.gsub(/[^a-zA-Z0-9_-]+/, "_").gsub(/\A_+|_+\z/, "") + raise ArgumentError, "model name must contain a letter or number" if safe.empty? + safe + end + + def self.generated_directory?(path) + config_path = File.join(path, "config.yml") + manifest_path = File.join(path, "manifest.md") + return false unless File.file?(config_path) && File.file?(manifest_path) + return false unless File.read(manifest_path, 256).include?("Generated by MoonModel") + Config.new(YAML.safe_load_file(config_path, aliases: false)) + true + rescue ArgumentError, Psych::Exception, Errno::ENOENT + false + end + + def initialize(config, data_set: DataSet.new, output_dir: nil) + @config = config + @data_set = data_set + name = self.class.safe_name(config["output"]["name"]) + @output_dir = File.expand_path(output_dir || File.expand_path("../../build/#{name}", __dir__)) + end + + def generate + result = Geometry.build(config, data_set) + validate_build_fit!(result) + if File.exist?(output_dir) && !self.class.generated_directory?(output_dir) + raise ArgumentError, "refusing to replace non-MoonModel directory: #{output_dir}" + end + parent = File.dirname(output_dir) + FileUtils.mkdir_p(parent) + temporary = Dir.mktmpdir(".#{File.basename(output_dir)}.tmp-", parent) + @write_dir = temporary + files = write_outputs(result) + install_atomically(temporary) + @write_dir = nil + files = files.map { |path| File.join(output_dir, File.basename(path)) } + { output_dir: output_dir, files: files, statistics: result.statistics } + rescue StandardError + @write_dir = nil + FileUtils.remove_entry(temporary) if defined?(temporary) && temporary && File.exist?(temporary) + raise + end + + private + + def write_outputs(result) + files = [] + if result.segmented? + result.parts.each do |part| + path = File.join(write_dir, "#{part[:name]}.3mf") + ThreeMF.write(path, print_oriented(part), metadata: metadata.merge("Part" => part[:name]), palette: config["palette"]) + files << path + end + assembled = File.join(write_dir, "moon_assembled_preview.3mf") + ThreeMF.write(assembled, result.assembled, metadata: metadata.merge("Purpose" => "assembled preview; not print layout"), palette: config["palette"]) + files << assembled + keys = File.join(write_dir, "assembly_keys.3mf") + ThreeMF.write(keys, result.keys, metadata: metadata.merge("Part" => "assembly keys"), palette: config["palette"]) + files << keys + else + moon = File.join(write_dir, "moon.3mf") + ThreeMF.write(moon, result.parts.first[:meshes], metadata: metadata, palette: config["palette"]) + files << moon + end + unless result.stand.empty? + stand = File.join(write_dir, "orbital_triskelion_stand.3mf") + ThreeMF.write(stand, result.stand, metadata: metadata.merge("Part" => "material-unassigned stand"), palette: config["palette"]) + files << stand + end + config_path = File.join(write_dir, "config.yml") + config.save(config_path) + files << config_path + manifest = write_manifest(result, files) + assembly = write_assembly(result) + files.concat([manifest, assembly]) + files + end + + def write_dir = @write_dir || output_dir + + def install_atomically(temporary) + unless File.exist?(output_dir) + rename_path(temporary, output_dir) + return + end + + backup = File.join(File.dirname(output_dir), ".#{File.basename(output_dir)}.backup-#{Process.pid}-#{rand(1_000_000)}") + rename_path(output_dir, backup) + begin + rename_path(temporary, output_dir) + FileUtils.remove_entry(backup) + rescue StandardError + rename_path(backup, output_dir) unless File.exist?(output_dir) + raise + end + end + + def rename_path(from, to) = File.rename(from, to) + + def validate_build_fit!(result) + candidates = result.segmented? ? result.parts.map { |part| print_oriented(part) } : [result.parts.first[:meshes]] + candidates.concat(result.stand.map { |mesh| [mesh] }) + candidates.each do |meshes| + bounds = Geometry.combined_bounds(meshes) + dimensions = bounds.map { |min, max| max - min } + usable = config.build_volume.map { |v| v - 2.0 * config["edge_clearance_mm"] } + next if dimensions.zip(usable).all? { |actual, limit| actual <= limit + 1e-6 } + raise ArgumentError, "generated part #{dimensions.map { |v| format("%.2f", v) }.join("×")} mm exceeds usable build volume #{usable.join("×")} mm" + end + end + + def print_oriented(part) + south = part[:name].start_with?("south") + part[:meshes].map do |source| + mesh = Marshal.load(Marshal.dump(source)) + if south + mesh.transform { |x, y, z| [x, y, -z] } + mesh.triangles.map! { |a, b, c| [a, c, b] } + end + min_z = mesh.bounds[2][0] + mesh.transform { |x, y, z| [x, y, z - min_z] } + end + end + + def metadata + { "Title" => config["output"]["name"], "Generator" => "MoonModel #{VERSION}", + "Data" => data_set.metadata.fetch("title", "LROC WAC GLD100/LOLA and normalized reflectance") } + end + + def write_manifest(result, files) + palette = config["palette"].map.with_index { |color, i| "| #{i + 1} | #{color["name"]} | `#{color["hex"].upcase}` | |" }.join("\n") + bodies = manifest_bodies(result).map do |body| + "| #{body[:role]} | #{body[:name]} | #{body[:slot] || "Unassigned"} |" + end.join("\n") + stand = result.statistics["stand"] + stand_summary = if stand + dimensions = stand["dimensions_mm"].map { |value| format("%.2f", value) }.join(" × ") + <<~TEXT + - Stand style: Orbital triskelion + - Stand safety model: Terrain envelope v2 (rotation independent) + - Stand dimensions: #{dimensions} mm + - Conservative terrain radii: #{format("%.3f", stand["minimum_terrain_radius_mm"])}–#{format("%.3f", stand["maximum_terrain_radius_mm"])} mm + - Stand contact radius/height: #{format("%.2f", stand["contact_radius_mm"])} / #{format("%.2f", stand["contact_height_mm"])} mm + - Guaranteed Moon clearance above hub/base: #{format("%.2f", stand["guaranteed_hub_clearance_mm"])} mm + - Guaranteed Moon bottom height: #{format("%.2f", stand["guaranteed_moon_bottom_height_mm"])} mm + - Non-contact arm clearance: at least #{format("%.2f", stand["noncontact_arm_clearance_mm"])} mm + - Stand footprint capped to build area: #{stand["bed_capped"] ? "yes" : "no"} + TEXT + else + "" + end + body = <<~MD + # #{config["output"]["name"]} print manifest + + Generated by MoonModel #{VERSION} at #{Time.now.iso8601}. + + - Mode: #{result.segmented? ? "eight section" : "one piece"} (#{result.mode_source}) + - Purpose: #{config["purpose"]} + - Datum diameter: #{format("%.2f", result.statistics["diameter_mm"])} mm (#{format("%.3f", result.statistics["diameter_mm"] / 25.4)} in) + - Final terrain envelope: #{format("%.2f", result.statistics["final_envelope_mm"])} mm (#{format("%.3f", result.statistics["final_envelope_mm"] / 25.4)} in) + - Vertical exaggeration: #{config["vertical_exaggeration"]}× + - Peak-to-trough relief: #{format("%.3f", result.statistics["relief_range_mm"])} mm + - Maximum safe exaggeration: #{format("%.2f", result.statistics["safe_exaggeration"])}× + - Mesh: #{result.statistics["longitude_segments"]} longitude segments + #{stand_summary.rstrip} + + ## Slicer material assignments + + | Object/material | Name | Display color | Slicer/spool | + |---:|---|---|---| + #{palette} + + ## 3MF body assignments + + | Body | Material | Filament slot | + |---|---|---:| + #{bodies} + + The structural shell remains separate from the colored surface. Both the structure and the first-color surface use filament 1, so an N-color Moon can contain N+1 bodies. The 3MF contains standard material names/colors plus Bambu Studio part names and slot assignments; confirm physical AMS or external-spool mappings in the slicer. The stand intentionally has no material assignment. + + ## Files + + #{files.map { |path| "- `#{File.basename(path)}`" }.join("\n")} + + ## Data provenance + + Terrain derives from LROC WAC GLD100 with LOLA polar coverage. Surface tones derive from the LROC WAC normalized reflectance mosaic. See `data/SOURCES.md` in the project. + MD + path = File.join(write_dir, "manifest.md") + File.write(path, body) + path + end + + def manifest_bodies(result) + meshes = result.parts.flat_map { |part| part[:meshes] } + result.keys + result.stand + rows = meshes.map do |mesh| + if mesh.material + slot = config["palette"].index do |color| + color["name"] == mesh.material["name"] && color["hex"].casecmp?(mesh.material["hex"]) + end + role = if mesh.name.end_with?("_structure") + "Structure" + elsif mesh.name == "assembly_keys" + "Assembly keys" + else + "Surface" + end + { role: role, name: "#{mesh.material["name"]} (#{mesh.material["hex"].upcase})", slot: slot && slot + 1 } + else + { role: mesh.name.tr("_", " ").split.map(&:capitalize).join(" "), name: "Material unassigned", slot: nil } + end + end + rows.uniq { |row| [row[:role], row[:name], row[:slot]] } + end + + def write_assembly(result) + text = if result.segmented? + config["purpose"] == "box" ? <<~MD : <<~MD + # Magnetic-box assembly + + 1. Dry-fit the four keyed sections of each hemisphere. + 2. Bond each hemisphere's quarter seams while its equator remains open. + 3. Install #{config["magnets"]["count"]} round magnets (#{config["magnets"]["diameter_mm"]} × #{config["magnets"]["thickness_mm"]} mm), verifying alternating polarity before adhesive cures. + 4. Bring the hemispheres together using the equatorial alignment seam. Do not glue the equator. + MD + # Glued display assembly + + 1. Dry-fit the four keyed sections of each hemisphere. + 2. Insert the internal seam keys from the open equator and bond each hemisphere. + 3. Preinstall the equatorial alignment keys in one hemisphere. + 4. Apply adhesive to the recessed equatorial land, align longitude seams, and close the second hemisphere from the outside. + MD + else + "# Printing\n\nThe one-piece hollow Moon requires slicer-generated build-plate adhesion and support appropriate to the selected material.\n" + end + path = File.join(write_dir, "ASSEMBLY.md") + File.write(path, text) + path + end + end +end diff --git a/lib/moon_model/geometry.rb b/lib/moon_model/geometry.rb new file mode 100644 index 0000000..538254c --- /dev/null +++ b/lib/moon_model/geometry.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +module MoonModel + module Geometry + Result = Struct.new(:parts, :assembled, :keys, :stand, :statistics, :mode, :mode_source, keyword_init: true) do + def segmented? = mode == "eight_piece" + end + module_function + + def build(config, data_set) + sizing = Sizing.calculate(config, data_set) + diameter = sizing.datum_diameter_mm + lon_segments = sizing.longitude_segments + lat_segments = sizing.latitude_segments + palette = Color.ordered_palette(config["palette"]) + ranges = sizing.segmented? ? section_ranges : [[-90.0, 90.0, 0.0, 360.0, "moon"]] + parts = build_parts(config, data_set, diameter, ranges, lon_segments, lat_segments, palette) + radius = diameter / 2.0 + all_meshes = parts.flat_map { |part| part[:meshes] } + keys = sizing.segmented? ? build_keys(config, radius) : [] + final_envelope = combined_bounds(all_meshes).map { |min, max| max - min }.max + stand_profile = { + "envelope_mm" => final_envelope, + "minimum_radius_mm" => sizing.minimum_radius_mm, + "maximum_radius_mm" => sizing.maximum_radius_mm + } + stand_result = config["stand"]["enabled"] ? Stand.build(config, stand_profile) : nil + stand = stand_result ? [stand_result.mesh] : [] + Result.new( + parts: parts, + assembled: all_meshes, + keys: keys, + stand: stand, + mode: sizing.mode, mode_source: sizing.mode_source, + statistics: { "diameter_mm" => diameter, "final_envelope_mm" => final_envelope, "dimensions_mm" => sizing.dimensions_mm, + "safe_exaggeration" => sizing.safe_exaggeration, "mode" => sizing.mode, "mode_source" => sizing.mode_source, + "longitude_segments" => lon_segments, + "latitude_segments" => lat_segments, "part_count" => parts.length, + "minimum_radius_mm" => sizing.minimum_radius_mm, + "maximum_radius_mm" => sizing.maximum_radius_mm, + "relief_range_mm" => relief_range_mm(config, data_set, diameter), + "stand" => stand_result&.statistics } + ) + end + + def relief_range_mm(config, data_set, datum_diameter_mm = config.diameter_mm) + data_set.relief_range_m * datum_diameter_mm / (2.0 * DataSet::DATUM_RADIUS_M) * config["vertical_exaggeration"] + end + + def scaled_datum_diameter(config, data_set) + requested = config.diameter_mm + return requested if config["size"]["meaning"] == "datum" + requested + end + + def build_parts(config, data_set, diameter, ranges, lon_segments, lat_segments, palette) + radius = diameter / 2.0 + ranges.map do |lat0, lat1, lon0, lon1, name| + build_patch(config, data_set, radius, lat0, lat1, lon0, lon1, name, lon_segments, lat_segments, palette) + end + end + + def section_ranges + %w[north south].flat_map do |hemisphere| + lat = hemisphere == "north" ? [0.0, 90.0] : [-90.0, 0.0] + 4.times.map { |q| [lat[0], lat[1], q * 90.0, (q + 1) * 90.0, "#{hemisphere}_#{q + 1}"] } + end + end + + def build_patch(config, data_set, radius, lat0, lat1, lon0, lon1, name, global_lon_segments, global_lat_segments, palette) + nlon = [(global_lon_segments * (lon1 - lon0) / 360.0).round, 2].max + nlat = [(global_lat_segments * (lat1 - lat0) / 180.0).round, 2].max + base = Mesh.new(name: "#{name}_structure", material: config["palette"].first) + color_meshes = palette.to_h { |color| [color["name"], Mesh.new(name: "#{name}_#{slug(color["name"])}", material: color)] } + multicolor = palette.length > 1 + unified_base = multicolor || !config["grid"]["enabled"] + substrate_vertices = {} + inner_vertices = {} + boundary_edges = {} + cell_index = 0 + + nlat.times do |iy| + a0 = lat0 + (lat1 - lat0) * iy / nlat.to_f + a1 = lat0 + (lat1 - lat0) * (iy + 1) / nlat.to_f + nlon.times do |ix| + o0 = lon0 + (lon1 - lon0) * ix / nlon.to_f + o1 = lon0 + (lon1 - lon0) * (ix + 1) / nlon.to_f + triangles_for_cell(a0, a1, o0, o1).each do |coords| + center_lat = coords.sum { |p| p[0] } / 3.0 + center_lon = coords.sum { |p| p[1] } / 3.0 + grid_cell = config["grid"]["enabled"] && on_grid?(config, center_lat, center_lon, radius, + (a1 - a0).abs / 2.0, (o1 - o0).abs / 2.0) + outer = coords.map { |lat, lon| point(config, data_set, radius, lat, lon, 0.0) } + deboss = !multicolor && grid_cell + surface_offset = if multicolor + -config["shell"]["inlay_depth_mm"] + elsif deboss + -config["grid"]["depth_mm"] + else + 0.0 + end + substrate = coords.map { |lat, lon| point(config, data_set, radius, lat, lon, surface_offset) } + inner = coords.map { |lat, lon| point(config, data_set, radius, lat, lon, -config["shell"]["wall_mm"]) } + if unified_base + outer_ids = coords.each_with_index.map do |coord, index| + substrate_vertices[coordinate_key(coord)] ||= base.vertex(substrate[index]) + end + inner_ids = coords.each_with_index.map do |coord, index| + inner_vertices[coordinate_key(coord)] ||= base.vertex(inner[index]) + end + base.triangle(*outer_ids) + base.triangle(inner_ids[0], inner_ids[2], inner_ids[1]) + 3.times do |edge| + next_edge = (edge + 1) % 3 + key = [coordinate_key(coords[edge]), coordinate_key(coords[next_edge])].sort + record = boundary_edges[key] + if record + record[:count] += 1 + else + boundary_edges[key] = { count: 1, outer: [outer_ids[edge], outer_ids[next_edge]], + inner: [inner_ids[edge], inner_ids[next_edge]] } + end + end + else + base.add_tri_prism(substrate, inner) + end + if multicolor + color = color_for(config, data_set, palette, center_lat, center_lon, iy, ix, grid_cell) + color_meshes.fetch(color["name"]).add_tri_prism(outer, substrate) + end + cell_index += 1 + end + end + end + if unified_base + boundary_edges.each_value do |edge| + next unless edge[:count] == 1 + base.quad(edge[:outer][0], edge[:outer][1], edge[:inner][1], edge[:inner][0]) + end + end + meshes = [base] + (multicolor ? color_meshes.values.reject { |mesh| mesh.triangles.empty? } : []) + add_equatorial_hardware(config, base, radius, lat0, lat1, lon0, lon1) + { name: name, meshes: meshes, bounds: combined_bounds(meshes), cells: cell_index } + end + + def triangles_for_cell(lat0, lat1, lon0, lon1) + if lat0 <= -90.0 + [[[lat0, lon0], [lat1, lon1], [lat1, lon0]]] + elsif lat1 >= 90.0 + [[[lat0, lon0], [lat0, lon1], [lat1, lon0]]] + else + [[[lat0, lon0], [lat0, lon1], [lat1, lon1]], [[lat0, lon0], [lat1, lon1], [lat1, lon0]]] + end + end + + def point(config, data_set, base_radius, latitude, longitude, offset_mm) + elevation = data_set.elevation_m(latitude, longitude) - DataSet::DATUM_RADIUS_M + scale = base_radius / DataSet::DATUM_RADIUS_M + r = base_radius + elevation * scale * config["vertical_exaggeration"] + offset_mm + lat = latitude * Math::PI / 180.0 + lon = longitude * Math::PI / 180.0 + [r * Math.cos(lat) * Math.cos(lon), r * Math.cos(lat) * Math.sin(lon), r * Math.sin(lat)] + end + + def color_for(config, data_set, palette, latitude, longitude, row, column, grid_cell) + if grid_cell + grid = config["grid"] + return palette.find { |item| item["name"] == grid["color_name"] } || config["palette"].last + end + Color.choose(data_set.reflectance(latitude, longitude), palette, row, column) + end + + def on_grid?(config, latitude, longitude, radius, latitude_cell_degrees = 0.0, longitude_cell_degrees = 0.0) + interval = config["grid"]["interval_degrees"].to_f + half_width_deg = config["grid"]["line_width_mm"].to_f / [radius, 0.1].max * 90.0 / Math::PI + lat_distance = (latitude / interval).round * interval - latitude + lon_distance = (longitude / interval).round * interval - longitude + cosine = Math.cos(latitude * Math::PI / 180.0).abs + lat_distance.abs <= half_width_deg + latitude_cell_degrees || + (lon_distance.abs * cosine) <= half_width_deg + longitude_cell_degrees * cosine + end + + def build_keys(config, radius) + material = config["palette"].first + clearance = config["shell"]["joint_clearance_mm"] + key = Mesh.new(name: "assembly_keys", material: material) + key_size = [[radius * 0.08, 8.0].max, 30.0].min + 8.times do |i| + x = (i % 4) * (key_size + 3.0) + y = (i / 4) * (key_size * 0.45 + 3.0) + key.add_tapered_box([x, y, key_size * 0.15], key_size, key_size * 0.38 - clearance, + key_size * 0.32 - clearance, key_size * 0.3) + end + if config["purpose"] == "display" + diameter = [4.0, radius * 0.04].min + 4.times do |i| + key.add_cylinder([i * (diameter + 3.0), key_size + 6.0, diameter], diameter / 2.0 - clearance, + diameter * 2.0, segments: 24) + end + end + [key] + end + + def add_equatorial_hardware(config, mesh, radius, lat0, lat1, lon0, lon1) + return unless lat0.zero? || lat1.zero? + + north = lat0.zero? + depth_sign = north ? 1.0 : -1.0 + wall = config["shell"]["wall_mm"] + clearance = config["shell"]["joint_clearance_mm"] + angles = if config["purpose"] == "box" + count = config["magnets"]["count"].to_i + count.times.map { |i| i * 360.0 / count } + else + [(lon0 + lon1) / 2.0] + end + angles.select { |angle| angle >= lon0 && (angle < lon1 || (lon1 == 360.0 && angle <= lon1)) }.each do |angle| + inner = if config["purpose"] == "box" + config["magnets"]["diameter_mm"].to_f / 2.0 + clearance + else + [2.0, radius * 0.02].min + clearance + end + outer = inner + [wall, 1.2].max + depth = if config["purpose"] == "box" + config["magnets"]["thickness_mm"].to_f + wall + else + inner * 2.5 + end + radial = radius - config["shell"]["wall_mm"] - outer * 0.75 + radians = angle * Math::PI / 180.0 + center = [radial * Math.cos(radians), radial * Math.sin(radians)] + mesh.add_annular_cylinder(center, inner, outer, 0.0, depth_sign * depth) + end + end + + def combined_bounds(meshes) + bounds = meshes.map(&:bounds) + 3.times.map { |axis| [bounds.map { |b| b[axis][0] }.min, bounds.map { |b| b[axis][1] }.max] } + end + + def slug(value) = value.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_|_\z/, "") + + def coordinate_key(coord) + lat, lon = coord + normalized_lon = lat.abs >= 89.999_999 ? 0.0 : lon % 360.0 + [lat.round(10), normalized_lon.round(10)] + end + end +end diff --git a/lib/moon_model/mesh.rb b/lib/moon_model/mesh.rb new file mode 100644 index 0000000..09fc984 --- /dev/null +++ b/lib/moon_model/mesh.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module MoonModel + class Mesh + attr_reader :name, :vertices, :triangles, :material + + def initialize(name:, material: nil) + @name = name + @material = material + @vertices = [] + @triangles = [] + end + + def vertex(point) + @vertices << point.map(&:to_f) + @vertices.length - 1 + end + + def triangle(a, b, c) + @triangles << [a, b, c] + end + + def quad(a, b, c, d, flip: false) + flip ? (triangle(a, c, b); triangle(a, d, c)) : (triangle(a, b, c); triangle(a, c, d)) + end + + def add_box(center, size) + cx, cy, cz = center + sx, sy, sz = size.map { |v| v / 2.0 } + ids = [[-sx,-sy,-sz],[sx,-sy,-sz],[sx,sy,-sz],[-sx,sy,-sz],[-sx,-sy,sz],[sx,-sy,sz],[sx,sy,sz],[-sx,sy,sz]].map do |x,y,z| + vertex([cx+x, cy+y, cz+z]) + end + quad(ids[0], ids[3], ids[2], ids[1]); quad(ids[4], ids[5], ids[6], ids[7]) + quad(ids[0], ids[1], ids[5], ids[4]); quad(ids[1], ids[2], ids[6], ids[5]) + quad(ids[2], ids[3], ids[7], ids[6]); quad(ids[3], ids[0], ids[4], ids[7]) + end + + def add_tapered_box(center, length, width_a, width_b, height) + cx, cy, cz = center + x0 = cx - length / 2.0; x1 = cx + length / 2.0 + z0 = cz - height / 2.0; z1 = cz + height / 2.0 + ids = [[x0,cy-width_a/2,z0],[x0,cy+width_a/2,z0],[x1,cy+width_b/2,z0],[x1,cy-width_b/2,z0], + [x0,cy-width_a/2,z1],[x0,cy+width_a/2,z1],[x1,cy+width_b/2,z1],[x1,cy-width_b/2,z1]].map { |p| vertex(p) } + quad(ids[0],ids[3],ids[2],ids[1]); quad(ids[4],ids[5],ids[6],ids[7]) + quad(ids[0],ids[1],ids[5],ids[4]); quad(ids[1],ids[2],ids[6],ids[5]) + quad(ids[2],ids[3],ids[7],ids[6]); quad(ids[3],ids[0],ids[4],ids[7]) + end + + def add_cylinder(center, radius, height, segments: 24) + cx, cy, cz = center + bottom = segments.times.map { |i| a = i * 2*Math::PI/segments; vertex([cx+radius*Math.cos(a), cy+radius*Math.sin(a), cz-height/2]) } + top = segments.times.map { |i| a = i * 2*Math::PI/segments; vertex([cx+radius*Math.cos(a), cy+radius*Math.sin(a), cz+height/2]) } + cb = vertex([cx,cy,cz-height/2]); ct = vertex([cx,cy,cz+height/2]) + segments.times do |i| + j = (i+1)%segments + triangle(cb,bottom[j],bottom[i]); triangle(ct,top[i],top[j]); quad(bottom[i],bottom[j],top[j],top[i]) + end + end + + def add_annular_cylinder(center, inner_radius, outer_radius, z0, z1, segments: 32) + cx, cy = center + oi=[]; oo=[]; ti=[]; to=[] + segments.times do |i| + a=i*2*Math::PI/segments + oi << vertex([cx+inner_radius*Math.cos(a),cy+inner_radius*Math.sin(a),z0]) + oo << vertex([cx+outer_radius*Math.cos(a),cy+outer_radius*Math.sin(a),z0]) + ti << vertex([cx+inner_radius*Math.cos(a),cy+inner_radius*Math.sin(a),z1]) + to << vertex([cx+outer_radius*Math.cos(a),cy+outer_radius*Math.sin(a),z1]) + end + segments.times do |i| + j=(i+1)%segments + quad(oo[i],oo[j],to[j],to[i]); quad(oi[i],ti[i],ti[j],oi[j]) + quad(oi[i],oi[j],oo[j],oo[i]); quad(ti[i],to[i],to[j],ti[j]) + end + end + + def add_tri_prism(outer, inner) + o = outer.map { |p| vertex(p) } + i = inner.map { |p| vertex(p) } + triangle(o[0], o[1], o[2]); triangle(i[0], i[2], i[1]) + 3.times { |n| quad(o[n], o[(n + 1) % 3], i[(n + 1) % 3], i[n]) } + end + + def transform + vertices.map! { |point| yield(point) } + self + end + + def bounds + axes = vertices.transpose + axes.map { |axis| [axis.min || 0.0, axis.max || 0.0] } + end + + def valid? + !vertices.empty? && triangles.all? { |tri| tri.uniq.length == 3 && tri.all? { |i| i.between?(0, vertices.length - 1) } } + end + end +end diff --git a/lib/moon_model/sizing.rb b/lib/moon_model/sizing.rb new file mode 100644 index 0000000..6488a70 --- /dev/null +++ b/lib/moon_model/sizing.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +module MoonModel + module Sizing + Result = Struct.new(:datum_diameter_mm, :final_envelope_mm, :dimensions_mm, + :minimum_radius_mm, :maximum_radius_mm, + :relief_range_mm, :safe_exaggeration, :mode, + :mode_source, :longitude_segments, :latitude_segments, + keyword_init: true) do + def segmented? = mode == "eight_piece" + + def to_h + { + "diameter_mm" => datum_diameter_mm, + "final_envelope_mm" => final_envelope_mm, + "dimensions_mm" => dimensions_mm, + "minimum_radius_mm" => minimum_radius_mm, + "maximum_radius_mm" => maximum_radius_mm, + "relief_range_mm" => relief_range_mm, + "safe_exaggeration" => safe_exaggeration, + "mode" => mode, + "mode_source" => mode_source, + "longitude_segments" => longitude_segments, + "latitude_segments" => latitude_segments + } + end + end + + module_function + + def calculate(config, data_set, validate_safety: true) + exaggeration = Float(config["vertical_exaggeration"]) + raise ArgumentError, "vertical exaggeration must be nonnegative" if exaggeration.negative? + + lon_segments = segment_count(config) + lat_segments = lon_segments / 2 + unit = sampled_bounds(data_set, exaggeration, lon_segments, lat_segments) + factor = unit.map { |min, max| max - min }.max + datum = config["size"]["meaning"] == "final_envelope" ? config.diameter_mm / factor : config.diameter_mm + dimensions = unit.map { |min, max| (max - min) * datum } + minimum_radius, maximum_radius = conservative_radial_bounds(config, data_set, datum, exaggeration, + lon_segments, lat_segments) + safe = validate_safety ? maximum_safe_exaggeration(config, data_set) : nil + if safe && exaggeration > safe + 1e-9 + raise ArgumentError, format("vertical exaggeration %.3gx is unsafe; maximum is %.3gx", exaggeration, safe) + end + + mode, source = resolve_mode(config, data_set, datum, exaggeration, lon_segments, lat_segments) + Result.new( + datum_diameter_mm: datum, + final_envelope_mm: dimensions.max, + dimensions_mm: dimensions, + minimum_radius_mm: minimum_radius, + maximum_radius_mm: maximum_radius, + relief_range_mm: data_set.relief_range_m * datum / (2.0 * DataSet::DATUM_RADIUS_M) * exaggeration, + safe_exaggeration: safe, + mode: mode, + mode_source: source, + longitude_segments: lon_segments, + latitude_segments: lat_segments + ) + end + + def conservative_radial_bounds(config, data_set, datum, exaggeration, lon_segments, lat_segments) + base_radius = datum / 2.0 + radii = (0..lat_segments).flat_map do |iy| + latitude = -90.0 + 180.0 * iy / lat_segments + (0..lon_segments).map do |ix| + longitude = 360.0 * ix / lon_segments + elevation = data_set.elevation_m(latitude, longitude) - DataSet::DATUM_RADIUS_M + base_radius + elevation * base_radius / DataSet::DATUM_RADIUS_M * exaggeration + end + end + sampled_min, sampled_max = radii.minmax + cell_diagonal = Math.hypot(180.0 / lat_segments, 360.0 / lon_segments) * Math::PI / 180.0 + facet_allowance = sampled_max * (1.0 - Math.cos(cell_diagonal)) + deboss_allowance = if config["grid"]["enabled"] && config["palette"].length == 1 + config["grid"]["depth_mm"].to_f + else + 0.0 + end + [sampled_min - facet_allowance - deboss_allowance, sampled_max] + end + + def segment_count(config) + requested = config["resolution"]["longitude_segments"]&.to_i + requested ||= [[(Math::PI * config.diameter_mm / config["shell"]["minimum_feature_mm"]).ceil, 96].max, 1024].min + requested + (8 - requested % 8) % 8 + end + + def sampled_bounds(data_set, exaggeration, lon_segments, lat_segments, range = [-90.0, 90.0, 0.0, 360.0]) + lat0, lat1, lon0, lon1 = range + nlat = [(lat_segments * (lat1 - lat0) / 180.0).round, 2].max + nlon = [(lon_segments * (lon1 - lon0) / 360.0).round, 2].max + bounds = Array.new(3) { [Float::INFINITY, -Float::INFINITY] } + (0..nlat).each do |iy| + lat = lat0 + (lat1 - lat0) * iy / nlat.to_f + (0..nlon).each do |ix| + lon = lon0 + (lon1 - lon0) * ix / nlon.to_f + offset = data_set.elevation_m(lat, lon) - DataSet::DATUM_RADIUS_M + radius = 0.5 * (1.0 + offset / DataSet::DATUM_RADIUS_M * exaggeration) + lat_r = lat * Math::PI / 180.0 + lon_r = lon * Math::PI / 180.0 + point = [radius * Math.cos(lat_r) * Math.cos(lon_r), + radius * Math.cos(lat_r) * Math.sin(lon_r), radius * Math.sin(lat_r)] + 3.times do |axis| + bounds[axis][0] = [bounds[axis][0], point[axis]].min + bounds[axis][1] = [bounds[axis][1], point[axis]].max + end + end + end + bounds + end + + def maximum_safe_exaggeration(config, data_set) + minimum_offset = data_set.metadata.fetch("elevation_offset_range_m", [-data_set.relief_range_m, 0]).first.to_f + return Float::INFINITY unless minimum_offset.negative? + + required = config["shell"]["wall_mm"] + config["shell"]["minimum_feature_mm"] + if config["size"]["meaning"] == "datum" + available = config.diameter_mm / 2.0 + return 0.0 if available <= required + return (1.0 - required / available) * DataSet::DATUM_RADIUS_M / -minimum_offset + end + + # Final-envelope sizing changes the datum radius as exaggeration changes. + low = 0.0 + high = DataSet::DATUM_RADIUS_M / -minimum_offset + # Twenty-four bisections are well beyond the precision meaningful to a printer + # while keeping the interactive preflight responsive at dense mesh settings. + 24.times do + mid = (low + high) / 2.0 + factor = sampled_bounds(data_set, mid, segment_count(config), segment_count(config) / 2).map { |a, b| b - a }.max + radius = config.diameter_mm / (2.0 * factor) + inner = radius * (1.0 + minimum_offset / DataSet::DATUM_RADIUS_M * mid) - config["shell"]["wall_mm"] + inner >= config["shell"]["minimum_feature_mm"] ? low = mid : high = mid + end + low + end + + def resolve_mode(config, data_set, datum, exaggeration, lon_segments, lat_segments) + requested = config["purpose"] == "box" ? "eight_piece" : config["assembly"] + if requested == "one_piece" + ensure_fit!(full_dimensions(data_set, datum, exaggeration, lon_segments, lat_segments), config, "one-piece Moon") + return ["one_piece", "forced"] + end + if requested == "eight_piece" + ensure_sections_fit!(config, data_set, datum, exaggeration, lon_segments, lat_segments) + return ["eight_piece", config["purpose"] == "box" ? "purpose" : "forced"] + end + + dims = full_dimensions(data_set, datum, exaggeration, lon_segments, lat_segments) + return ["one_piece", "automatic"] if fits?(dims, config) + ensure_sections_fit!(config, data_set, datum, exaggeration, lon_segments, lat_segments) + ["eight_piece", "automatic"] + end + + def full_dimensions(data_set, datum, exaggeration, lon_segments, lat_segments) + sampled_bounds(data_set, exaggeration, lon_segments, lat_segments).map { |a, b| (b - a) * datum } + end + + def ensure_sections_fit!(config, data_set, datum, exaggeration, lon_segments, lat_segments) + Geometry.section_ranges.each do |range| + dims = sampled_bounds(data_set, exaggeration, lon_segments, lat_segments, range[0, 4]).map { |a, b| (b - a) * datum } + ensure_fit!(dims, config, "eight-piece section") + end + end + + def fits?(dimensions, config) + usable = config.build_volume.map { |value| value - 2.0 * config["edge_clearance_mm"] } + dimensions.zip(usable).all? { |actual, limit| actual <= limit + 1e-6 } + end + + def ensure_fit!(dimensions, config, label) + return if fits?(dimensions, config) + usable = config.build_volume.map { |value| value - 2.0 * config["edge_clearance_mm"] } + raise ArgumentError, "#{label} #{dimensions.map { |v| format('%.2f', v) }.join('×')} mm exceeds usable build volume #{usable.join('×')} mm" + end + end +end diff --git a/lib/moon_model/stand.rb b/lib/moon_model/stand.rb new file mode 100644 index 0000000..fdb83fd --- /dev/null +++ b/lib/moon_model/stand.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +module MoonModel + module Stand + Result = Struct.new(:mesh, :statistics, keyword_init: true) + module_function + + STYLE = "orbital_triskelion" + ARM_COUNT = 3 + PATH_STATIONS = 25 + CROSS_SECTION_POINTS = 8 + + def build(config, moon_profile) + profile = normalize_profile(moon_profile) + diameter = profile.fetch("envelope_mm") + minimum_radius = profile.fetch("minimum_radius_mm") + maximum_radius = profile.fetch("maximum_radius_mm") + nozzle = Float(config["nozzle_mm"]) + usable_xy = config.build_volume.first(2).map { |value| value - 2.0 * config["edge_clearance_mm"] } + arm_width = [[0.035 * diameter, 8.0 * nozzle].max, 10.0].min + arm_height = [0.75 * arm_width, 3.0 * nozzle].max + desired_footprint = [0.68 * diameter, 12.0 * arm_width].max + footprint = [desired_footprint, usable_xy.min].min + raise ArgumentError, "printer build area is too small for a printable stand" if footprint < 8.0 * arm_width + + capped = footprint < desired_footprint - 1e-6 + footprint_radius = footprint / 2.0 + outer_radius = footprint_radius - 0.65 * arm_width + contact_radius = [0.30 * diameter, 0.82 * footprint_radius].min + hub_radius = [2.2 * arm_width, [0.10 * diameter, 0.12 * footprint].min].max + contact_radius = [contact_radius, hub_radius + 0.75 * arm_width].max + contact_radius = [contact_radius, outer_radius - arm_width].min + raise ArgumentError, "Moon is too small for the selected nozzle and stand geometry" unless contact_radius > hub_radius + + raise ArgumentError, "terrain profile has invalid radial bounds" unless minimum_radius.positive? && maximum_radius >= minimum_radius + raise ArgumentError, "stand contact radius exceeds the conservative Moon radius" unless contact_radius < minimum_radius + + base_clearance = Float(config["stand"]["base_clearance_mm"]) + approach_clearance = [1.0, 2.0 * nozzle].max + moon_center_z = maximum_radius + arm_height + base_clearance + contact_z = moon_center_z - Math.sqrt(minimum_radius**2 - contact_radius**2) + pad_thickness = [4.0 * nozzle, 0.45 * arm_width].max + pad_length = [[2.8 * arm_width, 0.12 * diameter].min, 24.0].min + pad_width = [2.0 * arm_width, 2.0 * (0.94 * minimum_radius - contact_radius)].min + pad_width = [pad_width, 1.5 * arm_width].max + rail_end_top = contact_z - 0.55 * pad_thickness + end_bottom = [rail_end_top - arm_height, 0.0].max + + mesh = Mesh.new(name: "orbital_triskelion_stand", material: nil) + mesh.add_cylinder([0, 0, arm_height / 2.0], hub_radius, arm_height, segments: 48) + maximum_slope = 0.0 + ARM_COUNT.times do |arm_index| + rotation = arm_index * 2.0 * Math::PI / ARM_COUNT + path = arm_path(hub_radius, outer_radius, contact_radius, arm_height, end_bottom, rotation) + maximum_slope = [maximum_slope, path_slope(path)].max + saddle_angle = rotation + radians(55.0) + first_vertex = mesh.vertices.length + add_swept_rail(mesh, path, arm_width, arm_height) + rail_vertices = mesh.vertices[first_vertex..] + validate_noncontact_clearance!(rail_vertices, maximum_radius, moon_center_z, contact_radius, + saddle_angle, pad_length, pad_width, approach_clearance) + add_saddle(mesh, minimum_radius, moon_center_z, contact_radius, saddle_angle, + pad_length, pad_width, pad_thickness) + end + + dimensions = mesh.bounds.map { |minimum, maximum| maximum - minimum } + Result.new( + mesh: mesh, + statistics: { + "style" => STYLE, + "dimensions_mm" => dimensions, + "footprint_mm" => dimensions.first(2).max, + "height_mm" => dimensions[2], + "safety_model" => "terrain_envelope_v2", + "minimum_terrain_radius_mm" => minimum_radius, + "maximum_terrain_radius_mm" => maximum_radius, + "contact_radius_mm" => contact_radius, + "contact_height_mm" => contact_z, + "requested_base_clearance_mm" => base_clearance, + "guaranteed_hub_clearance_mm" => base_clearance, + "guaranteed_moon_bottom_height_mm" => arm_height + base_clearance, + "moon_bottom_clearance_mm" => arm_height + base_clearance, + "noncontact_arm_clearance_mm" => approach_clearance, + "hub_height_mm" => arm_height, + "moon_center_height_mm" => moon_center_z, + "arm_width_mm" => arm_width, + "bed_capped" => capped, + "maximum_underside_slope" => maximum_slope + } + ) + end + + def normalize_profile(profile) + if profile.is_a?(Numeric) + diameter = Float(profile) + return { "envelope_mm" => diameter, "minimum_radius_mm" => diameter / 2.0, + "maximum_radius_mm" => diameter / 2.0 } + end + + values = profile.transform_keys(&:to_s) + %w[envelope_mm minimum_radius_mm maximum_radius_mm].to_h do |key| + [key, Float(values.fetch(key))] + end + end + + def arm_path(hub_radius, outer_radius, contact_radius, arm_height, end_bottom, rotation) + path = PATH_STATIONS.times.map do |index| + t = index / (PATH_STATIONS - 1.0) + if t <= 0.58 + local = smoothstep(t / 0.58) + radius = lerp(hub_radius * 0.68, outer_radius, local) + else + local = smoothstep((t - 0.58) / 0.42) + radius = lerp(outer_radius, contact_radius, local) + end + angle = rotation + radians(-25.0 + 80.0 * t) + { x: radius * Math.cos(angle), y: radius * Math.sin(angle), height: arm_height } + end + distance_to_end = 0.0 + (path.length - 1).downto(0) do |index| + if index < path.length - 1 + distance_to_end += Math.hypot(path[index + 1][:x] - path[index][:x], + path[index + 1][:y] - path[index][:y]) + end + path[index][:bottom] = [end_bottom - 0.999 * distance_to_end, 0.0].max + end + if path.first[:bottom] > 1e-6 + raise ArgumentError, "requested stand clearance cannot be reached with support-free arms inside this footprint" + end + path + end + + def validate_noncontact_clearance!(vertices, maximum_radius, moon_center_z, contact_radius, + saddle_angle, pad_length, pad_width, required_clearance) + minimum_gap = Float::INFINITY + vertices.each do |x, y, z| + next if beneath_saddle?(x, y, contact_radius, saddle_angle, pad_length, pad_width) + + radial = Math.hypot(x, y) + next if radial >= maximum_radius + moon_z = moon_center_z - Math.sqrt(maximum_radius**2 - radial**2) + minimum_gap = [minimum_gap, moon_z - z].min + end + return if minimum_gap >= required_clearance - 1e-6 + + raise ArgumentError, format("support-free arm would approach the worst-case terrain by %.2f mm; %.2f mm is required", + minimum_gap, required_clearance) + end + + def beneath_saddle?(x, y, contact_radius, angle, length, width) + center_x = contact_radius * Math.cos(angle) + center_y = contact_radius * Math.sin(angle) + dx = x - center_x + dy = y - center_y + tangent_offset = dx * -Math.sin(angle) + dy * Math.cos(angle) + radial_offset = dx * Math.cos(angle) + dy * Math.sin(angle) + (tangent_offset / (0.52 * length))**2 + (radial_offset / (0.52 * width))**2 <= 1.0 + end + + def add_swept_rail(mesh, path, width, height) + rings = path.each_with_index.map do |point, index| + previous = path[[index - 1, 0].max] + following = path[[index + 1, path.length - 1].min] + dx = following[:x] - previous[:x] + dy = following[:y] - previous[:y] + length = Math.hypot(dx, dy) + px = -dy / length + py = dx / length + cross_section(width, height).map do |side, z| + mesh.vertex([point[:x] + px * side, point[:y] + py * side, point[:bottom] + z]) + end + end + rings.each_cons(2) do |first, second| + CROSS_SECTION_POINTS.times do |index| + following = (index + 1) % CROSS_SECTION_POINTS + mesh.quad(first[index], first[following], second[following], second[index]) + end + end + cap_ring(mesh, rings.first, reverse: true) + cap_ring(mesh, rings.last, reverse: false) + end + + def cross_section(width, height) + [[-0.30 * width, 0.0], [0.30 * width, 0.0], [0.50 * width, 0.25 * height], + [0.50 * width, 0.75 * height], [0.30 * width, height], [-0.30 * width, height], + [-0.50 * width, 0.75 * height], [-0.50 * width, 0.25 * height]] + end + + def cap_ring(mesh, ring, reverse:) + center = mesh.vertex(3.times.map { |axis| ring.sum { |id| mesh.vertices[id][axis] } / ring.length.to_f }) + ring.length.times do |index| + following = (index + 1) % ring.length + reverse ? mesh.triangle(center, ring[following], ring[index]) : mesh.triangle(center, ring[index], ring[following]) + end + end + + def add_saddle(mesh, sphere_radius, moon_center_z, contact_radius, angle, length, width, thickness) + tangent = [-Math.sin(angle), Math.cos(angle)] + radial = [Math.cos(angle), Math.sin(angle)] + segments = 24 + top_center = saddle_point(sphere_radius, moon_center_z, contact_radius, angle, 0.0, 0.0, tangent, radial) + top = mesh.vertex(top_center) + bottom = mesh.vertex([top_center[0], top_center[1], top_center[2] - thickness]) + top_ring = [] + bottom_ring = [] + segments.times do |index| + theta = index * 2.0 * Math::PI / segments + u = Math.cos(theta) * length / 2.0 + v = Math.sin(theta) * width / 2.0 + point = saddle_point(sphere_radius, moon_center_z, contact_radius, angle, u, v, tangent, radial) + top_ring << mesh.vertex(point) + bottom_ring << mesh.vertex([point[0], point[1], point[2] - thickness]) + end + segments.times do |index| + following = (index + 1) % segments + mesh.triangle(top, top_ring[index], top_ring[following]) + mesh.triangle(bottom, bottom_ring[following], bottom_ring[index]) + mesh.quad(top_ring[index], bottom_ring[index], bottom_ring[following], top_ring[following]) + end + end + + def saddle_point(sphere_radius, moon_center_z, contact_radius, angle, tangent_offset, radial_offset, tangent, radial) + x = contact_radius * Math.cos(angle) + tangent[0] * tangent_offset + radial[0] * radial_offset + y = contact_radius * Math.sin(angle) + tangent[1] * tangent_offset + radial[1] * radial_offset + z = moon_center_z - Math.sqrt([sphere_radius**2 - x**2 - y**2, 0.0].max) + [x, y, z] + end + + def path_slope(path) + path.each_cons(2).map do |first, second| + horizontal = Math.hypot(second[:x] - first[:x], second[:y] - first[:y]) + horizontal.zero? ? 0.0 : (second[:bottom] - first[:bottom]).abs / horizontal + end.max || 0.0 + end + + def smoothstep(value) = value * value * (3.0 - 2.0 * value) + def lerp(from, to, fraction) = from + (to - from) * fraction + def radians(degrees) = degrees * Math::PI / 180.0 + end +end diff --git a/lib/moon_model/three_mf.rb b/lib/moon_model/three_mf.rb new file mode 100644 index 0000000..7a42eb4 --- /dev/null +++ b/lib/moon_model/three_mf.rb @@ -0,0 +1,126 @@ +# 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 diff --git a/lib/moon_model/version.rb b/lib/moon_model/version.rb new file mode 100644 index 0000000..8cd0b58 --- /dev/null +++ b/lib/moon_model/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module MoonModel + VERSION = "0.1.0" +end diff --git a/lib/moon_model/wizard.rb b/lib/moon_model/wizard.rb new file mode 100644 index 0000000..fdff86c --- /dev/null +++ b/lib/moon_model/wizard.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require "highline" + +module MoonModel + class Wizard + def initialize(io: HighLine.new, data_set: nil, output_root: File.expand_path("../../build", __dir__)) + @io = io + @data_set = data_set + @output_root = output_root + end + + def run + values = select_model + printer(values) + purpose(values) + palette(values) + grid(values) + sizing(values) + engineering(values) + Config.new(values) + end + + private + + attr_reader :io + + def data_set = @data_set ||= DataSet.new + + def select_model + default_name = "moon" + loop do + name = ask("Model name", default_name).strip + begin + safe = Generator.safe_name(name) + rescue ArgumentError => e + io.say(e.message) + next + end + path = File.join(@output_root, safe) + unless File.exist?(path) + return { "printer" => Config::X2D.dup, "output" => { "name" => name } } + end + unless Generator.generated_directory?(path) + io.say("#{path} already exists but is not a valid MoonModel output directory.") + default_name = "#{name}_2" + next + end + unless yes_no("Revise the existing #{name} model?", true) + default_name = "#{name}_2" + next + end + begin + values = YAML.safe_load_file(File.join(path, "config.yml"), aliases: false) + Config.new(values) + values["output"] ||= {} + values["output"]["name"] = name + return values + rescue ArgumentError, Psych::Exception => e + io.say("Cannot revise #{name}: #{e.message}") + default_name = "#{name}_2" + end + end + end + + def printer(values) + current = values.fetch("printer", Config::X2D) + custom = yes_no("Use a custom printer build volume?", current["tooling"] == "custom") + if custom + dimensions = current.fetch("build_volume_mm", [256.0, 256.0, 256.0]) + values["printer"] = { "name" => ask("Printer name", current.fetch("name", "Custom")), "tooling" => "custom", + "build_volume_mm" => %w[X Y Z].each_with_index.map { |axis, i| ask_float("#{axis} build dimension (mm)", dimensions[i]) } } + else + both = yes_no("Use the X2D shared two-nozzle area?", current["tooling"] != "main_only") + values["printer"] = Config::X2D.dup + unless both + values["printer"]["tooling"] = "main_only" + values["printer"]["build_volume_mm"] = Config::X2D["main_only_volume_mm"] + end + end + values["nozzle_mm"] = ask_float("Nozzle diameter (mm)", values.fetch("nozzle_mm", 0.4)) + values["edge_clearance_mm"] = ask_float("Build-edge clearance (mm)", values.fetch("edge_clearance_mm", 2.0)) + end + + def purpose(values) + current = values.fetch("purpose", "display") + previous_assembly = values.fetch("assembly", "auto") + values["purpose"] = io.choose do |m| + m.prompt = "Intended use?" + m.default = current == "box" ? "Magnetic decorative box" : "Glued display globe" + m.choice("Glued display globe") { "display" } + m.choice("Magnetic decorative box") { "box" } + end + values["assembly"] = values["purpose"] == "box" ? "eight_piece" : previous_assembly + previous_stand = values.fetch("stand", {}) + enabled = yes_no("Generate a separate orbital triskelion stand?", previous_stand["enabled"] != false) + clearance = previous_stand.fetch("base_clearance_mm", 10.0) + if enabled + clearance = ask_float("Worst-case Moon clearance above the stand hub (mm)", clearance) + io.say("Warning: clearances below 10 mm waive the recommended terrain safety margin.") if clearance < 10.0 + end + values["stand"] = { + "enabled" => enabled, + "style" => "orbital_triskelion", + "base_clearance_mm" => clearance, + "material" => previous_stand["material"] + } + end + + def palette(values) + previous = values.fetch("palette", Config::DEFAULTS["palette"]) + count = ask_float("Number of Moon colors", previous.length).round + count = ask_float("Number of Moon colors", 1).round until count.positive? + colors = count.times.map do |index| + old = previous[index] || { "name" => "Moon color #{index + 1}", "hex" => "#FFFFFF" } + { "name" => ask("Color #{index + 1} name", old["name"]), "hex" => ask_hex(old["hex"]) } + end + values["palette"] = colors + end + + def grid(values) + old = values.fetch("grid", {}) + enabled = yes_no("Add latitude/longitude lines?", old.fetch("enabled", false)) + values["grid"] = old.merge("enabled" => enabled) + return unless enabled + values["grid"]["interval_degrees"] = ask_float("Grid interval (degrees)", old.fetch("interval_degrees", 30.0)) + if values["palette"].length > 1 + names = values["palette"].map { |c| c["name"] } + default = names.include?(old["color_name"]) ? old["color_name"] : names.last + values["grid"]["color_name"] = io.choose { |m| m.prompt = "Grid color?"; m.default = default; names.each { |name| m.choice(name) { name } } } + end + end + + def sizing(values) + provisional = Config.new(values) + io.say("One-piece maximum: #{units(provisional.maximum_diameter("one_piece"))}") + io.say("Eight-section maximum: #{units(provisional.maximum_diameter("eight_piece"))}") + old = values.fetch("size", Config::DEFAULTS["size"]) + scale = yes_no("Specify size as a scale ratio?", old["input"] == "scale") + if scale + default = old["input"] == "scale" ? old["value"] : 10_000_000.0 + values["size"] = { "input" => "scale", "value" => ask_float("Scale denominator (for 1:N)", default), "unit" => "ratio", "meaning" => "datum" } + else + unit = yes_no("Enter diameter in inches?", old["unit"] == "in") ? "in" : "mm" + default_value = old["input"] == "diameter" && old["unit"] == unit ? old["value"] : (unit == "in" ? 4.0 : 120.0) + value = ask_float("Requested diameter (#{unit})", default_value) + meaning = io.choose do |m| + m.prompt = "What does that diameter mean?" + m.default = old["meaning"] == "datum" ? "Lunar datum sphere" : "Final terrain envelope" + m.choice("Final terrain envelope") { "final_envelope" } + m.choice("Lunar datum sphere") { "datum" } + end + values["size"] = { "input" => "diameter", "value" => value, "unit" => unit, "meaning" => meaning } + end + io.say("Terrain sizing at this size:") + [1, 2, 5, 10].each do |factor| + trial = Config.new(values.merge("vertical_exaggeration" => factor)) + begin + result = Sizing.calculate(trial, data_set, validate_safety: false) + io.say(" #{factor}x: relief #{format('%.3f', result.relief_range_mm)} mm; " \ + "envelope #{units(result.final_envelope_mm)}; #{result.mode.tr('_', ' ')}") + rescue ArgumentError + io.say(" #{factor}x: does not fit") + end + end + validated = nil + loop do + values["vertical_exaggeration"] = ask_float("Vertical exaggeration", values.fetch("vertical_exaggeration", 1.0)) + begin + validated = Sizing.calculate(Config.new(values), data_set) + break + rescue ArgumentError => e + io.say(e.message) + end + end + force = yes_no("Force eight-section output?", values["assembly"] == "eight_piece") + values["assembly"] = values["purpose"] == "box" || force ? "eight_piece" : "auto" + summary = values["assembly"] == "auto" || values["assembly"] == validated.mode ? validated : Sizing.calculate(Config.new(values), data_set) + io.say("Datum diameter: #{units(summary.datum_diameter_mm)}") + io.say("Finished envelope: #{units(summary.final_envelope_mm)}; relief #{format('%.3f', summary.relief_range_mm)} mm") + io.say("Maximum safe exaggeration: #{format('%.2f', summary.safe_exaggeration)}x; mode: #{summary.mode.tr('_', ' ')} (#{summary.mode_source})") + end + + def engineering(values) + config = Config.new(values) + io.say("Derived wall #{config["shell"]["wall_mm"]} mm; inlay/feature #{config["shell"]["inlay_depth_mm"]} mm; joint clearance #{config["shell"]["joint_clearance_mm"]} mm.") + return unless values["purpose"] == "box" + old = values.fetch("magnets", Config::DEFAULTS["magnets"]) + values["magnets"] = { "diameter_mm" => ask_float("Magnet diameter (mm)", old["diameter_mm"]), + "thickness_mm" => ask_float("Magnet thickness (mm)", old["thickness_mm"]), + "count" => ask_float("Magnet count", old["count"]).round } + end + + def ask(prompt, default) = io.ask("#{prompt} [#{default}]: ") { |q| q.default = default } + def ask_float(prompt, default) = io.ask("#{prompt} [#{default}]: ", Float) { |q| q.default = default } + def ask_hex(default) = io.ask("Hex color [#{default}]: ") { |q| q.default = default; q.validate = /\A#[0-9a-fA-F]{6}\z/ } + def yes_no(prompt, default) = io.agree("#{prompt} (y/n) ") { |q| q.default = default ? "yes" : "no" } + def units(mm) = format("%.1f mm / %.2f in", mm, mm / 25.4) + end +end diff --git a/script/render_previews.py b/script/render_previews.py new file mode 100644 index 0000000..5768c4c --- /dev/null +++ b/script/render_previews.py @@ -0,0 +1,111 @@ +"""Render seven validation views of a generated 3MF with Blender. + +Usage: blender --background --python script/render_previews.py -- model.3mf output_dir +""" + +import math +import os +import sys +import zipfile +import xml.etree.ElementTree as ET + +import bpy +from mathutils import Vector + + +def args(): + marker = sys.argv.index("--") + return os.path.abspath(sys.argv[marker + 1]), os.path.abspath(sys.argv[marker + 2]) + + +source, output = args() +os.makedirs(output, exist_ok=True) +bpy.ops.object.select_all(action="SELECT") +bpy.ops.object.delete(use_global=False) + + +def import_3mf(path): + with zipfile.ZipFile(path) as package: + root = ET.fromstring(package.read("3D/3dmodel.model")) + namespace = {"m": "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"} + materials = [] + bases = root.find("m:resources/m:basematerials", namespace) + if bases is not None: + for base in bases: + color = base.attrib.get("displaycolor", "#B0B0B0").lstrip("#")[:6] + rgba = tuple(int(color[i:i+2], 16) / 255 for i in (0, 2, 4)) + (1.0,) + material = bpy.data.materials.new(base.attrib.get("name", "material")) + material.diffuse_color = rgba + materials.append(material) + for element in root.findall("m:resources/m:object", namespace): + mesh_node = element.find("m:mesh", namespace) + if mesh_node is None: + continue + vertices = [(float(v.attrib["x"]), float(v.attrib["y"]), float(v.attrib["z"])) + for v in mesh_node.findall("m:vertices/m:vertex", namespace)] + faces = [(int(t.attrib["v1"]), int(t.attrib["v2"]), int(t.attrib["v3"])) + for t in mesh_node.findall("m:triangles/m:triangle", namespace)] + data = bpy.data.meshes.new(element.attrib.get("name", "mesh")) + data.from_pydata(vertices, [], faces) + data.update() + obj = bpy.data.objects.new(element.attrib.get("name", "object"), data) + bpy.context.collection.objects.link(obj) + if materials and "pindex" in element.attrib: + obj.data.materials.append(materials[int(element.attrib["pindex"])]) + + +import_3mf(source) + +objects = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"] +if not objects: + raise RuntimeError(f"No mesh objects imported from {source}") + +corners = [] +for obj in objects: + corners.extend(obj.matrix_world @ Vector(corner) for corner in obj.bound_box) +minimum = Vector((min(p.x for p in corners), min(p.y for p in corners), min(p.z for p in corners))) +maximum = Vector((max(p.x for p in corners), max(p.y for p in corners), max(p.z for p in corners))) +center = (minimum + maximum) / 2 +extent = max(maximum - minimum) + +world = bpy.context.scene.world +world.color = (0.035, 0.035, 0.035) +bpy.context.scene.render.engine = "BLENDER_EEVEE" +bpy.context.scene.render.resolution_x = 700 +bpy.context.scene.render.resolution_y = 700 +bpy.context.scene.render.resolution_percentage = 100 +bpy.context.scene.render.image_settings.file_format = "PNG" +bpy.context.scene.render.film_transparent = False + +camera_data = bpy.data.cameras.new("Validation camera") +camera = bpy.data.objects.new("Validation camera", camera_data) +bpy.context.collection.objects.link(camera) +bpy.context.scene.camera = camera +camera.data.type = "ORTHO" +camera.data.ortho_scale = extent * 1.25 + +for energy, direction in [(1800, Vector((1, -1, 2))), (900, Vector((-2, 1, 0.5)) )]: + light_data = bpy.data.lights.new(name="Area", type="AREA") + light_data.energy = energy + light_data.shape = "DISK" + light_data.size = extent + light = bpy.data.objects.new(name="Area", object_data=light_data) + light.location = center + direction.normalized() * extent * 2 + bpy.context.collection.objects.link(light) + +views = { + "front": Vector((0, -1, 0)), + "back": Vector((0, 1, 0)), + "left": Vector((-1, 0, 0)), + "right": Vector((1, 0, 0)), + "top": Vector((0, 0, 1)), + "bottom": Vector((0, 0, -1)), + "iso": Vector((1, -1, 0.8)).normalized(), +} +for name, direction in views.items(): + camera.location = center + direction * extent * 3 + camera.rotation_euler = (center - camera.location).to_track_quat("-Z", "Y").to_euler() + bpy.context.scene.render.filepath = os.path.join(output, f"{name}.png") + bpy.ops.render.render(write_still=True) + +print(f"Rendered {len(views)} views to {output}") diff --git a/test/color_test.rb b/test/color_test.rb new file mode 100644 index 0000000..2610adc --- /dev/null +++ b/test/color_test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "test_helper" + +class ColorTest < Minitest::Test + def test_orders_by_luminance_and_is_deterministic + palette = [{ "name" => "white", "hex" => "#FFFFFF" }, { "name" => "black", "hex" => "#000000" }] + ordered = MoonModel::Color.ordered_palette(palette) + assert_equal %w[black white], ordered.map { |c| c["name"] } + assert_equal MoonModel::Color.choose(0.4, ordered, 10, 11), MoonModel::Color.choose(0.4, ordered, 10, 11) + end +end diff --git a/test/config_test.rb b/test/config_test.rb new file mode 100644 index 0000000..220ea2c --- /dev/null +++ b/test/config_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "test_helper" + +class ConfigTest < Minitest::Test + def test_x2d_maxima_and_derived_defaults + config = MoonModel::Config.new + assert_in_delta 231.5, config.maximum_diameter("one_piece"), 0.001 + assert_in_delta 463.0, config.maximum_diameter("eight_piece"), 0.001 + assert_in_delta 1.6, config["shell"]["wall_mm"], 0.001 + assert_in_delta 0.8, config["shell"]["minimum_feature_mm"], 0.001 + assert_in_delta 10.0, config["stand"]["base_clearance_mm"], 0.001 + end + + def test_scale_ratio_uses_lunar_datum + config = MoonModel::Config.new("size" => { "input" => "scale", "value" => 10_000_000 }) + assert_in_delta 347.48, config.diameter_mm, 0.001 + assert_equal "datum", config["size"]["meaning"] + assert config.segmented? + end + + def test_box_forces_segments + assert MoonModel::Config.new("purpose" => "box", "size" => { "value" => 80 }).segmented? + end + + def test_rejects_bad_color + assert_raises(ArgumentError) { MoonModel::Config.new("palette" => [{ "name" => "bad", "hex" => "gray" }]) } + end + + def test_rejects_negative_stand_clearance + assert_raises(ArgumentError) do + MoonModel::Config.new("stand" => { "base_clearance_mm" => -0.1 }) + end + end +end diff --git a/test/data_set_test.rb b/test/data_set_test.rb new file mode 100644 index 0000000..b67bc8f --- /dev/null +++ b/test/data_set_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "test_helper" + +class DataSetTest < Minitest::Test + def test_reads_and_wraps_data + Dir.mktmpdir do |dir| + data = MoonModel::DataSet.new(make_data(File.join(dir, "data.gz"))) + assert_equal 16, data.width + assert_in_delta data.reflectance(0, 0), data.reflectance(0, 360), 1e-9 + assert_in_delta 300, data.relief_range_m, 1e-9 + end + end +end diff --git a/test/generator_test.rb b/test/generator_test.rb new file mode 100644 index 0000000..dbab490 --- /dev/null +++ b/test/generator_test.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "test_helper" + +class GeneratorTest < Minitest::Test + def test_one_piece_multicolor_package + Dir.mktmpdir do |dir| + data = MoonModel::DataSet.new(make_data(File.join(dir, "data.gz"))) + config = MoonModel::Config.new( + "size" => { "value" => 40 }, "resolution" => { "longitude_segments" => 24 }, + "palette" => [{ "name" => "dark", "hex" => "#222222" }, { "name" => "light", "hex" => "#DDDDDD" }], + "grid" => { "enabled" => true, "color_name" => "light" }, "output" => { "name" => "test" } + ) + result = MoonModel::Generator.new(config, data_set: data, output_dir: File.join(dir, "out")).generate + path = File.join(result[:output_dir], "moon.3mf") + assert File.file?(path) + Zip::File.open(path) do |zip| + model = zip.read("3D/3dmodel.model") + assert_includes model, "" + end + assert File.file?(File.join(result[:output_dir], "manifest.md")) + assert_in_delta 40.0, result[:statistics]["final_envelope_mm"], 0.01 + end + end + + def test_segmented_box_outputs_parts_keys_and_stand + Dir.mktmpdir do |dir| + data = MoonModel::DataSet.new(make_data(File.join(dir, "data.gz"))) + config = MoonModel::Config.new("purpose" => "box", "size" => { "value" => 50 }, + "stand" => { "enabled" => true }, + "resolution" => { "longitude_segments" => 24 }) + result = MoonModel::Generator.new(config, data_set: data, output_dir: File.join(dir, "out")).generate + assert_equal 8, Dir[File.join(result[:output_dir], "north_*.3mf")].length + Dir[File.join(result[:output_dir], "south_*.3mf")].length + assert File.file?(File.join(result[:output_dir], "assembly_keys.3mf")) + assert File.file?(File.join(result[:output_dir], "orbital_triskelion_stand.3mf")) + manifest = File.read(File.join(result[:output_dir], "manifest.md")) + assert_includes manifest, "Stand style: Orbital triskelion" + assert_includes manifest, "Terrain envelope v2 (rotation independent)" + assert_includes manifest, "Guaranteed Moon clearance above hub/base: 10.00 mm" + assert_includes manifest, "Stand dimensions:" + end + end + + def test_invalid_preflight_does_not_create_output_directory + Dir.mktmpdir do |dir| + data = MoonModel::DataSet.new(make_data(File.join(dir, "data.gz"))) + config = MoonModel::Config.new("size" => { "value" => 40, "meaning" => "datum" }, + "vertical_exaggeration" => 100_000, + "resolution" => { "longitude_segments" => 24 }) + output = File.join(dir, "out") + + assert_raises(ArgumentError) { MoonModel::Generator.new(config, data_set: data, output_dir: output).generate } + refute Dir.exist?(output) + end + end + + def test_revision_atomically_replaces_obsolete_files + Dir.mktmpdir do |dir| + data = MoonModel::DataSet.new(make_data(File.join(dir, "data.gz"))) + output = File.join(dir, "named_moon") + common = { "size" => { "value" => 40 }, "resolution" => { "longitude_segments" => 24 }, + "output" => { "name" => "Named Moon" } } + MoonModel::Generator.new(MoonModel::Config.new(common), data_set: data, output_dir: output).generate + assert File.file?(File.join(output, "moon.3mf")) + + revised = MoonModel::Config.new(common.merge("assembly" => "eight_piece")) + MoonModel::Generator.new(revised, data_set: data, output_dir: output).generate + + refute File.exist?(File.join(output, "moon.3mf")) + assert File.file?(File.join(output, "north_1.3mf")) + assert MoonModel::Generator.generated_directory?(output) + end + end + + def test_refuses_to_replace_unrelated_directory + Dir.mktmpdir do |dir| + output = File.join(dir, "not_a_model") + Dir.mkdir(output) + File.write(File.join(output, "keep.txt"), "important") + data = MoonModel::DataSet.new(make_data(File.join(dir, "data.gz"))) + config = MoonModel::Config.new("size" => { "value" => 40 }, + "resolution" => { "longitude_segments" => 24 }) + + assert_raises(ArgumentError) { MoonModel::Generator.new(config, data_set: data, output_dir: output).generate } + assert_equal "important", File.read(File.join(output, "keep.txt")) + end + end + + def test_failed_atomic_install_restores_previous_model + Dir.mktmpdir do |dir| + data = MoonModel::DataSet.new(make_data(File.join(dir, "data.gz"))) + output = File.join(dir, "recoverable") + common = { "size" => { "value" => 40 }, "resolution" => { "longitude_segments" => 24 }, + "output" => { "name" => "Recoverable" } } + MoonModel::Generator.new(MoonModel::Config.new(common), data_set: data, output_dir: output).generate + original = File.binread(File.join(output, "moon.3mf")) + failing_class = Class.new(MoonModel::Generator) do + private + + def rename_path(from, to) + @rename_count = @rename_count.to_i + 1 + raise IOError, "simulated install failure" if @rename_count == 2 + super + end + end + + error = assert_raises(IOError) do + failing_class.new(MoonModel::Config.new(common.merge("assembly" => "eight_piece")), + data_set: data, output_dir: output).generate + end + assert_equal "simulated install failure", error.message + assert_equal original, File.binread(File.join(output, "moon.3mf")) + assert_empty Dir[File.join(dir, ".recoverable.*")] + end + end + + def test_safe_model_name_and_default_build_directory + config = MoonModel::Config.new("output" => { "name" => "My 8-inch Moon!" }) + generator = MoonModel::Generator.new(config) + + assert_equal "My_8-inch_Moon", MoonModel::Generator.safe_name("My 8-inch Moon!") + assert_equal File.expand_path("../build/My_8-inch_Moon", __dir__), generator.output_dir + assert_raises(ArgumentError) { MoonModel::Generator.safe_name("...") } + end +end diff --git a/test/sizing_test.rb b/test/sizing_test.rb new file mode 100644 index 0000000..d95bb74 --- /dev/null +++ b/test/sizing_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "test_helper" + +class SizingTest < Minitest::Test + def setup + @data = MoonModel::DataSet.new + end + + def datum_config(exaggeration, assembly: "auto") + MoonModel::Config.new( + "size" => { "input" => "diameter", "value" => 8.0, "unit" => "in", "meaning" => "datum" }, + "vertical_exaggeration" => exaggeration, + "assembly" => assembly, + "resolution" => { "longitude_segments" => 48 } + ) + end + + def test_eight_inch_relief_uses_consistent_units + one = MoonModel::Sizing.calculate(datum_config(1), @data) + ten = MoonModel::Sizing.calculate(datum_config(10), @data) + + assert_in_delta 1.153, one.relief_range_mm, 0.001 + assert_in_delta 11.532, ten.relief_range_mm, 0.001 + assert_operator ten.final_envelope_mm, :>, one.final_envelope_mm + assert_operator ten.maximum_radius_mm, :>, ten.minimum_radius_mm + assert_operator ten.maximum_radius_mm, :>, ten.datum_diameter_mm / 2.0 + end + + + def test_single_color_grid_is_included_in_conservative_minimum_radius + plain = datum_config(5) + grid = datum_config(5) + grid = MoonModel::Config.new(grid.to_h.merge("grid" => { "enabled" => true, "depth_mm" => 0.7 })) + + plain_result = MoonModel::Sizing.calculate(plain, @data) + grid_result = MoonModel::Sizing.calculate(grid, @data) + + assert_in_delta 0.7, plain_result.minimum_radius_mm - grid_result.minimum_radius_mm, 0.001 + end + + def test_unsafe_exaggeration_is_rejected_with_limit + error = assert_raises(ArgumentError) { MoonModel::Sizing.calculate(datum_config(1000), @data) } + + assert_match(/maximum is 18[89]x/, error.message) + end + + def test_exaggerated_datum_can_automatically_select_sections + result = MoonModel::Sizing.calculate(datum_config(100), @data) + + assert result.segmented? + assert_equal "automatic", result.mode_source + end + + def test_final_envelope_remains_requested_size + config = MoonModel::Config.new( + "size" => { "input" => "diameter", "value" => 8.0, "unit" => "in", "meaning" => "final_envelope" }, + "vertical_exaggeration" => 10, + "resolution" => { "longitude_segments" => 48 } + ) + result = MoonModel::Sizing.calculate(config, @data) + + assert_in_delta 203.2, result.final_envelope_mm, 0.001 + refute result.segmented? + end +end diff --git a/test/stand_test.rb b/test/stand_test.rb new file mode 100644 index 0000000..699aad7 --- /dev/null +++ b/test/stand_test.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "test_helper" + +class StandTest < Minitest::Test + def setup + @config = MoonModel::Config.new("stand" => { "enabled" => true }) + end + + def test_orbital_stand_is_printable_and_scales_for_common_moons + [30.0, 120.0, 203.2].each do |diameter| + result = MoonModel::Stand.build(@config, diameter) + stats = result.statistics + + assert result.mesh.valid? + assert_manifold result.mesh + assert_equal "orbital_triskelion_stand", result.mesh.name + assert_equal "orbital_triskelion", stats["style"] + assert_in_delta 0.0, result.mesh.bounds[2][0], 1e-9 + assert_operator stats["maximum_underside_slope"], :<=, 1.0 + assert_operator stats["contact_radius_mm"], :<, diameter / 2.0 + assert_operator stats["contact_height_mm"], :>, stats["moon_bottom_clearance_mm"] + assert_in_delta 10.0, stats["guaranteed_hub_clearance_mm"], 1e-9 + assert_in_delta stats["hub_height_mm"] + 10.0, stats["guaranteed_moon_bottom_height_mm"], 1e-9 + refute stats["bed_capped"] + end + end + + + def test_eight_inch_exaggerated_moon_clears_hub_in_any_rotation + config = MoonModel::Config.new( + "size" => { "input" => "diameter", "value" => 8.0, "unit" => "in", "meaning" => "datum" }, + "vertical_exaggeration" => 5.0, + "stand" => { "enabled" => true }, + "resolution" => { "longitude_segments" => 96 } + ) + sizing = MoonModel::Sizing.calculate(config, MoonModel::DataSet.new) + result = MoonModel::Stand.build(config, "envelope_mm" => sizing.final_envelope_mm, + "minimum_radius_mm" => sizing.minimum_radius_mm, + "maximum_radius_mm" => sizing.maximum_radius_mm) + stats = result.statistics + + assert_equal "terrain_envelope_v2", stats["safety_model"] + assert_in_delta 10.0, stats["guaranteed_hub_clearance_mm"], 1e-9 + assert_operator stats["guaranteed_moon_bottom_height_mm"], :>=, stats["hub_height_mm"] + 10.0 + assert_operator stats["contact_height_mm"], :>, 30.0 + assert_operator stats["maximum_underside_slope"], :<=, 1.0 + end + + def test_custom_clearance_changes_guaranteed_gap + config = MoonModel::Config.new("stand" => { "enabled" => true, "base_clearance_mm" => 4.0 }) + result = MoonModel::Stand.build(config, 120.0) + + assert_in_delta 4.0, result.statistics["guaranteed_hub_clearance_mm"], 1e-9 + end + + def test_large_moon_stand_is_capped_to_usable_bed + result = MoonModel::Stand.build(@config, 463.0) + usable = @config.build_volume.first(2).map { |value| value - 2.0 * @config["edge_clearance_mm"] } + + assert result.statistics["bed_capped"] + assert_operator result.statistics["dimensions_mm"][0], :<=, usable[0] + assert_operator result.statistics["dimensions_mm"][1], :<=, usable[1] + assert_operator result.statistics["dimensions_mm"][2], :<=, @config.build_volume[2] + end + + def test_legacy_stand_configuration_gets_new_style + config = MoonModel::Config.new("stand" => { "enabled" => true, "material" => nil }) + + assert_equal "orbital_triskelion", config["stand"]["style"] + assert_equal 10.0, config["stand"]["base_clearance_mm"] + end + + private + + def assert_manifold(mesh) + edge_uses = Hash.new(0) + mesh.triangles.each do |triangle| + triangle.each_index do |index| + edge = [triangle[index], triangle[(index + 1) % triangle.length]].sort + edge_uses[edge] += 1 + end + end + + assert edge_uses.values.all? { |count| count == 2 }, "every mesh edge must belong to exactly two faces" + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..2578a17 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "tmpdir" +require "zip" +require "moon_model" + +module TestData + def make_data(path, width: 16, height: 8) + elevations = Array.new(width * height) { |i| ((i % width) - width / 2) * 20 } + reflectance = Array.new(width * height) { |i| (255.0 * (i % width) / (width - 1)).round } + header = { "format_version" => 1, "title" => "test data", "width" => width, "height" => height, + "elevation_offset_range_m" => elevations.minmax } + encoded = JSON.generate(header) + Zlib::GzipWriter.open(path) do |gzip| + gzip.write(MoonModel::DataSet::MAGIC) + gzip.write([encoded.bytesize].pack("L<")) + gzip.write(encoded) + gzip.write(elevations.pack("s<*")) + gzip.write(reflectance.pack("C*")) + end + path + end +end + +class Minitest::Test + include TestData +end diff --git a/test/three_mf_test.rb b/test/three_mf_test.rb new file mode 100644 index 0000000..4fe57d4 --- /dev/null +++ b/test/three_mf_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "test_helper" + +class ThreeMFTest < Minitest::Test + def test_bambu_part_names_and_slots_follow_palette_order + palette = [ + { "name" => "Bright & Structural", "hex" => "#EEEEEE" }, + { "name" => "Dark", "hex" => "#222222" }, + { "name" => "Mid gray", "hex" => "#888888" }, + { "name" => "Blue gray", "hex" => "#667788" } + ] + meshes = [mesh("moon_structure", palette[0], 0)] + + palette.each_with_index.map { |color, index| mesh("moon_surface_#{index + 1}", color, index + 2) } + + Dir.mktmpdir do |dir| + path = File.join(dir, "moon.3mf") + MoonModel::ThreeMF.write(path, meshes, metadata: { "Title" => "Four Color Moon" }, palette: palette) + Zip::File.open(path) do |zip| + model = zip.read("3D/3dmodel.model").force_encoding(Encoding::UTF_8) + settings = zip.read("Metadata/model_settings.config").force_encoding(Encoding::UTF_8) + + assert_operator model.index('name="Bright & Structural"'), :<, model.index('name="Dark"') + assert_includes model, 'name="Four Color Moon" type="model">' + assert_includes settings, 'value="Structure — Bright & Structural (#EEEEEE) — Filament 1"' + assert_includes settings, 'value="Surface — Bright & Structural (#EEEEEE) — Filament 1"' + assert_equal 2, settings.scan('key="extruder" value="1"').length + assert_equal 1, settings.scan('key="extruder" value="2"').length + assert_equal 1, settings.scan('key="extruder" value="3"').length + assert_equal 1, settings.scan('key="extruder" value="4"').length + end + end + end + + def test_unassigned_mesh_has_name_but_no_extruder + mesh = mesh("three_leg_stand", nil, 0) + + settings = MoonModel::ThreeMF.send(:build_bambu_model_settings, [mesh], { "Title" => "Stand" }, []) + + assert_includes settings, "Three Leg Stand — material unassigned" + refute_includes settings, 'key="extruder"' + end + + def test_unused_colors_do_not_renumber_later_palette_slots + palette = [ + { "name" => "First", "hex" => "#111111" }, + { "name" => "Unused", "hex" => "#222222" }, + { "name" => "Third", "hex" => "#333333" } + ] + meshes = [mesh("moon_structure", palette[0], 0), mesh("moon_third", palette[2], 2)] + + settings = MoonModel::ThreeMF.send(:build_bambu_model_settings, meshes, { "Title" => "Moon" }, palette) + + assert_includes settings, "Surface — Third (#333333) — Filament 3" + assert_includes settings, 'key="extruder" value="3"' + end + + private + + def mesh(name, material, offset) + MoonModel::Mesh.new(name: name, material: material).tap do |value| + value.add_box([offset, 0, 0], [1, 1, 1]) + end + end +end diff --git a/test/wizard_test.rb b/test/wizard_test.rb new file mode 100644 index 0000000..4b43b14 --- /dev/null +++ b/test/wizard_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "test_helper" +require "stringio" + +class WizardTest < Minitest::Test + def test_blank_input_accepts_false_default + answer, output = ask_yes_no("\n", false) + + refute answer + assert_includes output, "|no|" + refute_includes output, 'Please enter "yes" or "no".' + end + + def test_blank_input_accepts_true_default + answer, output = ask_yes_no("\n", true) + + assert answer + assert_includes output, "|yes|" + refute_includes output, 'Please enter "yes" or "no".' + end + + def test_explicit_answers_override_defaults + { "yes\n" => true, "y\n" => true, "no\n" => false, "n\n" => false }.each do |input, expected| + answer, = ask_yes_no(input, !expected) + assert_equal expected, answer, "expected #{input.inspect} to override the default" + end + end + + def test_selects_a_new_named_model + Dir.mktmpdir do |dir| + output = StringIO.new + wizard = MoonModel::Wizard.new(io: HighLine.new(StringIO.new("My Moon\n"), output), output_root: dir) + + values = wizard.send(:select_model) + + assert_equal "My Moon", values.dig("output", "name") + refute Dir.exist?(File.join(dir, "My_Moon")) + end + end + + def test_existing_model_can_seed_revision_defaults + Dir.mktmpdir do |dir| + model = File.join(dir, "Saved_Moon") + Dir.mkdir(model) + config = MoonModel::Config.new("vertical_exaggeration" => 5, "output" => { "name" => "Saved Moon" }) + config.save(File.join(model, "config.yml")) + File.write(File.join(model, "manifest.md"), "Generated by MoonModel test") + output = StringIO.new + wizard = MoonModel::Wizard.new(io: HighLine.new(StringIO.new("Saved Moon\n\n"), output), output_root: dir) + + values = wizard.send(:select_model) + + assert_equal 5, values["vertical_exaggeration"] + assert_equal "Saved Moon", values.dig("output", "name") + assert_includes output.string, "Revise the existing Saved Moon model?" + end + end + + def test_warns_when_user_chooses_less_than_recommended_stand_clearance + output = StringIO.new + highline = HighLine.new(StringIO.new("\n\n5\n"), output) + wizard = MoonModel::Wizard.new(io: highline) + values = { "purpose" => "display", "assembly" => "auto" } + + wizard.send(:purpose, values) + + assert_equal 5.0, values.dig("stand", "base_clearance_mm") + assert_includes output.string, "waive the recommended terrain safety margin" + end + + private + + def ask_yes_no(input, default) + output = StringIO.new + highline = HighLine.new(StringIO.new(input), output) + wizard = MoonModel::Wizard.new(io: highline) + [wizard.send(:yes_no, "Continue?", default), output.string] + end +end