Initial MoonModel implementation

This commit is contained in:
Nick Estes
2026-08-11 14:41:33 -07:00
commit 518fe64b13
36 changed files with 2587 additions and 0 deletions
+25
View File
@@ -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
*~
+9
View File
@@ -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
+23
View File
@@ -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
+40
View File
@@ -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/<name>/`. 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
```
+25
View File
@@ -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.
+9
View File
@@ -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
Executable
+8
View File
@@ -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)
+8
View File
@@ -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)
+30
View File
@@ -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
+22
View File
@@ -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}
+21
View File
@@ -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.
Binary file not shown.
+24
View File
@@ -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
+27
View File
@@ -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/<model name>)") { |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
+36
View File
@@ -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
+109
View File
@@ -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
+132
View File
@@ -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
+65
View File
@@ -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
+251
View File
@@ -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
+249
View File
@@ -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
+98
View File
@@ -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
+180
View File
@@ -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
+240
View File
@@ -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
+126
View File
@@ -0,0 +1,126 @@
# frozen_string_literal: true
require "cgi"
require "zip"
module MoonModel
class ThreeMF
CONTENT_TYPES = <<~XML.freeze
<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
</Types>
XML
RELATIONSHIPS = <<~XML.freeze
<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
</Relationships>
XML
def self.write(path, meshes, metadata: {}, palette: [])
raise ArgumentError, "3MF needs at least one mesh" if meshes.empty?
meshes.each { |mesh| raise ArgumentError, "invalid mesh #{mesh.name}" unless mesh.valid? }
materials = ordered_materials(meshes, palette)
model = build_model(meshes, metadata, materials)
model_settings = build_bambu_model_settings(meshes, metadata, materials)
FileUtils.mkdir_p(File.dirname(path))
Zip::File.open(path, create: true) do |zip|
zip.get_output_stream("[Content_Types].xml") { |io| io.write(CONTENT_TYPES) }
zip.get_output_stream("_rels/.rels") { |io| io.write(RELATIONSHIPS) }
zip.get_output_stream("3D/3dmodel.model") { |io| io.write(model) }
zip.get_output_stream("Metadata/model_settings.config") { |io| io.write(model_settings) }
end
path
end
def self.build_model(meshes, metadata, materials = ordered_materials(meshes, []))
material_index = materials.each_with_index.to_h { |item, index| [[item["name"], item["hex"]], index] }
labels = mesh_labels(meshes, materials)
xml = +%(<?xml version="1.0" encoding="UTF-8"?>\n)
xml << %(<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">\n)
metadata.each { |key, value| xml << %(<metadata name="#{escape(key)}">#{escape(value)}</metadata>\n) }
xml << "<resources>\n"
unless materials.empty?
xml << %(<basematerials id="1">\n)
materials.each { |item| xml << %(<base name="#{escape(item["name"])}" displaycolor="#{escape(item["hex"].upcase)}"/>\n) }
xml << "</basematerials>\n"
end
meshes.each_with_index do |mesh, index|
id = index + 2
attrs = mesh.material ? %( pid="1" pindex="#{material_index.fetch([mesh.material["name"], mesh.material["hex"]])}") : ""
xml << %(<object id="#{id}" name="#{escape(labels[index])}" type="model"#{attrs}><mesh><vertices>\n)
mesh.vertices.each { |x, y, z| xml << %(<vertex x="#{fmt(x)}" y="#{fmt(y)}" z="#{fmt(z)}"/>\n) }
xml << "</vertices><triangles>\n"
mesh.triangles.each { |a, b, c| xml << %(<triangle v1="#{a}" v2="#{b}" v3="#{c}"/>\n) }
xml << "</triangles></mesh></object>\n"
end
assembly_id = meshes.length + 2
if meshes.length > 1
xml << %(<object id="#{assembly_id}" name="#{escape(wrapper_name(metadata))}" type="model"><components>\n)
meshes.each_index { |index| xml << %(<component objectid="#{index + 2}"/>\n) }
xml << "</components></object>\n"
end
xml << "</resources><build>\n"
xml << %(<item objectid="#{meshes.length > 1 ? assembly_id : 2}"/>\n)
xml << "</build></model>\n"
xml
end
def self.ordered_materials(meshes, palette)
used = meshes.filter_map(&:material).uniq { |item| [item["name"], item["hex"]] }
(palette + used).uniq { |item| [item["name"], item["hex"].upcase] }
end
def self.mesh_labels(meshes, materials)
meshes.map do |mesh|
unless mesh.material
next "#{humanize(mesh.name)} — material unassigned"
end
slot = materials.index { |item| item["name"] == mesh.material["name"] && item["hex"].casecmp?(mesh.material["hex"]) } + 1
role = if mesh.name.end_with?("_structure")
"Structure"
elsif mesh.name == "assembly_keys"
"Assembly keys"
else
"Surface"
end
"#{role} — #{mesh.material["name"]} (#{mesh.material["hex"].upcase}) — Filament #{slot}"
end
end
def self.build_bambu_model_settings(meshes, metadata, materials)
labels = mesh_labels(meshes, materials)
object_id = meshes.length > 1 ? meshes.length + 2 : 2
xml = +%(<?xml version="1.0" encoding="UTF-8"?>\n<config>\n)
xml << %( <object id="#{object_id}">\n)
xml << %( <metadata key="name" value="#{escape(wrapper_name(metadata))}"/>\n)
meshes.each_with_index do |mesh, index|
xml << %( <part id="#{index + 2}" subtype="normal_part">\n)
xml << %( <metadata key="name" value="#{escape(labels[index])}"/>\n)
if mesh.material
slot = materials.index { |item| item["name"] == mesh.material["name"] && item["hex"].casecmp?(mesh.material["hex"]) } + 1
xml << %( <metadata key="extruder" value="#{slot}"/>\n)
end
xml << " </part>\n"
end
xml << " </object>\n <assemble/>\n</config>\n"
xml
end
def self.wrapper_name(metadata)
title = metadata.fetch("Title", "Moon")
part = metadata["Part"]
part && !part.empty? ? "#{title} — #{part}" : title
end
def self.humanize(value) = value.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
def self.escape(value) = CGI.escapeHTML(value.to_s)
def self.fmt(value) = format("%.6f", value).sub(/\.?0+\z/, "")
private_class_method :escape, :fmt, :ordered_materials, :mesh_labels, :build_bambu_model_settings,
:wrapper_name, :humanize
end
end
+5
View File
@@ -0,0 +1,5 @@
# frozen_string_literal: true
module MoonModel
VERSION = "0.1.0"
end
+200
View File
@@ -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
+111
View File
@@ -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}")
+12
View File
@@ -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
+35
View File
@@ -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
+14
View File
@@ -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
+127
View File
@@ -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, "<basematerials"
assert_includes model, "displaycolor=\"#222222\""
assert_includes model, "<components>"
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
+66
View File
@@ -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
+87
View File
@@ -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
+28
View File
@@ -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
+65
View File
@@ -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 &amp; Structural"'), :<, model.index('name="Dark"')
assert_includes model, 'name="Four Color Moon" type="model"><components>'
assert_includes settings, 'value="Structure — Bright &amp; Structural (#EEEEEE) — Filament 1"'
assert_includes settings, 'value="Surface — Bright &amp; 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
+80
View File
@@ -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