Compare commits

...

9 Commits

Author SHA1 Message Date
Connor Slade
fe08ef5e1f Update slicer CLI documentation
Some checks failed
Build / build (macos-latest) (push) Has been cancelled
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-latest) (push) Has been cancelled
2025-04-04 21:52:11 -04:00
Connor Slade
478023da66 Don't produce incorrect results when models extend beyond build volume 2025-04-04 21:42:27 -04:00
Connor Slade
5fc58dc767 Allow attaching custom preview images with slicer CLI 2025-04-04 21:26:42 -04:00
Connor Slade
9daca647cc Slicer CLI cleanup 2025-04-04 20:52:13 -04:00
Connor Slade
bcd76a40ea Update build workflow
- Run on PRs
- Upload artifacts for all crates
2025-04-04 20:34:44 -04:00
Connor Slade
85c4eae0e1 Cleanup slicer CLI 2025-04-04 16:58:24 -04:00
Connor Slade
00d82313b7 Allow slicing multiple models with CLI 2025-04-04 16:48:32 -04:00
Connor Slade
7a16dfb196 Add readme to slicer crate 2025-04-04 15:15:48 -04:00
Connor Slade
ccc7424992 Start on slicer CLI 2025-04-04 15:11:02 -04:00
12 changed files with 423 additions and 118 deletions

View File

