tweaks
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# MoonModel
|
# 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.
|
MoonModel creates terrain-accurate lunar globes as generic, material-aware 3MF files. It supports a solid one-piece display Moon intended for slicer-controlled sparse infill, an optional hollow one-piece Moon, a hollow 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
|
## Setup and use
|
||||||
|
|
||||||
@@ -15,13 +15,13 @@ The executable begins with `require "bundler/setup"`, so all application imports
|
|||||||
bundle exec ruby bin/moon_model --config config/example.yml --output build/example
|
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.
|
The wizard first asks for a model name, then asks about the printer/tooling envelope, intended use, palette, grid, size or scale, one-piece interior, 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
|
## 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.
|
One-piece runs create `moon.3mf`. Their interior defaults to a watertight solid volume so the slicer can apply balanced sparse infill without generating inaccessible cavity supports; `shell.interior: hollow` retains the thin-wall alternative. Segmented runs remain hollow and 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.
|
Colors are closed surface inlay volumes over a continuous structural body. 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 body 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
|
## Modeling notes
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ palette:
|
|||||||
grid:
|
grid:
|
||||||
enabled: false
|
enabled: false
|
||||||
interval_degrees: 30
|
interval_degrees: 30
|
||||||
|
shell:
|
||||||
|
interior: solid
|
||||||
stand:
|
stand:
|
||||||
enabled: true
|
enabled: true
|
||||||
style: orbital_triskelion
|
style: orbital_triskelion
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ grid:
|
|||||||
interval_degrees: 30
|
interval_degrees: 30
|
||||||
line_width_mm: 1.2
|
line_width_mm: 1.2
|
||||||
color_name: Highland silver
|
color_name: Highland silver
|
||||||
|
shell: {interior: hollow}
|
||||||
magnets: {diameter_mm: 6, thickness_mm: 3, count: 8}
|
magnets: {diameter_mm: 6, thickness_mm: 3, count: 8}
|
||||||
stand: {enabled: true, style: orbital_triskelion, base_clearance_mm: 10.0, material: null}
|
stand: {enabled: true, style: orbital_triskelion, base_clearance_mm: 10.0, material: null}
|
||||||
resolution: {longitude_segments: 360}
|
resolution: {longitude_segments: 360}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ module MoonModel
|
|||||||
def derive!
|
def derive!
|
||||||
nozzle = Float(data["nozzle_mm"])
|
nozzle = Float(data["nozzle_mm"])
|
||||||
shell = data["shell"]
|
shell = data["shell"]
|
||||||
|
shell["interior"] ||= "solid"
|
||||||
shell["wall_mm"] ||= 4.0 * nozzle
|
shell["wall_mm"] ||= 4.0 * nozzle
|
||||||
shell["inlay_depth_mm"] ||= 2.0 * nozzle
|
shell["inlay_depth_mm"] ||= 2.0 * nozzle
|
||||||
shell["minimum_feature_mm"] ||= 2.0 * nozzle
|
shell["minimum_feature_mm"] ||= 2.0 * nozzle
|
||||||
@@ -84,6 +85,7 @@ module MoonModel
|
|||||||
raise ArgumentError, "build volume requires three positive dimensions" unless build_volume.length == 3 && build_volume.all?(&: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, "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, "assembly must be auto, one_piece, or eight_piece" unless %w[auto one_piece eight_piece].include?(self["assembly"])
|
||||||
|
raise ArgumentError, "shell interior must be solid or hollow" unless %w[solid hollow].include?(self["shell"]["interior"])
|
||||||
raise ArgumentError, "unsupported stand style" unless self["stand"]["style"] == "orbital_triskelion"
|
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, "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?
|
raise ArgumentError, "palette must contain at least one color" if self["palette"].empty?
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ module MoonModel
|
|||||||
|
|
||||||
- Mode: #{result.segmented? ? "eight section" : "one piece"} (#{result.mode_source})
|
- Mode: #{result.segmented? ? "eight section" : "one piece"} (#{result.mode_source})
|
||||||
- Purpose: #{config["purpose"]}
|
- Purpose: #{config["purpose"]}
|
||||||
|
- Interior: #{result.statistics["interior"] == "solid" ? "solid slicer volume (use configured infill)" : "hollow shell"}
|
||||||
- Datum diameter: #{format("%.2f", result.statistics["diameter_mm"])} mm (#{format("%.3f", result.statistics["diameter_mm"] / 25.4)} in)
|
- 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)
|
- 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"]}×
|
- Vertical exaggeration: #{config["vertical_exaggeration"]}×
|
||||||
@@ -186,7 +187,7 @@ module MoonModel
|
|||||||
|---|---|---:|
|
|---|---|---:|
|
||||||
#{bodies}
|
#{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.
|
The structural volume 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
|
||||||
|
|
||||||
@@ -241,7 +242,15 @@ module MoonModel
|
|||||||
4. Apply adhesive to the recessed equatorial land, align longitude seams, and close the second hemisphere from the outside.
|
4. Apply adhesive to the recessed equatorial land, align longitude seams, and close the second hemisphere from the outside.
|
||||||
MD
|
MD
|
||||||
else
|
else
|
||||||
"# Printing\n\nThe one-piece hollow Moon requires slicer-generated build-plate adhesion and support appropriate to the selected material.\n"
|
if result.statistics["interior"] == "solid"
|
||||||
|
<<~MD
|
||||||
|
# Printing
|
||||||
|
|
||||||
|
The one-piece Moon is a watertight solid slicer volume. Select the desired sparse infill pattern and percentage in the slicer; no internal cavity supports are required. Configure build-plate adhesion and external support for the lower surface as appropriate to the selected material and printer.
|
||||||
|
MD
|
||||||
|
else
|
||||||
|
"# Printing\n\nThe one-piece hollow Moon requires slicer-generated build-plate adhesion and support appropriate to the selected material. Internal supports may be difficult to remove and can break loose inside the finished Moon.\n"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
path = File.join(write_dir, "ASSEMBLY.md")
|
path = File.join(write_dir, "ASSEMBLY.md")
|
||||||
File.write(path, text)
|
File.write(path, text)
|
||||||
|
|||||||
+33
-20
@@ -9,12 +9,13 @@ module MoonModel
|
|||||||
|
|
||||||
def build(config, data_set)
|
def build(config, data_set)
|
||||||
sizing = Sizing.calculate(config, data_set)
|
sizing = Sizing.calculate(config, data_set)
|
||||||
|
interior = sizing.segmented? ? "hollow" : config["shell"]["interior"]
|
||||||
diameter = sizing.datum_diameter_mm
|
diameter = sizing.datum_diameter_mm
|
||||||
lon_segments = sizing.longitude_segments
|
lon_segments = sizing.longitude_segments
|
||||||
lat_segments = sizing.latitude_segments
|
lat_segments = sizing.latitude_segments
|
||||||
palette = Color.ordered_palette(config["palette"])
|
palette = Color.ordered_palette(config["palette"])
|
||||||
ranges = sizing.segmented? ? section_ranges : [[-90.0, 90.0, 0.0, 360.0, "moon"]]
|
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)
|
parts = build_parts(config, data_set, diameter, ranges, lon_segments, lat_segments, palette, interior)
|
||||||
radius = diameter / 2.0
|
radius = diameter / 2.0
|
||||||
all_meshes = parts.flat_map { |part| part[:meshes] }
|
all_meshes = parts.flat_map { |part| part[:meshes] }
|
||||||
keys = sizing.segmented? ? build_keys(config, radius) : []
|
keys = sizing.segmented? ? build_keys(config, radius) : []
|
||||||
@@ -36,6 +37,7 @@ module MoonModel
|
|||||||
"safe_exaggeration" => sizing.safe_exaggeration, "mode" => sizing.mode, "mode_source" => sizing.mode_source,
|
"safe_exaggeration" => sizing.safe_exaggeration, "mode" => sizing.mode, "mode_source" => sizing.mode_source,
|
||||||
"longitude_segments" => lon_segments,
|
"longitude_segments" => lon_segments,
|
||||||
"latitude_segments" => lat_segments, "part_count" => parts.length,
|
"latitude_segments" => lat_segments, "part_count" => parts.length,
|
||||||
|
"interior" => interior,
|
||||||
"minimum_radius_mm" => sizing.minimum_radius_mm,
|
"minimum_radius_mm" => sizing.minimum_radius_mm,
|
||||||
"maximum_radius_mm" => sizing.maximum_radius_mm,
|
"maximum_radius_mm" => sizing.maximum_radius_mm,
|
||||||
"relief_range_mm" => relief_range_mm(config, data_set, diameter),
|
"relief_range_mm" => relief_range_mm(config, data_set, diameter),
|
||||||
@@ -53,10 +55,10 @@ module MoonModel
|
|||||||
requested
|
requested
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_parts(config, data_set, diameter, ranges, lon_segments, lat_segments, palette)
|
def build_parts(config, data_set, diameter, ranges, lon_segments, lat_segments, palette, interior)
|
||||||
radius = diameter / 2.0
|
radius = diameter / 2.0
|
||||||
ranges.map do |lat0, lat1, lon0, lon1, name|
|
ranges.map do |lat0, lat1, lon0, lon1, name|
|
||||||
build_patch(config, data_set, radius, lat0, lat1, lon0, lon1, name, lon_segments, lat_segments, palette)
|
build_patch(config, data_set, radius, lat0, lat1, lon0, lon1, name, lon_segments, lat_segments, palette, interior)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -67,13 +69,14 @@ module MoonModel
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_patch(config, data_set, radius, lat0, lat1, lon0, lon1, name, global_lon_segments, global_lat_segments, palette)
|
def build_patch(config, data_set, radius, lat0, lat1, lon0, lon1, name, global_lon_segments, global_lat_segments, palette, interior)
|
||||||
nlon = [(global_lon_segments * (lon1 - lon0) / 360.0).round, 2].max
|
nlon = [(global_lon_segments * (lon1 - lon0) / 360.0).round, 2].max
|
||||||
nlat = [(global_lat_segments * (lat1 - lat0) / 180.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)
|
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)] }
|
color_meshes = palette.to_h { |color| [color["name"], Mesh.new(name: "#{name}_#{slug(color["name"])}", material: color)] }
|
||||||
multicolor = palette.length > 1
|
multicolor = palette.length > 1
|
||||||
unified_base = multicolor || !config["grid"]["enabled"]
|
solid = interior == "solid"
|
||||||
|
unified_base = solid || multicolor || !config["grid"]["enabled"]
|
||||||
substrate_vertices = {}
|
substrate_vertices = {}
|
||||||
inner_vertices = {}
|
inner_vertices = {}
|
||||||
boundary_edges = {}
|
boundary_edges = {}
|
||||||
@@ -99,26 +102,35 @@ module MoonModel
|
|||||||
else
|
else
|
||||||
0.0
|
0.0
|
||||||
end
|
end
|
||||||
substrate = coords.map { |lat, lon| point(config, data_set, radius, lat, lon, surface_offset) }
|
substrate = if solid && !multicolor && config["grid"]["enabled"]
|
||||||
inner = coords.map { |lat, lon| point(config, data_set, radius, lat, lon, -config["shell"]["wall_mm"]) }
|
coords.map do |lat, lon|
|
||||||
|
vertex_is_grid = on_grid?(config, lat, lon, radius, (a1 - a0).abs / 2.0, (o1 - o0).abs / 2.0)
|
||||||
|
point(config, data_set, radius, lat, lon, vertex_is_grid ? -config["grid"]["depth_mm"] : 0.0)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
coords.map { |lat, lon| point(config, data_set, radius, lat, lon, surface_offset) }
|
||||||
|
end
|
||||||
|
inner = solid ? nil : coords.map { |lat, lon| point(config, data_set, radius, lat, lon, -config["shell"]["wall_mm"]) }
|
||||||
if unified_base
|
if unified_base
|
||||||
outer_ids = coords.each_with_index.map do |coord, index|
|
outer_ids = coords.each_with_index.map do |coord, index|
|
||||||
substrate_vertices[coordinate_key(coord)] ||= base.vertex(substrate[index])
|
substrate_vertices[coordinate_key(coord)] ||= base.vertex(substrate[index])
|
||||||
end
|
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(*outer_ids)
|
||||||
base.triangle(inner_ids[0], inner_ids[2], inner_ids[1])
|
unless solid
|
||||||
3.times do |edge|
|
inner_ids = coords.each_with_index.map do |coord, index|
|
||||||
next_edge = (edge + 1) % 3
|
inner_vertices[coordinate_key(coord)] ||= base.vertex(inner[index])
|
||||||
key = [coordinate_key(coords[edge]), coordinate_key(coords[next_edge])].sort
|
end
|
||||||
record = boundary_edges[key]
|
base.triangle(inner_ids[0], inner_ids[2], inner_ids[1])
|
||||||
if record
|
3.times do |edge|
|
||||||
record[:count] += 1
|
next_edge = (edge + 1) % 3
|
||||||
else
|
key = [coordinate_key(coords[edge]), coordinate_key(coords[next_edge])].sort
|
||||||
boundary_edges[key] = { count: 1, outer: [outer_ids[edge], outer_ids[next_edge]],
|
record = boundary_edges[key]
|
||||||
inner: [inner_ids[edge], inner_ids[next_edge]] }
|
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
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
@@ -135,6 +147,7 @@ module MoonModel
|
|||||||
if unified_base
|
if unified_base
|
||||||
boundary_edges.each_value do |edge|
|
boundary_edges.each_value do |edge|
|
||||||
next unless edge[:count] == 1
|
next unless edge[:count] == 1
|
||||||
|
next if solid
|
||||||
base.quad(edge[:outer][0], edge[:outer][1], edge[:inner][1], edge[:inner][0])
|
base.quad(edge[:outer][0], edge[:outer][1], edge[:inner][1], edge[:inner][0])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -40,12 +40,13 @@ module MoonModel
|
|||||||
dimensions = unit.map { |min, max| (max - min) * datum }
|
dimensions = unit.map { |min, max| (max - min) * datum }
|
||||||
minimum_radius, maximum_radius = conservative_radial_bounds(config, data_set, datum, exaggeration,
|
minimum_radius, maximum_radius = conservative_radial_bounds(config, data_set, datum, exaggeration,
|
||||||
lon_segments, lat_segments)
|
lon_segments, lat_segments)
|
||||||
safe = validate_safety ? maximum_safe_exaggeration(config, data_set) : nil
|
interior = prospective_interior(config, data_set, datum, exaggeration, lon_segments, lat_segments)
|
||||||
|
safe = validate_safety ? maximum_safe_exaggeration(config, data_set, interior: interior) : nil
|
||||||
if safe && exaggeration > safe + 1e-9
|
if safe && exaggeration > safe + 1e-9
|
||||||
raise ArgumentError, format("vertical exaggeration %.3gx is unsafe; maximum is %.3gx", exaggeration, safe)
|
raise ArgumentError, format("vertical exaggeration %.3gx is unsafe; maximum is %.3gx", exaggeration, safe)
|
||||||
end
|
end
|
||||||
|
|
||||||
mode, source = resolve_mode(config, data_set, datum, exaggeration, lon_segments, lat_segments)
|
mode, source = resolve_mode(config, data_set, datum, exaggeration, lon_segments, lat_segments)
|
||||||
|
|
||||||
Result.new(
|
Result.new(
|
||||||
datum_diameter_mm: datum,
|
datum_diameter_mm: datum,
|
||||||
final_envelope_mm: dimensions.max,
|
final_envelope_mm: dimensions.max,
|
||||||
@@ -61,6 +62,14 @@ module MoonModel
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def prospective_interior(config, data_set, datum, exaggeration, lon_segments, lat_segments)
|
||||||
|
return "hollow" if config["purpose"] == "box" || config["assembly"] == "eight_piece"
|
||||||
|
return config["shell"]["interior"] if config["assembly"] == "one_piece"
|
||||||
|
|
||||||
|
dimensions = full_dimensions(data_set, datum, exaggeration, lon_segments, lat_segments)
|
||||||
|
fits?(dimensions, config) ? config["shell"]["interior"] : "hollow"
|
||||||
|
end
|
||||||
|
|
||||||
def conservative_radial_bounds(config, data_set, datum, exaggeration, lon_segments, lat_segments)
|
def conservative_radial_bounds(config, data_set, datum, exaggeration, lon_segments, lat_segments)
|
||||||
base_radius = datum / 2.0
|
base_radius = datum / 2.0
|
||||||
radii = (0..lat_segments).flat_map do |iy|
|
radii = (0..lat_segments).flat_map do |iy|
|
||||||
@@ -112,11 +121,13 @@ module MoonModel
|
|||||||
bounds
|
bounds
|
||||||
end
|
end
|
||||||
|
|
||||||
def maximum_safe_exaggeration(config, data_set)
|
def maximum_safe_exaggeration(config, data_set, interior: config["shell"]["interior"])
|
||||||
minimum_offset = data_set.metadata.fetch("elevation_offset_range_m", [-data_set.relief_range_m, 0]).first.to_f
|
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?
|
return Float::INFINITY unless minimum_offset.negative?
|
||||||
|
|
||||||
required = config["shell"]["wall_mm"] + config["shell"]["minimum_feature_mm"]
|
solid = interior == "solid"
|
||||||
|
required = config["shell"]["minimum_feature_mm"]
|
||||||
|
required += config["shell"]["wall_mm"] unless solid
|
||||||
if config["size"]["meaning"] == "datum"
|
if config["size"]["meaning"] == "datum"
|
||||||
available = config.diameter_mm / 2.0
|
available = config.diameter_mm / 2.0
|
||||||
return 0.0 if available <= required
|
return 0.0 if available <= required
|
||||||
@@ -132,7 +143,8 @@ module MoonModel
|
|||||||
mid = (low + high) / 2.0
|
mid = (low + high) / 2.0
|
||||||
factor = sampled_bounds(data_set, mid, segment_count(config), segment_count(config) / 2).map { |a, b| b - a }.max
|
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)
|
radius = config.diameter_mm / (2.0 * factor)
|
||||||
inner = radius * (1.0 + minimum_offset / DataSet::DATUM_RADIUS_M * mid) - config["shell"]["wall_mm"]
|
inner = radius * (1.0 + minimum_offset / DataSet::DATUM_RADIUS_M * mid)
|
||||||
|
inner -= config["shell"]["wall_mm"] unless solid
|
||||||
inner >= config["shell"]["minimum_feature_mm"] ? low = mid : high = mid
|
inner >= config["shell"]["minimum_feature_mm"] ? low = mid : high = mid
|
||||||
end
|
end
|
||||||
low
|
low
|
||||||
|
|||||||
@@ -152,6 +152,10 @@ module MoonModel
|
|||||||
end
|
end
|
||||||
values["size"] = { "input" => "diameter", "value" => value, "unit" => unit, "meaning" => meaning }
|
values["size"] = { "input" => "diameter", "value" => value, "unit" => unit, "meaning" => meaning }
|
||||||
end
|
end
|
||||||
|
shell = values.fetch("shell", {}).dup
|
||||||
|
shell["interior"] = yes_no("For a one-piece Moon, use slicer-controlled infill instead of a hollow cavity?",
|
||||||
|
shell.fetch("interior", "solid") == "solid") ? "solid" : "hollow"
|
||||||
|
values["shell"] = shell
|
||||||
io.say("Terrain sizing at this size:")
|
io.say("Terrain sizing at this size:")
|
||||||
[1, 2, 5, 10].each do |factor|
|
[1, 2, 5, 10].each do |factor|
|
||||||
trial = Config.new(values.merge("vertical_exaggeration" => factor))
|
trial = Config.new(values.merge("vertical_exaggeration" => factor))
|
||||||
@@ -179,6 +183,10 @@ module MoonModel
|
|||||||
io.say("Datum diameter: #{units(summary.datum_diameter_mm)}")
|
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("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})")
|
io.say("Maximum safe exaggeration: #{format('%.2f', summary.safe_exaggeration)}x; mode: #{summary.mode.tr('_', ' ')} (#{summary.mode_source})")
|
||||||
|
if summary.segmented?
|
||||||
|
values["shell"]["interior"] = "hollow"
|
||||||
|
io.say("Eight-section output uses hollow sections; the solid/infill option applies only to one-piece Moons.")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def engineering(values)
|
def engineering(values)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class ConfigTest < Minitest::Test
|
|||||||
assert_in_delta 463.0, config.maximum_diameter("eight_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 1.6, config["shell"]["wall_mm"], 0.001
|
||||||
assert_in_delta 0.8, config["shell"]["minimum_feature_mm"], 0.001
|
assert_in_delta 0.8, config["shell"]["minimum_feature_mm"], 0.001
|
||||||
|
assert_equal "solid", config["shell"]["interior"]
|
||||||
assert_in_delta 10.0, config["stand"]["base_clearance_mm"], 0.001
|
assert_in_delta 10.0, config["stand"]["base_clearance_mm"], 0.001
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -32,4 +33,8 @@ class ConfigTest < Minitest::Test
|
|||||||
MoonModel::Config.new("stand" => { "base_clearance_mm" => -0.1 })
|
MoonModel::Config.new("stand" => { "base_clearance_mm" => -0.1 })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_rejects_unknown_interior_mode
|
||||||
|
assert_raises(ArgumentError) { MoonModel::Config.new("shell" => { "interior" => "foam" }) }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ class GeneratorTest < Minitest::Test
|
|||||||
assert_includes model, "<components>"
|
assert_includes model, "<components>"
|
||||||
end
|
end
|
||||||
assert File.file?(File.join(result[:output_dir], "manifest.md"))
|
assert File.file?(File.join(result[:output_dir], "manifest.md"))
|
||||||
|
manifest = File.read(File.join(result[:output_dir], "manifest.md"))
|
||||||
|
assembly = File.read(File.join(result[:output_dir], "ASSEMBLY.md"))
|
||||||
|
assert_includes manifest, "Interior: solid slicer volume"
|
||||||
|
assert_includes assembly, "no internal cavity supports are required"
|
||||||
assert_in_delta 40.0, result[:statistics]["final_envelope_mm"], 0.01
|
assert_in_delta 40.0, result[:statistics]["final_envelope_mm"], 0.01
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class GeometryTest < Minitest::Test
|
||||||
|
def setup
|
||||||
|
@data = MoonModel::DataSet.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_solid_one_piece_is_a_closed_outer_volume_without_inner_surface
|
||||||
|
solid = structure("solid")
|
||||||
|
hollow = structure("hollow")
|
||||||
|
|
||||||
|
assert solid.valid?
|
||||||
|
assert_manifold solid
|
||||||
|
assert_equal hollow.triangles.length / 2, solid.triangles.length
|
||||||
|
assert_equal hollow.vertices.length / 2, solid.vertices.length
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_solid_single_color_grid_remains_manifold
|
||||||
|
config = config_for("solid", "grid" => { "enabled" => true, "depth_mm" => 0.4 })
|
||||||
|
mesh = MoonModel::Geometry.build(config, @data).parts.first[:meshes].first
|
||||||
|
|
||||||
|
assert mesh.valid?
|
||||||
|
assert_manifold mesh
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_segmented_output_is_hollow_even_when_solid_is_requested
|
||||||
|
config = config_for("solid", "purpose" => "box", "assembly" => "eight_piece")
|
||||||
|
result = MoonModel::Geometry.build(config, @data)
|
||||||
|
|
||||||
|
assert result.segmented?
|
||||||
|
assert_equal "hollow", result.statistics["interior"]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def structure(interior)
|
||||||
|
MoonModel::Geometry.build(config_for(interior), @data).parts.first[:meshes].first
|
||||||
|
end
|
||||||
|
|
||||||
|
def config_for(interior, extra = {})
|
||||||
|
MoonModel::Config.new({
|
||||||
|
"size" => { "value" => 40 },
|
||||||
|
"shell" => { "interior" => interior },
|
||||||
|
"resolution" => { "longitude_segments" => 24 }
|
||||||
|
}.merge(extra))
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_manifold(mesh)
|
||||||
|
edge_uses = Hash.new(0)
|
||||||
|
mesh.triangles.each do |triangle|
|
||||||
|
triangle.each_index do |index|
|
||||||
|
edge_uses[[triangle[index], triangle[(index + 1) % 3]].sort] += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert edge_uses.values.all? { |count| count == 2 }, "every edge must belong to exactly two faces"
|
||||||
|
end
|
||||||
|
end
|
||||||
+10
-1
@@ -11,11 +11,20 @@ class SizingTest < Minitest::Test
|
|||||||
MoonModel::Config.new(
|
MoonModel::Config.new(
|
||||||
"size" => { "input" => "diameter", "value" => 8.0, "unit" => "in", "meaning" => "datum" },
|
"size" => { "input" => "diameter", "value" => 8.0, "unit" => "in", "meaning" => "datum" },
|
||||||
"vertical_exaggeration" => exaggeration,
|
"vertical_exaggeration" => exaggeration,
|
||||||
"assembly" => assembly,
|
"assembly" => assembly, "shell" => { "interior" => "hollow" },
|
||||||
"resolution" => { "longitude_segments" => 48 }
|
"resolution" => { "longitude_segments" => 48 }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
def test_solid_one_piece_allows_more_relief_before_radial_collapse
|
||||||
|
hollow = datum_config(1, assembly: "one_piece")
|
||||||
|
solid = MoonModel::Config.new(hollow.to_h.merge("shell" => hollow["shell"].merge("interior" => "solid")))
|
||||||
|
|
||||||
|
assert_operator MoonModel::Sizing.maximum_safe_exaggeration(solid, @data), :>,
|
||||||
|
MoonModel::Sizing.maximum_safe_exaggeration(hollow, @data)
|
||||||
|
end
|
||||||
|
|
||||||
def test_eight_inch_relief_uses_consistent_units
|
def test_eight_inch_relief_uses_consistent_units
|
||||||
one = MoonModel::Sizing.calculate(datum_config(1), @data)
|
one = MoonModel::Sizing.calculate(datum_config(1), @data)
|
||||||
ten = MoonModel::Sizing.calculate(datum_config(10), @data)
|
ten = MoonModel::Sizing.calculate(datum_config(10), @data)
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ class WizardTest < Minitest::Test
|
|||||||
assert_includes output.string, "waive the recommended terrain safety margin"
|
assert_includes output.string, "waive the recommended terrain safety margin"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_one_piece_interior_choice_can_select_hollow
|
||||||
|
input = StringIO.new("\n\n\n\nn\n\n\n")
|
||||||
|
output = StringIO.new
|
||||||
|
wizard = MoonModel::Wizard.new(io: HighLine.new(input, output), data_set: MoonModel::DataSet.new)
|
||||||
|
values = { "printer" => MoonModel::Config::X2D.dup, "purpose" => "display", "assembly" => "auto" }
|
||||||
|
|
||||||
|
wizard.send(:sizing, values)
|
||||||
|
|
||||||
|
assert_equal "hollow", values.dig("shell", "interior")
|
||||||
|
assert_includes output.string, "use slicer-controlled infill instead of a hollow cavity"
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ask_yes_no(input, default)
|
def ask_yes_no(input, default)
|
||||||
|
|||||||
Reference in New Issue
Block a user