Initial MoonModel implementation
This commit is contained in:
+25
@@ -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
|
||||||
|
*~
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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
@@ -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)
|
||||||
Executable
+8
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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.
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module MoonModel
|
||||||
|
VERSION = "0.1.0"
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ThreeMFTest < Minitest::Test
|
||||||
|
def test_bambu_part_names_and_slots_follow_palette_order
|
||||||
|
palette = [
|
||||||
|
{ "name" => "Bright & Structural", "hex" => "#EEEEEE" },
|
||||||
|
{ "name" => "Dark", "hex" => "#222222" },
|
||||||
|
{ "name" => "Mid gray", "hex" => "#888888" },
|
||||||
|
{ "name" => "Blue gray", "hex" => "#667788" }
|
||||||
|
]
|
||||||
|
meshes = [mesh("moon_structure", palette[0], 0)] +
|
||||||
|
palette.each_with_index.map { |color, index| mesh("moon_surface_#{index + 1}", color, index + 2) }
|
||||||
|
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
path = File.join(dir, "moon.3mf")
|
||||||
|
MoonModel::ThreeMF.write(path, meshes, metadata: { "Title" => "Four Color Moon" }, palette: palette)
|
||||||
|
Zip::File.open(path) do |zip|
|
||||||
|
model = zip.read("3D/3dmodel.model").force_encoding(Encoding::UTF_8)
|
||||||
|
settings = zip.read("Metadata/model_settings.config").force_encoding(Encoding::UTF_8)
|
||||||
|
|
||||||
|
assert_operator model.index('name="Bright & Structural"'), :<, model.index('name="Dark"')
|
||||||
|
assert_includes model, 'name="Four Color Moon" type="model"><components>'
|
||||||
|
assert_includes settings, 'value="Structure — Bright & Structural (#EEEEEE) — Filament 1"'
|
||||||
|
assert_includes settings, 'value="Surface — Bright & Structural (#EEEEEE) — Filament 1"'
|
||||||
|
assert_equal 2, settings.scan('key="extruder" value="1"').length
|
||||||
|
assert_equal 1, settings.scan('key="extruder" value="2"').length
|
||||||
|
assert_equal 1, settings.scan('key="extruder" value="3"').length
|
||||||
|
assert_equal 1, settings.scan('key="extruder" value="4"').length
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_unassigned_mesh_has_name_but_no_extruder
|
||||||
|
mesh = mesh("three_leg_stand", nil, 0)
|
||||||
|
|
||||||
|
settings = MoonModel::ThreeMF.send(:build_bambu_model_settings, [mesh], { "Title" => "Stand" }, [])
|
||||||
|
|
||||||
|
assert_includes settings, "Three Leg Stand — material unassigned"
|
||||||
|
refute_includes settings, 'key="extruder"'
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_unused_colors_do_not_renumber_later_palette_slots
|
||||||
|
palette = [
|
||||||
|
{ "name" => "First", "hex" => "#111111" },
|
||||||
|
{ "name" => "Unused", "hex" => "#222222" },
|
||||||
|
{ "name" => "Third", "hex" => "#333333" }
|
||||||
|
]
|
||||||
|
meshes = [mesh("moon_structure", palette[0], 0), mesh("moon_third", palette[2], 2)]
|
||||||
|
|
||||||
|
settings = MoonModel::ThreeMF.send(:build_bambu_model_settings, meshes, { "Title" => "Moon" }, palette)
|
||||||
|
|
||||||
|
assert_includes settings, "Surface — Third (#333333) — Filament 3"
|
||||||
|
assert_includes settings, 'key="extruder" value="3"'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def mesh(name, material, offset)
|
||||||
|
MoonModel::Mesh.new(name: name, material: material).tap do |value|
|
||||||
|
value.add_box([offset, 0, 0], [1, 1, 1])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user