@@ -1,5 +1,5 @@
name: Build
on: [push]
on: [push, pull_request]
jobs:
build:
@@ -23,6 +23,9 @@ jobs:
with:
name: mslicer-${{ matrix.os }}
path: |
target/release/goo_format*
target/release/mslicer*
!target/release/mslicer.d
!target/release/mslicer.pdb
target/release/remote_send*
target/release/slicer*
!target/release/*.d
!target/release/*.pdb

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
/*output
*.goo
*.stl
codebook.toml

2
Cargo.lock generated
View File

@@ -4491,11 +4491,13 @@ name = "slicer"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"common",
"criterion",
"goo_format",
"image 0.25.1",
"nalgebra 0.32.6",
"num-traits",
"obj-rs",
"ordered-float",
"parking_lot",

View File

@@ -29,6 +29,7 @@ markdown = "0.3.0"
md5 = "0.7.0"
nalgebra = { version = "0.32.6", features = ["serde-serialize"] }
notify-rust = "4.11.0"
num-traits = "0.2.19"
obj-rs = "0.7.1"
open = "5.3.0"
ordered-float = "4.2.0"

View File

@@ -1,5 +1,9 @@
# Changelog
## v0.2.1 — Coming Soon?
- Don't produce invalid results when models extend beyond build volume
## v0.2.0 — Feb 19, 2025
- Convert slice operation window to a dockable panel

View File

@@ -5,8 +5,10 @@ edition = "2021"
[dependencies]
anyhow.workspace = true
clap.workspace = true
image.workspace = true
nalgebra.workspace = true
num-traits.workspace = true
obj-rs.workspace = true
ordered-float.workspace = true
parking_lot.workspace = true
@@ -20,6 +22,10 @@ goo_format = { path = "../goo_format" }
[dev-dependencies]
criterion.workspace = true
[[bin]]
name = "slicer"
path = "bin/main.rs"
[[bench]]
name = "benchmark"
harness = false

70
slicer/README.md Normal file
View File

@@ -0,0 +1,70 @@
# `slicer`
This crate contains the types and algorithms to efficiently slice a mesh and some other stuff for post processing and support generation.
## Command Line interface
This crate also exposes a CLI for slicing models, open the dropdown below to view the help page.
Multiple meshes can be added by using the `--mesh` argument more than once.
If you want to change any properties of the mesh like position, rotation, or scale, you can use the flag followed by a 3D vector (`x,y,z`).
These flags will modify the mesh defined most recently.
See the example below.
```bash
$ slicer --mesh teapot.stl --position 0,0,-0.05 --scale 2,2,2 --mesh frog.stl --position 100,0,0 output.goo
```
<details>
<summary>CLI Help</summary>
```plain
mslicer command line interface
Usage: slicer [OPTIONS] <--mesh <MESH>|--position <POSITION>|--rotation <ROTATION>|--scale <SCALE>> <OUTPUT>
Arguments:
<OUTPUT> File to save sliced result to. Currently only .goo files can be generated
Options:
--platform-resolution <PLATFORM_RESOLUTION>
Resolution of the printer mask display in pixels [default: "11520, 5120"]
--platform-size <PLATFORM_SIZE>
Size of the printer display / platform in mm [default: "218.88, 122.904, 260.0"]
--layer-height <LAYER_HEIGHT>
Layer height in mm [default: 0.05]
--first-layers <FIRST_LAYERS>
Number of 'first layers'. These are layers that obey the --first- exposure config flags [default: 3]
--transition-layers <TRANSITION_LAYERS>
Number of transition layers. These are layers that interpolate from the first layer config to the default config [default: 10]
--exposure-time <EXPOSURE_TIME>
Layer exposure time in seconds [default: 3]
--lift-distance <LIFT_DISTANCE>
Distance to lift the platform after exposing each regular layer, in mm [default: 5]
--lift-speed <LIFT_SPEED>
The speed to lift the platform after exposing each regular layer, in mm/min [default: 65]
--retract-speed <RETRACT_SPEED>
The speed to retract (move down) the platform after exposing each regular layer, in mm/min [default: 150]
--first-exposure-time <FIRST_EXPOSURE_TIME>
First layer exposure time in seconds [default: 30]
--first-lift-distance <FIRST_LIFT_DISTANCE>
Distance to lift the platform after exposing each first layer, in mm [default: 5]
--first-lift-speed <FIRST_LIFT_SPEED>
The speed to lift the platform after exposing each first layer, in mm/min [default: 65]
--first-retract-speed <FIRST_RETRACT_SPEED>
The speed to retract (move down) the platform after exposing each first layer, in mm/min [default: 150]
--preview <PREVIEW>
Path to a preview image, will be scaled as needed
--mesh <MESH>
Path to a .stl or .obj file
--position <POSITION>
Location of the bottom center of model bounding box. The origin is the center of the build plate
--rotation <ROTATION>
Rotation of the model in degrees, pitch, roll, yaw
--scale <SCALE>
Scale of the model along the X, Y, and Z axes
-h, --help
Print help
```
</details>

211
slicer/bin/args.rs Normal file
View File

@@ -0,0 +1,211 @@
use std::{any::Any, path::PathBuf, str::FromStr};
use anyhow::{Context, Ok, Result};
use clap::{ArgMatches, Parser};
use common::{
config::{ExposureConfig, SliceConfig},
format::Format,
};
use nalgebra::{ArrayStorage, Const, Matrix, Scalar, Vector2, Vector3, U1};
use num_traits::Zero;
#[derive(Debug, Parser)]
/// mslicer command line interface.
pub struct Args {
#[arg(long, default_value = "11520, 5120", value_parser = vector_value_parser::<u32, 2>, )]
/// Resolution of the printer mask display in pixels.
pub platform_resolution: Vector2<u32>,
#[arg(long, default_value = "218.88, 122.904, 260.0", value_parser = vector_value_parser::<f32, 3>)]
/// Size of the printer display / platform in mm.
pub platform_size: Vector3<f32>,
#[arg(long, default_value_t = 0.05)]
/// Layer height in mm.
pub layer_height: f32,
#[arg(long, default_value_t = 3)]
/// Number of 'first layers'. These are layers that obey the --first-
/// exposure config flags.
pub first_layers: u32,
#[arg(long, default_value_t = 10)]
/// Number of transition layers. These are layers that interpolate from the
/// first layer config to the default config.
pub transition_layers: u32,
#[arg(long, default_value_t = 3.0)]
/// Layer exposure time in seconds.
pub exposure_time: f32,
#[arg(long, default_value_t = 5.0)]
/// Distance to lift the platform after exposing each regular layer, in mm.
pub lift_distance: f32,
#[arg(long, default_value_t = 65.0)]
/// The speed to lift the platform after exposing each regular layer, in
/// mm/min.
pub lift_speed: f32,
#[arg(long, default_value_t = 150.0)]
/// The speed to retract (move down) the platform after exposing each
/// regular layer, in mm/min.
pub retract_speed: f32,
#[arg(long, default_value_t = 30.0)]
/// First layer exposure time in seconds.
pub first_exposure_time: f32,
#[arg(long, default_value_t = 5.0)]
/// Distance to lift the platform after exposing each first layer, in mm.
pub first_lift_distance: f32,
#[arg(long, default_value_t = 65.0)]
/// The speed to lift the platform after exposing each first layer, in
/// mm/min.
pub first_lift_speed: f32,
#[arg(long, default_value_t = 150.0)]
/// The speed to retract (move down) the platform after exposing each first
/// layer, in mm/min.
pub first_retract_speed: f32,
#[arg(long)]
/// Path to a preview image, will be scaled as needed.
pub preview: Option<PathBuf>,
#[command(flatten)]
pub model: ModelArgs,
/// File to save sliced result to. Currently only .goo files can be
/// generated.
pub output: PathBuf,
}
#[derive(clap::Args, Debug)]
#[group(required = true)]
pub struct ModelArgs {
#[arg(long)]
/// Path to a .stl or .obj file
pub mesh: Vec<PathBuf>,
#[arg(long, value_parser = vector_value_parser::<f32, 3>)]
/// Location of the bottom center of model bounding box. The origin is the
/// center of the build plate.
pub position: Vec<Vector3<f32>>,
#[arg(long, value_parser = vector_value_parser::<f32, 3>)]
/// Rotation of the model in degrees, pitch, roll, yaw.
pub rotation: Vec<Vector3<f32>>,
#[arg(long, value_parser = vector_value_parser::<f32, 3>)]
/// Scale of the model along the X, Y, and Z axes.
pub scale: Vec<Vector3<f32>>,
}
#[derive(Debug)]
pub struct Model {
pub path: PathBuf,
pub position: Vector3<f32>,
pub rotation: Vector3<f32>,
pub scale: Vector3<f32>,
}
impl Args {
pub fn slice_config(&self) -> SliceConfig {
SliceConfig {
format: Format::Goo,
platform_resolution: self.platform_resolution,
platform_size: self.platform_size,
slice_height: self.layer_height,
exposure_config: ExposureConfig {
exposure_time: self.exposure_time,
lift_distance: self.lift_distance,
lift_speed: self.lift_speed,
retract_distance: self.lift_distance,
retract_speed: self.retract_speed,
},
first_exposure_config: ExposureConfig {
exposure_time: self.first_exposure_time,
lift_distance: self.first_lift_distance,
lift_speed: self.first_lift_speed,
retract_distance: self.first_lift_distance,
retract_speed: self.first_retract_speed,
},
first_layers: self.first_layers,
transition_layers: self.transition_layers,
}
}
pub fn mm_to_px(&self) -> Vector3<f32> {
Vector3::new(
self.platform_resolution.x as f32 / self.platform_size.x,
self.platform_resolution.y as f32 / self.platform_size.y,
1.0,
)
}
}
impl Model {
fn new(path: PathBuf) -> Self {
Self {
path,
..Default::default()
}
}
pub fn from_matches(matches: &ArgMatches) -> Vec<Self> {
let mut meshes = matches
.get_many::<PathBuf>("mesh")
.expect("No meshes defined")
.zip(matches.indices_of("mesh").unwrap())
.map(|x| (x.1, Model::new(x.0.to_owned())))
.collect::<Vec<_>>();
fn model_parameter<T: Any + Clone + Send + Sync + 'static>(
matches: &clap::ArgMatches,
meshes: &mut [(usize, Model)],
key: &str,
value: impl Fn(&mut Model) -> &mut T,
) {
let Some(instances) = matches.get_many::<T>(key) else {
return;
};
for (instance, idx) in instances.zip(matches.indices_of(key).unwrap()) {
let mesh = meshes
.iter_mut()
.rfind(|x| idx > x.0)
.expect("Mesh parameter before mesh");
*value(&mut mesh.1) = instance.to_owned();
}
}
model_parameter(matches, &mut meshes, "scale", |mesh| &mut mesh.scale);
model_parameter(matches, &mut meshes, "rotation", |mesh| &mut mesh.rotation);
model_parameter(matches, &mut meshes, "position", |mesh| &mut mesh.position);
meshes.into_iter().map(|x| x.1).collect()
}
}
impl Default for Model {
fn default() -> Self {
Self {
path: PathBuf::default(),
position: Vector3::zeros(),
rotation: Vector3::zeros(),
scale: Vector3::repeat(1.0),
}
}
}
fn vector_value_parser<T, const N: usize>(
raw: &str,
) -> Result<Matrix<T, Const<N>, U1, ArrayStorage<T, N, 1>>>
where
T: FromStr + Scalar + Zero,
T::Err: Send + Sync + std::error::Error,
{
let mut vec = Matrix::<T, Const<N>, U1, ArrayStorage<T, N, 1>>::zeros();
let mut parts = raw.splitn(N, ',');
for i in 0..N {
let element = parts.next().context("Missing vector element")?.trim();
vec[i] = element
.parse()
.context("Can't convert element from string")?;
}
Ok(vec)
}

105
slicer/bin/main.rs Normal file
View File

@@ -0,0 +1,105 @@
use std::{
fs::{self, File},
io::{stdout, BufReader, Write},
thread,
time::Instant,
};
use anyhow::Result;
use args::{Args, Model};
use clap::{CommandFactory, FromArgMatches};
use image::{imageops::FilterType, io::Reader as ImageReader};
use common::serde::DynamicSerializer;
use goo_format::{File as GooFile, LayerEncoder, PreviewImage};
use slicer::{mesh::load_mesh, slicer::Slicer};
mod args;
fn main() -> Result<()> {
let matches = Args::command().get_matches();
let args = Args::from_arg_matches(&matches)?;
let models = Model::from_matches(&matches);
let slice_config = args.slice_config();
let mm_to_px = args.mm_to_px();
let mut meshes = Vec::new();
for model in models {
let ext = model.path.extension().unwrap().to_string_lossy();
let file = File::open(&model.path)?;
let mut buf = BufReader::new(file);
let mut mesh = load_mesh(&mut buf, &ext)?;
mesh.set_scale(model.scale);
mesh.set_rotation(model.rotation);
// Center the model
let (min, max) = mesh.bounds();
let mesh_center = (min + max) / 2.0;
let center = (slice_config.platform_resolution / 2).map(|x| x as f32);
mesh.set_position((center - mesh_center.xy()).to_homogeneous() + model.position);
// Scale the model into printer-space (mm => px)
mesh.set_scale(model.scale.component_mul(&mm_to_px));
println!(
"Loaded `{}`. {{ vert: {}, face: {} }}",
model.path.file_name().unwrap().to_string_lossy(),
mesh.vertex_count(),
mesh.face_count()
);
let (min, max) = mesh.bounds();
if min.x < 0.0
|| min.y < 0.0
|| min.z < 0.0
|| max.x > slice_config.platform_resolution.x as f32
|| max.y > slice_config.platform_resolution.y as f32
|| max.z > slice_config.platform_size.z
{
println!(" \\ Model extends outsize of print volume and will be cut off.",);
}
meshes.push(mesh);
}
// Actually slice it on another thread (the slicing is multithreaded)
let now = Instant::now();
let slicer = Slicer::new(slice_config.clone(), meshes);
let progress = slicer.progress();
let goo = thread::spawn(move || GooFile::from_slice_result(slicer.slice::<LayerEncoder>()));
let mut completed = 0;
while completed < progress.total() {
completed = progress.wait();
print!(
"\rLayer: {}/{}, {:.1}%",
completed,
progress.total(),
completed as f32 / progress.total() as f32 * 100.0
);
stdout().flush()?;
}
// Once slicing is complete write to a .goo file
let mut goo = goo.join().unwrap();
if let Some(path) = args.preview {
let image = ImageReader::open(path)?.decode()?.to_rgba8();
goo.header.small_preview = PreviewImage::from_image_scaled(&image, FilterType::Triangle);
goo.header.big_preview = PreviewImage::from_image_scaled(&image, FilterType::Triangle);
}
let mut serializer = DynamicSerializer::new();
goo.serialize(&mut serializer);
fs::write(args.output, serializer.into_inner())?;
println!("\nDone. Elapsed: {:.1}s", now.elapsed().as_secs_f32());
Ok(())
}

View File

@@ -1,98 +0,0 @@
use std::{
fs::{self, File},
io::{stdout, BufReader, Write},
thread,
time::Instant,
};
use anyhow::Result;
use nalgebra::{Vector2, Vector3};
use common::{
config::{ExposureConfig, SliceConfig},
format::Format,
serde::DynamicSerializer,
};
use goo_format::{File as GooFile, LayerEncoder};
use slicer::{mesh::load_mesh, slicer::Slicer, Pos};
fn main() -> Result<()> {
const FILE_PATH: &str = "teapot.stl";
const OUTPUT_PATH: &str = "output.goo";
let slice_config = SliceConfig {
format: Format::Goo,
platform_resolution: Vector2::new(11_520, 5_120),
platform_size: Vector3::new(218.88, 122.904, 260.0),
slice_height: 0.05,
exposure_config: ExposureConfig {
exposure_time: 3.0,
..Default::default()
},
first_exposure_config: ExposureConfig {
exposure_time: 50.0,
..Default::default()
},
first_layers: 10,
transition_layers: 10,
};
let file = File::open(FILE_PATH)?;
let mut buf = BufReader::new(file);
let mut mesh = load_mesh(&mut buf, "stl")?;
let (min, max) = mesh.bounds();
// Scale the model into printer-space (mm => px)
let real_scale = 1.0;
mesh.set_scale(Pos::new(
real_scale / slice_config.platform_size.x * slice_config.platform_resolution.x as f32,
real_scale / slice_config.platform_size.y * slice_config.platform_resolution.y as f32,
real_scale,
));
// Center the model
let center = slice_config.platform_resolution / 2;
let mesh_center = (min + max) / 2.0;
mesh.set_position(Vector3::new(
center.x as f32 - mesh_center.x,
center.y as f32 - mesh_center.y,
mesh.position().z - 0.05,
));
println!(
"Loaded mesh. {{ vert: {}, face: {} }}",
mesh.vertex_count(),
mesh.face_count()
);
// Actually slice it on another thread (the slicing is multithreaded)
let now = Instant::now();
let slicer = Slicer::new(slice_config.clone(), vec![mesh]);
let progress = slicer.progress();
let goo = thread::spawn(move || GooFile::from_slice_result(slicer.slice::<LayerEncoder>()));
let mut completed = 0;
while completed < progress.total() {
completed = progress.wait();
print!(
"\rLayer: {}/{}, {:.1}%",
completed,
progress.total(),
completed as f32 / progress.total() as f32 * 100.0
);
stdout().flush()?;
}
// Once slicing is complete write to a .goo file
let mut serializer = DynamicSerializer::new();
goo.join().unwrap().serialize(&mut serializer);
fs::write(OUTPUT_PATH, serializer.into_inner())?;
println!("\nDone. Elapsed: {:.1}s", now.elapsed().as_secs_f32());
Ok(())
}

View File

@@ -340,9 +340,9 @@ impl Default for Mesh {
transformation_matrix: Matrix4::identity(),
inv_transformation_matrix: Matrix4::identity(),
position: Pos::new(0.0, 0.0, 0.0),
scale: Pos::new(1.0, 1.0, 1.0),
rotation: Pos::new(0.0, 0.0, 0.0),
position: Pos::repeat(0.0),
scale: Pos::repeat(1.0),
rotation: Pos::repeat(0.0),
}
}
}

View File

@@ -14,7 +14,7 @@ use common::{
use ordered_float::OrderedFloat;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use crate::{format::FormatSliceResult, mesh::Mesh, segments::Segments1D, Pos};
use crate::{format::FormatSliceResult, mesh::Mesh, segments::Segments1D};
/// Used to slice a mesh.
pub struct Slicer {
@@ -40,14 +40,14 @@ pub struct ProgressInner {
impl Slicer {
/// Creates a new slicer given a slice config and list of models.
pub fn new(slice_config: SliceConfig, models: Vec<Mesh>) -> Self {
let max = models.iter().fold(Pos::zeros(), |max, model| {
let f = model.vertices().iter().fold(Pos::zeros(), |max, &f| {
let f = model.transform(&f);
Pos::new(max.x.max(f.x), max.y.max(f.y), max.z.max(f.z))
});
Pos::new(max.x.max(f.x), max.y.max(f.y), max.z.max(f.z))
let max_z = models.iter().fold(0_f32, |max, model| {
let verts = model.vertices().iter();
let z = verts.fold(0_f32, |max, &f| max.max(model.transform(&f).z));
max.max(z)
});
let layers = (max.z / slice_config.slice_height).ceil() as u32;
let layers = (max_z / slice_config.slice_height).ceil() as u32;
let max_layers = (slice_config.platform_size.z / slice_config.slice_height).ceil() as u32;
Self {
slice_config,
@@ -55,7 +55,7 @@ impl Slicer {
progress: Progress {
inner: Arc::new(ProgressInner {
completed: AtomicU32::new(0),
total: layers,
total: layers.min(max_layers),
notify: Condvar::new(),
last_completed: Mutex::new(0),
@@ -77,8 +77,8 @@ impl Slicer {
/// Actually runs the slicing operation, it is multithreaded.
pub fn slice<Layer: EncodableLayer>(&self) -> SliceResult<Layer::Output> {
let pixels = (self.slice_config.platform_resolution.x
* self.slice_config.platform_resolution.y) as u64;
let platform_resolution = self.slice_config.platform_resolution;
let pixels = (platform_resolution.x * platform_resolution.y) as u64;
// A segment contains a reference to all of the triangles it contains. By
// splitting the mesh into segments, not all triangles need to be tested
@@ -124,7 +124,7 @@ impl Slicer {
// across and mark that as an intersection to then be run-length
// encoded. There is probably a better polygon filling algo, but
// this one works surprisingly fast.
for y in 0..self.slice_config.platform_resolution.y {
for y in 0..platform_resolution.y {
let yf = y as f32;
let mut intersections = segments
.iter()
@@ -155,14 +155,14 @@ impl Slicer {
depth += (dir as i32) * 2 - 1;
if (depth == 0) ^ (prev_depth == 0) {
filtered.push(pos);
filtered.push(pos.clamp(0.0, platform_resolution.x as f32));
}
}
// Convert the intersections into runs of white pixels to be
// encoded into the layer
for span in filtered.chunks_exact(2) {
let y_offset = (self.slice_config.platform_resolution.x * y) as u64;
let y_offset = (platform_resolution.x * y) as u64;
let a = span[0].round() as u64;
let b = span[1].round() as u64;