mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
Add craft-plotter (HP-GL, DMPL, GP-GL); craft-gcode: bare output and Grbl replies (#863)
* craft-gcode: bare output for line-by-line senders, and Grbl replies
Writer::bare writes the same moves as Writer::new with every line a
command: no comments, coordinates at up to three decimals without
trailing zeros, M5 at the start, and the return to the origin at the
end. A sender that waits for an ok after each line can stream it as is.
Writer::set_feed changes the feed rate between contours.
The new grbl module has the real-time command bytes (status query, feed
hold, cycle start, soft reset) and the parsers of the controller's
answers: the state of a status report (read up to MAX_REPLY_LEN bytes)
and the ok / error / ALARM acknowledgement of a line. No I/O.
* Add craft-plotter: HP-GL, DMPL and GP-GL from a job in millimetres
One crate for the command languages of vinyl cutters and pen plotters,
one module per language: hpgl, dmpl, gpgl (plain) and gpgl::silhouette
(the Silhouette dialect in its three generations, with registration
marks, the device queries and the parsers of their replies).
A Job is ordered operations: polylines in millimetres plus tool
settings. Each encode gives a Program: the commands, their framing, and
where a sender waits for the registration marks. The crate does no I/O
and builds for wasm.
A job is refused past 64 operations, 2,000,000 points, 10 passes or a
2 mm overcut, or when a value is not finite. Replies are read up to 256
bytes and report at most 8 tool holders. Every limit has a test, and
tests/golden.rs fixes the exact commands of each language.
The README records where the protocol facts come from.
* craft-plotter: optional serde for Mat, as its short name
A caller that keeps a mat in a settings file or a machine table needs to
read and write it. The serde feature serializes Mat as its short name
("12x24") and refuses an unknown name. Off by default; the crate still
has thiserror as its only required dependency.
This commit is contained in:
1 parent
9ab7768771
commit
5375f49ffc
14 files changed
+1814
-10
No files matched your search
Generated
+8
@@ -1214,6 +1214,14 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-plotter"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-prepress"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -58,6 +58,7 @@ craft-menu = { path = "crates/menu" }
|
||||
craft-metafile = { path = "crates/metafile" }
|
||||
craft-opc = { path = "crates/opc" }
|
||||
craft-pcraft-format = { path = "crates/pcraft-format" }
|
||||
craft-plotter = { path = "crates/plotter" }
|
||||
craft-prepress = { path = "crates/prepress" }
|
||||
craft-print = { path = "crates/print" }
|
||||
craft-proof = { path = "crates/proof" }
|
||||
|
||||
@@ -91,7 +91,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
metadata and Adobe `hdrgm` XMP (read and write), measuring a gain map from SDR and HDR
|
||||
renditions, applying one, and Apple's legacy maps. Encoding and the container stay in each app.
|
||||
- [`craft-gcode`](crates/gcode/src/lib.rs): Grbl-style G-code writer: polylines in
|
||||
millimetres to `G0`/`G1` moves with laser power and feed, for cutting lasers and plotters.
|
||||
millimetres to `G0`/`G1` moves with laser power and feed, for cutting lasers and plotters, as a
|
||||
commented file or as bare lines for streaming. Its `grbl` module has the real-time command bytes
|
||||
and the status and acknowledgement parsers.
|
||||
- [`craft-heif`](crates/heif/README.md): optional HEIF/HEIC still-image decoder, from LightCraft:
|
||||
heic-rs reads the container and HEVC pictures, and this crate converts to 16-bit RGB to match
|
||||
libheif (VUI range and matrix, chroma upsampling, `imir`, ICC on grid tiles). Also orientation,
|
||||
@@ -162,6 +164,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
- [`craft-pcraft-format`](crates/pcraft-format/README.md): the envelope of PhotoCraft's `.pcraft`
|
||||
bundle: `Manifest<D>` with the document as a type parameter, `TileRef`, the entry paths, the
|
||||
manifest decode limits and the migration runner. The document model stays in PhotoCraft.
|
||||
- [`craft-plotter`](crates/plotter/README.md): cutter and pen-plotter command languages: HP-GL,
|
||||
DMPL and GP-GL (plain and the Silhouette dialect, three generations, registration marks) from a
|
||||
job in millimetres, plus the Silhouette device queries and reply parsers. No I/O.
|
||||
- [`craft-print`](crates/print/README.md): PDF submission to CUPS and Windows, printer discovery,
|
||||
duplex/copies/colour and driver preferences. PDF generation stays in each app.
|
||||
- [`craft-proof`](crates/proof/README.md): proofing per language (spelling, suggestions, grammar,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "craft-gcode"
|
||||
description = "Grbl-style G-code writer for laser cutters and plotters: polylines in millimetres to G0/G1 moves"
|
||||
description = "Grbl-style G-code writer for laser cutters and plotters: polylines in millimetres to G0/G1 moves, plus Grbl's real-time commands and reply parsers"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Talking to a Grbl controller: the real-time command bytes and the parsers of what it answers.
|
||||
//! No I/O happens here; a sender writes the bytes and hands the replies in.
|
||||
//!
|
||||
//! Facts from the Grbl v1.1 interface documentation
|
||||
//! (<https://github.com/gnea/grbl/wiki/Grbl-v1.1-Interface>): a line is answered with `ok` or
|
||||
//! `error:<n>`, a fault with `ALARM:<n>`, and `?` with a status report `<State|…>`.
|
||||
|
||||
/// Real-time command: ask for a status report ([`parse_status`] reads it).
|
||||
pub const STATUS_QUERY: u8 = b'?';
|
||||
/// Real-time command: feed hold (pause).
|
||||
pub const FEED_HOLD: u8 = b'!';
|
||||
/// Real-time command: cycle start (resume).
|
||||
pub const CYCLE_START: u8 = b'~';
|
||||
/// Real-time command: soft reset (Ctrl-X). Cancels the job.
|
||||
pub const SOFT_RESET: u8 = 0x18;
|
||||
/// The line that switches the laser or spindle off; send it when a job is abandoned mid-stream.
|
||||
pub const TOOL_OFF: &str = "M5";
|
||||
|
||||
/// Most bytes of a status report [`parse_status`] looks at. Grbl's own lines are well under 100
|
||||
/// bytes.
|
||||
pub const MAX_REPLY_LEN: usize = 256;
|
||||
|
||||
/// The machine state a status report starts with.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
Idle,
|
||||
Run,
|
||||
Hold,
|
||||
Jog,
|
||||
Alarm,
|
||||
Door,
|
||||
Check,
|
||||
Home,
|
||||
Sleep,
|
||||
/// A state word this crate does not know, as sent.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// The first `MAX_REPLY_LEN` bytes of `reply`, cut at a character boundary.
|
||||
fn capped(reply: &str) -> &str {
|
||||
let mut end = reply.len().min(MAX_REPLY_LEN);
|
||||
while !reply.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
reply.get(..end).unwrap_or("")
|
||||
}
|
||||
|
||||
/// The state of a status report (`<Idle|MPos:…>`, `<Hold:0|…>`). `None` when `reply` is not a
|
||||
/// status report.
|
||||
pub fn parse_status(reply: &str) -> Option<State> {
|
||||
let body = capped(reply).trim().strip_prefix('<')?;
|
||||
Some(match body.split(['|', '>', ':']).next().unwrap_or("") {
|
||||
"Idle" => State::Idle,
|
||||
"Run" => State::Run,
|
||||
"Hold" => State::Hold,
|
||||
"Jog" => State::Jog,
|
||||
"Alarm" => State::Alarm,
|
||||
"Door" => State::Door,
|
||||
"Check" => State::Check,
|
||||
"Home" => State::Home,
|
||||
"Sleep" => State::Sleep,
|
||||
other => State::Other(other.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// What the controller said about the line it was sent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Ack {
|
||||
/// `ok`: the line was accepted; send the next.
|
||||
Ok,
|
||||
/// `error:<n>` or `ALARM:<n>`: stop sending and switch the tool off.
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// Reads what has arrived since a line was sent. `None` while the answer is still incomplete: read
|
||||
/// more and ask again with everything received so far. A rejection anywhere in the reply wins over
|
||||
/// an `ok`. The whole reply is searched, without copying it: unrelated lines may arrive before the
|
||||
/// answer, and how much to wait for is the sender's limit to set.
|
||||
pub fn parse_ack(reply: &str) -> Option<Ack> {
|
||||
if reply.contains("error") || reply.contains("ALARM") {
|
||||
Some(Ack::Rejected)
|
||||
} else if reply.contains("ok") {
|
||||
Some(Ack::Ok)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn real_time_bytes_are_the_documented_ones() {
|
||||
assert_eq!([STATUS_QUERY, FEED_HOLD, CYCLE_START, SOFT_RESET], *b"?!~\x18");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_reports_give_their_state() {
|
||||
assert_eq!(parse_status("<Idle|MPos:0,0,0>"), Some(State::Idle));
|
||||
assert_eq!(parse_status("<Hold:0|MPos:0,0,0>"), Some(State::Hold));
|
||||
assert_eq!(parse_status(" <Door:3|WPos:1,2,3|FS:0,0>\r\n"), Some(State::Door));
|
||||
for (word, state) in [
|
||||
("Run", State::Run),
|
||||
("Jog", State::Jog),
|
||||
("Alarm", State::Alarm),
|
||||
("Check", State::Check),
|
||||
("Home", State::Home),
|
||||
("Sleep", State::Sleep),
|
||||
] {
|
||||
assert_eq!(parse_status(&format!("<{word}>")), Some(state));
|
||||
}
|
||||
assert_eq!(parse_status("<Tool|MPos:0,0,0>"), Some(State::Other("Tool".into())));
|
||||
assert_eq!(parse_status("<"), Some(State::Other(String::new())));
|
||||
assert_eq!(parse_status("ok"), None);
|
||||
assert_eq!(parse_status(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acks_are_ok_rejected_or_not_yet() {
|
||||
assert_eq!(parse_ack("ok\r\n"), Some(Ack::Ok));
|
||||
assert_eq!(parse_ack("error:22\r\n"), Some(Ack::Rejected));
|
||||
assert_eq!(parse_ack("ALARM:1\r\n"), Some(Ack::Rejected));
|
||||
assert_eq!(parse_ack("ok\r\nerror:9\r\n"), Some(Ack::Rejected));
|
||||
assert_eq!(parse_ack("o"), None);
|
||||
assert_eq!(parse_ack(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_report_is_read_only_up_to_the_cap() {
|
||||
let pad = " ".repeat(MAX_REPLY_LEN - 2);
|
||||
// An acknowledgement is found wherever it is.
|
||||
assert_eq!(parse_ack(&format!("{pad}{pad}ok")), Some(Ack::Ok));
|
||||
assert_eq!(parse_status(&format!("{pad}<Idle>")), Some(State::Other("I".into())));
|
||||
assert_eq!(parse_status(&format!("{pad} <Idle>")), None);
|
||||
// A long unknown state word is kept only up to the cap; a cut inside a character backs up.
|
||||
assert_eq!(parse_status(&format!("<{}", "x".repeat(1000))), Some(State::Other("x".repeat(MAX_REPLY_LEN - 1))));
|
||||
assert_eq!(parse_status(&format!("<{}", "é".repeat(1000))), Some(State::Other("é".repeat(127))));
|
||||
}
|
||||
}
|
||||
+87
-8
@@ -7,11 +7,18 @@
|
||||
//! several passes, with an optional power ramp. Coordinates are absolute millimetres, exactly as
|
||||
//! given.
|
||||
//!
|
||||
//! [`Writer::new`] writes a file to keep: commented, fixed four-decimal coordinates.
|
||||
//! [`Writer::bare`] writes the same moves for a sender that streams a job line by line and waits
|
||||
//! for each `ok`: no comments, shortest coordinates. [`grbl`] has what such a sender needs from
|
||||
//! the controller's side: the real-time command bytes and the reply parsers.
|
||||
//!
|
||||
//! Flattening curves, choosing what to cut and at what power, and machine or material presets
|
||||
//! are the caller's business. Depends on no other Craft crate.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod grbl;
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
@@ -83,10 +90,7 @@ impl Default for Settings {
|
||||
impl Settings {
|
||||
/// Checks every knob is inside its range.
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
// Written to three decimals: a smaller rate would come out as `F0`, which Grbl rejects.
|
||||
if !self.feed_mm_per_min.is_finite() || self.feed_mm_per_min < 0.001 {
|
||||
return Err(Error::Settings(format!("feed rate {} mm/min is not a finite rate of at least 0.001", self.feed_mm_per_min)));
|
||||
}
|
||||
check_feed(self.feed_mm_per_min)?;
|
||||
if self.passes == 0 || self.passes > MAX_PASSES {
|
||||
return Err(Error::Settings(format!("passes {} is outside the 1–{MAX_PASSES} range", self.passes)));
|
||||
}
|
||||
@@ -107,6 +111,21 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_feed(feed_mm_per_min: f64) -> Result<(), Error> {
|
||||
// Written to three decimals: a smaller rate would come out as `F0`, which Grbl rejects.
|
||||
if !feed_mm_per_min.is_finite() || feed_mm_per_min < 0.001 {
|
||||
return Err(Error::Settings(format!("feed rate {feed_mm_per_min} mm/min is not a finite rate of at least 0.001")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A coordinate at up to three decimals, without trailing zeros.
|
||||
fn short(v: f64) -> String {
|
||||
let s = format!("{v:.3}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" { "0".to_string() } else { s.to_string() }
|
||||
}
|
||||
|
||||
/// Checks a power is one the `S` word accepts; `what` names it in the error ("power",
|
||||
/// "engrave power").
|
||||
pub fn check_power(what: &str, power: u32) -> Result<(), Error> {
|
||||
@@ -121,13 +140,15 @@ pub fn check_power(what: &str, power: u32) -> Result<(), Error> {
|
||||
pub struct Writer {
|
||||
out: String,
|
||||
settings: Settings,
|
||||
/// No comments, shortest coordinates: see [`Writer::bare`].
|
||||
bare: bool,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
/// Starts a program with `banner` as its first comment line.
|
||||
pub fn new(settings: Settings, banner: &str) -> Result<Writer, Error> {
|
||||
settings.validate()?;
|
||||
let mut w = Writer { out: String::with_capacity(4096), settings };
|
||||
let mut w = Writer { out: String::with_capacity(4096), settings, bare: false };
|
||||
w.comment(banner);
|
||||
let _ = writeln!(w.out, "G21 ; millimetres");
|
||||
let _ = writeln!(w.out, "G90 ; absolute coordinates");
|
||||
@@ -137,11 +158,31 @@ impl Writer {
|
||||
Ok(w)
|
||||
}
|
||||
|
||||
/// Starts a program for a sender that streams it line by line and waits for the controller's
|
||||
/// `ok` after each: every line is a command. There are no comments ([`Writer::comment`] writes
|
||||
/// nothing), coordinates have at most three decimals and no trailing zeros, the program starts
|
||||
/// with the tool off (`M5`), and it ends with the return to the origin, each contour having
|
||||
/// switched the tool off itself.
|
||||
pub fn bare(settings: Settings) -> Result<Writer, Error> {
|
||||
settings.validate()?;
|
||||
Ok(Writer { out: "G21\nG90\nG17\nM5\n".to_string(), settings, bare: true })
|
||||
}
|
||||
|
||||
/// Changes the feed rate for the contours that follow.
|
||||
pub fn set_feed(&mut self, feed_mm_per_min: f64) -> Result<(), Error> {
|
||||
check_feed(feed_mm_per_min)?;
|
||||
self.settings.feed_mm_per_min = feed_mm_per_min;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A comment line. Control characters become spaces: a line break in `text` (a layer name
|
||||
/// from a file, say) would otherwise start a line the machine runs as a command. Grbl acts
|
||||
/// on its real-time bytes (`!` feed hold, `?`, `~` and everything past ASCII) wherever they
|
||||
/// arrive, comments included, so those become `_`.
|
||||
pub fn comment(&mut self, text: &str) {
|
||||
if self.bare {
|
||||
return;
|
||||
}
|
||||
let safe = |c: char| match c {
|
||||
c if c.is_control() => ' ',
|
||||
'!' | '?' | '~' => '_',
|
||||
@@ -171,16 +212,18 @@ impl Writer {
|
||||
}
|
||||
let on = self.settings.mode.on_command();
|
||||
let passes = self.settings.passes;
|
||||
let bare = self.bare;
|
||||
let xy = |p: (f64, f64)| if bare { format!("X{} Y{}", short(p.0), short(p.1)) } else { format!("X{:.4} Y{:.4}", p.0, p.1) };
|
||||
for pass in 1..=passes {
|
||||
let _ = writeln!(self.out, "G0 X{:.4} Y{:.4}", first.0, first.1);
|
||||
let _ = writeln!(self.out, "G0 {}", xy(first));
|
||||
let power = self.settings.power_for_pass(power, pass);
|
||||
if passes > 1 {
|
||||
if passes > 1 && !bare {
|
||||
let _ = writeln!(self.out, "{on} S{power} ; pass {pass} of {passes}");
|
||||
} else {
|
||||
let _ = writeln!(self.out, "{on} S{power}");
|
||||
}
|
||||
for p in path.iter().skip(1) {
|
||||
let _ = writeln!(self.out, "G1 X{:.4} Y{:.4} F{}", p.0, p.1, (self.settings.feed_mm_per_min * 1000.0).round() / 1000.0);
|
||||
let _ = writeln!(self.out, "G1 {} F{}", xy(*p), (self.settings.feed_mm_per_min * 1000.0).round() / 1000.0);
|
||||
}
|
||||
// Never rapid with the beam on, whatever the mode or controller.
|
||||
let _ = writeln!(self.out, "M5");
|
||||
@@ -190,6 +233,10 @@ impl Writer {
|
||||
|
||||
/// Turns the tool off, returns to the origin and gives the program.
|
||||
pub fn finish(mut self) -> String {
|
||||
if self.bare {
|
||||
self.out.push_str("G0 X0 Y0\n");
|
||||
return self.out;
|
||||
}
|
||||
let _ = writeln!(self.out, "M5 ; tool off");
|
||||
let _ = writeln!(self.out, "G0 X0 Y0 ; return to origin");
|
||||
self.out
|
||||
@@ -304,6 +351,38 @@ mod tests {
|
||||
assert!(g.lines().all(|l| !l.starts_with("M3")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_program_is_commands_only() {
|
||||
let mut w = Writer::bare(Settings { feed_mm_per_min: 1200.0, ..Settings::default() }).unwrap();
|
||||
w.comment("never written");
|
||||
w.contour(&[(0.0, 5.0), (10.0, 5.0), (10.0, 0.0)], false, 800).unwrap();
|
||||
w.set_feed(0.25).unwrap();
|
||||
w.contour(&[(-0.0001, 1.0 / 3.0), (2.5, 1e-9)], false, 0).unwrap();
|
||||
assert_eq!(
|
||||
w.finish(),
|
||||
"G21\nG90\nG17\nM5\nG0 X0 Y5\nM4 S800\nG1 X10 Y5 F1200\nG1 X10 Y0 F1200\nM5\n\
|
||||
G0 X0 Y0.333\nM4 S0\nG1 X2.5 Y0 F0.25\nM5\nG0 X0 Y0\n"
|
||||
);
|
||||
// Passes repeat the contour without a comment; the mode still picks the on command.
|
||||
let mut w = Writer::bare(Settings { passes: 2, mode: LaserMode::Constant, ..Settings::default() }).unwrap();
|
||||
w.contour(&[(0.0, 0.0), (1.0, 0.0)], false, 5).unwrap();
|
||||
assert_eq!(w.finish(), "G21\nG90\nG17\nM5\nG0 X0 Y0\nM3 S5\nG1 X1 Y0 F1000\nM5\nG0 X0 Y0\nM3 S5\nG1 X1 Y0 F1000\nM5\nG0 X0 Y0\n");
|
||||
assert!(matches!(Writer::bare(Settings { passes: 0, ..Settings::default() }), Err(Error::Settings(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_feed_refuses_a_bad_rate_and_keeps_the_old_one() {
|
||||
let mut w = Writer::new(Settings::default(), "").unwrap();
|
||||
for feed in [0.0, -1.0, f64::NAN, f64::INFINITY, 0.0004] {
|
||||
assert!(matches!(w.set_feed(feed), Err(Error::Settings(_))));
|
||||
}
|
||||
w.contour(&[(0.0, 0.0), (1.0, 0.0)], false, 1).unwrap();
|
||||
w.set_feed(300.0).unwrap();
|
||||
w.contour(&[(0.0, 0.0), (2.0, 0.0)], false, 1).unwrap();
|
||||
let g = w.finish();
|
||||
assert!(g.contains("G1 X1.0000 Y0.0000 F1000\n") && g.contains("G1 X2.0000 Y0.0000 F300\n"), "{g}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laser_modes_parse_their_spellings() {
|
||||
assert_eq!(LaserMode::parse(" M3 "), Some(LaserMode::Constant));
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "craft-plotter"
|
||||
description = "Cutter and pen-plotter command languages: HP-GL, DMPL and GP-GL (plain and the Silhouette dialect) from a job in millimetres, plus the device queries and reply parsers. No I/O"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
# `Serialize` / `Deserialize` for `gpgl::silhouette::Mat`, as its short name (`"12x24"`).
|
||||
serde = ["dep:serde"]
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# craft-plotter
|
||||
|
||||
The command languages of vinyl cutters and pen plotters. A job (polylines in millimetres, tool
|
||||
settings, registration marks) goes in; the exact command bytes of one language come out. The
|
||||
device queries and the parsers of their replies are here too. The crate does no I/O and builds
|
||||
for wasm: opening a port, writing to it and waiting for the device stay with the caller.
|
||||
|
||||
| Language | Module | What it writes |
|
||||
|---|---|---|
|
||||
| HP-GL (the `IN SP PU PD` subset, also valid HP-GL/2) | `hpgl` | `IN;` `SP<n>;` then `PUx,y;` / `PDx,y,…;` per path, `PU;` `SP0;`. 40 units per mm, origin bottom-left, y flipped about the design's bottom edge. One command per line. |
|
||||
| DMPL | `dmpl` | `;:` `EC1` then `Ux,y` / `Dx,y,…` per path, `U0,0`, `@`. 0.001 in units, origin bottom-left. One command per line. Experimental. |
|
||||
| GP-GL, plain | `gpgl` | `!<speed>` when set, `M<down>,<right>` / `D<down>,<right>` at 0.1 mm steps, park `M<below>,0`. Each command ends with ETX. |
|
||||
| GP-GL, Silhouette dialect | `gpgl::silhouette` | Setup preamble per generation (legacy, Cameo 3, Cameo 4 line), registration marks, `M`/`D` at 0.05 mm units, park `M<below>,0` `SO0`. Each command ends with ETX. Status, firmware-version and tool-setup queries and their reply parsers. |
|
||||
|
||||
G-code is not here: it is [`craft-gcode`](../gcode/src/lib.rs).
|
||||
|
||||
## Use
|
||||
|
||||
```rust
|
||||
use craft_plotter::{hpgl, Job, Operation};
|
||||
|
||||
let job = Job {
|
||||
operations: vec![Operation { holder: 1, paths: vec![vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]], ..Operation::default() }],
|
||||
..Job::default()
|
||||
};
|
||||
let program = hpgl::encode(&job)?;
|
||||
let bytes = program.to_stream(); // what to write to the device
|
||||
let lines = program.transcript(); // one command per line, for a dry run
|
||||
```
|
||||
|
||||
Paths are `(x right, y down)` in millimetres from the media's top-left corner. `ToolSettings` has
|
||||
force and speed in the device's own steps, passes, line-segment overcut at sharp corners, and the
|
||||
blade settings of the Silhouette dialect. The Silhouette encoder also takes a `Setup`: the
|
||||
generation, the cutting region, the mat and the media preset.
|
||||
|
||||
A program with registration marks has `registration_wait`: send the first part
|
||||
(`Program::split_at_registration`), wait until the device reports the marks found
|
||||
(`parse_status` gives `Status::Ready`), then send the rest.
|
||||
|
||||
The optional `serde` feature gives `gpgl::silhouette::Mat` `Serialize` and `Deserialize`, as its
|
||||
short name (`"12x24"`).
|
||||
|
||||
## Limits
|
||||
|
||||
Input is not trusted. A job is refused, and nothing encoded, past 64 operations
|
||||
(`MAX_OPERATIONS`), 2,000,000 points (`MAX_POINTS`), 10 passes (`MAX_PASSES`) or a 2 mm overcut
|
||||
(`MAX_OVERCUT_MM`), or when a coordinate, offset or setting is not finite. A device reply is read
|
||||
only up to 256 bytes (`gpgl::silhouette::MAX_REPLY_LEN`) and reports at most 8 tool holders
|
||||
(`MAX_TOOL_HOLDERS`). Coordinates past the integer range saturate. Nothing panics.
|
||||
|
||||
What a particular machine accepts (cutting area, force and speed range, which tools and mats) is
|
||||
not checked here. That is a table of products, and it belongs to the caller.
|
||||
|
||||
## Where the protocol facts come from
|
||||
|
||||
None of these languages has a vendor specification that covers what current machines accept, so
|
||||
the facts are from public documentation and from community descriptions of the command sets.
|
||||
They were not verified on hardware by this crate's authors.
|
||||
|
||||
- Silhouette dialect (units, framing, the escape queries and their replies, the preamble of each
|
||||
generation, mat and tool codes, registration-mark commands): the command documentation and
|
||||
device notes published by the community driver projects
|
||||
[`fablabnbg/inkscape-silhouette`](https://github.com/fablabnbg/inkscape-silhouette) (GPL-2.0),
|
||||
[`virtualabs/inkscape-cutcutgo`](https://github.com/virtualabs/inkscape-cutcutgo) (GPL-3.0),
|
||||
`pklaus/Silhouette-Cutting-Plotters`, and the `robocut` lineage they credit.
|
||||
- GP-GL: Graphtec's GP-GL command list (CraftRobo Pro S / CE5000):
|
||||
<https://web.archive.org/web/20160801023226/http://ohthehugemanatee.net/uploads/2011/07/GP-GL.pdf>.
|
||||
The plain encoder assumes the panel's GP-GL step size is 0.1 mm and the Silhouette axis order
|
||||
(feed first).
|
||||
- HP-GL: the long-published `IN`, `SP`, `PU`, `PD` instructions of the HP-GL and HP-GL/2
|
||||
references.
|
||||
- DMPL: no published manual was found for the `;:`, `EC1`, `U`/`D`, `@` framing. It is
|
||||
experimental: test on scrap.
|
||||
|
||||
Those drivers were read as specifications only: for the facts of what a device accepts, not for
|
||||
code. No GPL code was ported or adapted; the Rust here was written from the facts, and the crate
|
||||
is MIT OR Apache-2.0 like the rest of the repository. Vendor binaries were not disassembled,
|
||||
decompiled or debugged. The line-segment overcut is this crate's own geometric rule, not a
|
||||
captured vendor stream. When extending the crate, keep to this: public documentation and
|
||||
observed device behaviour, never GPL driver source.
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Houston Instrument DMPL, as sign cutters take it.
|
||||
//!
|
||||
//! The stream is `;:` and `EC1` (0.001 in resolution), then per path `Ux,y` to its start and one
|
||||
//! `Dx,y,…` through the rest, then `U0,0` and `@`. One command per line. The origin is
|
||||
//! bottom-left and y points up, so the design is flipped about its bottom edge. Tool holders,
|
||||
//! force and speed are not addressed.
|
||||
//!
|
||||
//! This framing was not checked against a published manual: treat it as experimental.
|
||||
|
||||
use crate::{Command, Error, Framing, Job, Program, y_up};
|
||||
|
||||
/// Device units per mm under `EC1`: 0.001 in.
|
||||
pub const UNITS_PER_MM: f64 = 1.0 / 0.0254;
|
||||
|
||||
/// Encodes `job` as DMPL. A job with no geometry gives an empty program.
|
||||
pub fn encode(job: &Job) -> Result<Program, Error> {
|
||||
let Some(prepared) = job.prepare()? else {
|
||||
return Ok(Program::default());
|
||||
};
|
||||
let p = y_up(job, prepared.bbox_mm.2, UNITS_PER_MM);
|
||||
let mut c = vec![Command::Raw(";:".to_string()), Command::Raw("EC1".to_string())];
|
||||
for path in prepared.operations.iter().flatten() {
|
||||
let Some((first, rest)) = path.split_first() else {
|
||||
continue;
|
||||
};
|
||||
let (x, y) = p(first);
|
||||
c.push(Command::Raw(format!("U{x},{y}")));
|
||||
let pts: Vec<String> = rest
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let (x, y) = p(q);
|
||||
format!("{x},{y}")
|
||||
})
|
||||
.collect();
|
||||
c.push(Command::Raw(format!("D{}", pts.join(","))));
|
||||
}
|
||||
c.push(Command::Raw("U0,0".to_string()));
|
||||
c.push(Command::Raw("@".to_string()));
|
||||
Ok(Program { commands: c, bbox_mm: Some(prepared.bbox_mm), framing: Framing::Newline, registration_wait: None })
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Graphtec GP-GL. This module is the plain language as Graphtec's own cutters take it; the
|
||||
//! dialect of the Silhouette machines, with its setup preamble, registration marks and device
|
||||
//! queries, is [`silhouette`].
|
||||
//!
|
||||
//! The plain stream is `!<speed>` when an operation sets one, then per path `M<down>,<right>` to
|
||||
//! its start and `D<down>,<right>` to each further point, then `M<below>,0` to park below the cut.
|
||||
//! Every command ends with ETX. Coordinates are feed axis first, in steps of 0.1 mm, which must be
|
||||
//! the GP-GL step size set on the machine's panel. Force is set on the panel.
|
||||
|
||||
pub mod silhouette;
|
||||
|
||||
use crate::{Command, Error, Framing, Job, Program, units};
|
||||
|
||||
/// Command terminator: every GP-GL command is followed by ETX (0x03).
|
||||
pub const ETX: u8 = 0x03;
|
||||
|
||||
/// Plain GP-GL steps per mm (0.1 mm steps).
|
||||
pub const UNITS_PER_MM: f64 = 10.0;
|
||||
|
||||
/// Encodes `job` as plain GP-GL. A job with no geometry gives an empty program.
|
||||
pub fn encode(job: &Job) -> Result<Program, Error> {
|
||||
let Some(prepared) = job.prepare()? else {
|
||||
return Ok(Program::default());
|
||||
};
|
||||
let (dm, rm) = (job.origin_down_mm, job.origin_right_mm);
|
||||
let g = |&(x, y): &(f64, f64)| (units(y + dm, UNITS_PER_MM), units(x + rm, UNITS_PER_MM));
|
||||
let mut c = Vec::new();
|
||||
for (op, paths) in job.operations.iter().zip(&prepared.operations) {
|
||||
if let Some(s) = op.settings.speed {
|
||||
c.push(Command::Raw(format!("!{s}")));
|
||||
}
|
||||
for path in paths {
|
||||
let Some((first, rest)) = path.split_first() else {
|
||||
continue;
|
||||
};
|
||||
let (d, r) = g(first);
|
||||
c.push(Command::Raw(format!("M{d},{r}")));
|
||||
for q in rest {
|
||||
let (d, r) = g(q);
|
||||
c.push(Command::Raw(format!("D{d},{r}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Park below the cut, head up.
|
||||
c.push(Command::Raw(format!("M{},0", units(prepared.bbox_mm.2 + job.end_feed_mm.max(0.0), UNITS_PER_MM))));
|
||||
Ok(Program { commands: c, bbox_mm: Some(prepared.bbox_mm), framing: Framing::Etx, registration_wait: None })
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
//! The GP-GL dialect of Silhouette cutters (and the Graphtec Craft Robo they descend from), in
|
||||
//! three generations: [`Generation::Legacy`], [`Generation::Cameo3`] and [`Generation::Cameo4`].
|
||||
//!
|
||||
//! [`encode`] gives the whole program: per operation a setup preamble (mat, cutting region, tool
|
||||
//! holder, force, speed, blade offset, corner sharpening), the registration-mark commands after
|
||||
//! the first preamble, the move/draw body, and a park below the cut. Coordinates are feed axis
|
||||
//! first in units of 0.05 mm.
|
||||
//!
|
||||
//! The device is asked about itself with [`INIT`], [`STATUS_QUERY`], [`VERSION_QUERY`] and
|
||||
//! [`TOOL_QUERY`]; [`parse_status`], [`parse_version`] and [`parse_tool_setup`] read the replies.
|
||||
|
||||
use crate::{BBoxMm, Command, Error, Framing, Job, Operation, Path, Program, Registration};
|
||||
|
||||
/// Device units ("Silhouette Units") per mm: the native resolution is 0.05 mm.
|
||||
pub const SU_PER_MM: f64 = 20.0;
|
||||
/// Device units per inch.
|
||||
pub const SU_PER_INCH: f64 = 508.0;
|
||||
|
||||
/// Escape `\x1b\x04` (EOT): initializes the device.
|
||||
pub const INIT: &str = "\u{1b}\u{4}";
|
||||
/// Escape `\x1b\x05` (ENQ): device status query; [`parse_status`] reads the reply.
|
||||
pub const STATUS_QUERY: &str = "\u{1b}\u{5}";
|
||||
/// Escape `\x1b\x15` (NAK): tool-setup query (Cameo 4 line); [`parse_tool_setup`] reads the reply.
|
||||
pub const TOOL_QUERY: &str = "\u{1b}\u{15}";
|
||||
/// The firmware-version command, `FG`. It is an ordinary command: [`VERSION_QUERY`] is the bytes
|
||||
/// to send. The reply is e.g. `CAMEO 4 PRO V1.02`; [`parse_version`] reads it.
|
||||
pub const VERSION_COMMAND: &str = "FG";
|
||||
/// [`VERSION_COMMAND`] with its ETX terminator.
|
||||
pub const VERSION_QUERY: &[u8] = b"FG\x03";
|
||||
|
||||
/// Most bytes of a reply the parsers look at. Real replies are a few dozen bytes.
|
||||
pub const MAX_REPLY_LEN: usize = 256;
|
||||
/// Most holders [`parse_tool_setup`] reports. Real machines have one or two.
|
||||
pub const MAX_TOOL_HOLDERS: usize = 8;
|
||||
|
||||
/// Millimetres to device units, rounded.
|
||||
pub fn su(mm: f64) -> i64 {
|
||||
(mm * SU_PER_MM).round() as i64
|
||||
}
|
||||
|
||||
/// Which generation of the dialect a machine speaks.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Generation {
|
||||
/// Before the Cameo 3 (Cameo 1/2, Portrait 1/2, Craft Robo, SD): `FW` media select, and
|
||||
/// force, speed and blade offset without a tool holder.
|
||||
#[default]
|
||||
Legacy,
|
||||
/// Cameo 3: commands addressed to a tool holder, mat select, blade depth.
|
||||
Cameo3,
|
||||
/// Cameo 4 line (Cameo 4/5, Portrait 3/4): force and blade offset are sent twice.
|
||||
Cameo4,
|
||||
}
|
||||
|
||||
/// A cutting mat: the device's `TG` code and the mat's usable extent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Mat {
|
||||
#[default]
|
||||
None,
|
||||
Cameo12x12,
|
||||
Cameo12x24,
|
||||
Portrait8x12,
|
||||
CameoPlus15x15,
|
||||
CameoPro24x24,
|
||||
Portrait8_5x12,
|
||||
}
|
||||
|
||||
impl Mat {
|
||||
pub const ALL: &'static [Mat] =
|
||||
&[Mat::None, Mat::Cameo12x12, Mat::Cameo12x24, Mat::Portrait8x12, Mat::CameoPlus15x15, Mat::CameoPro24x24, Mat::Portrait8_5x12];
|
||||
|
||||
/// The device's `TG` mat code.
|
||||
pub fn code(self) -> u8 {
|
||||
match self {
|
||||
Mat::None => 0,
|
||||
Mat::Cameo12x12 => 1,
|
||||
Mat::Cameo12x24 => 2,
|
||||
Mat::Portrait8x12 => 3,
|
||||
Mat::CameoPlus15x15 => 8,
|
||||
Mat::CameoPro24x24 => 9,
|
||||
Mat::Portrait8_5x12 => 11,
|
||||
}
|
||||
}
|
||||
|
||||
/// A short name by size (`12x24`, `none`, …); [`Mat::parse`] reads it back.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Mat::None => "none",
|
||||
Mat::Cameo12x12 => "12x12",
|
||||
Mat::Cameo12x24 => "12x24",
|
||||
Mat::Portrait8x12 => "8x12",
|
||||
Mat::CameoPlus15x15 => "15x15",
|
||||
Mat::CameoPro24x24 => "24x24",
|
||||
Mat::Portrait8_5x12 => "8.5x12",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Mat> {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
if s == "portrait8x12" {
|
||||
return Some(Mat::Portrait8x12);
|
||||
}
|
||||
Mat::ALL.iter().copied().find(|m| m.id() == s)
|
||||
}
|
||||
|
||||
/// Usable mat extent (down, right) in inches. The Pro mat is 24 in × 24 in even though the
|
||||
/// tray takes 609.6 mm-wide material.
|
||||
pub fn extent_inch(self) -> (f64, f64) {
|
||||
match self {
|
||||
Mat::None => (0.0, 0.0),
|
||||
Mat::Cameo12x12 => (12.0, 12.0),
|
||||
Mat::Cameo12x24 => (24.0, 12.0),
|
||||
Mat::Portrait8x12 => (12.0, 8.0),
|
||||
Mat::CameoPlus15x15 => (15.0, 15.0),
|
||||
Mat::CameoPro24x24 => (24.0, 24.0),
|
||||
Mat::Portrait8_5x12 => (12.0, 8.5),
|
||||
}
|
||||
}
|
||||
|
||||
/// Usable mat extent (down, right) in mm.
|
||||
pub fn extent_mm(self) -> (f64, f64) {
|
||||
let (d, r) = self.extent_inch();
|
||||
(d * 25.4, r * 25.4)
|
||||
}
|
||||
}
|
||||
|
||||
/// A mat is serialized as its short name ([`Mat::id`]); an unknown name is an error.
|
||||
#[cfg(feature = "serde")]
|
||||
impl serde::Serialize for Mat {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(self.id())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
impl<'de> serde::Deserialize<'de> for Mat {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Mat, D::Error> {
|
||||
let name = <std::borrow::Cow<'de, str>>::deserialize(d)?;
|
||||
Mat::parse(&name).ok_or_else(|| serde::de::Error::custom(format!("unknown mat `{name}`")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine and media a program is written for: what the preamble announces besides the
|
||||
/// per-operation tool settings.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Setup {
|
||||
pub generation: Generation,
|
||||
/// The machine has two tool holders. A Cameo 4 line machine with one holder gets no `J`.
|
||||
pub two_holders: bool,
|
||||
/// The cutting region (down, right) in mm the boundary commands announce.
|
||||
pub area_down_mm: f64,
|
||||
pub area_right_mm: f64,
|
||||
pub mat: Mat,
|
||||
/// Landscape media feed (`TB50,1`).
|
||||
pub landscape: bool,
|
||||
/// Roller track enhancing (`FY0`).
|
||||
pub track_enhancing: bool,
|
||||
/// Media preset id for the legacy `FW` command; ids outside 100..=300 are sent as 300 (custom).
|
||||
pub media_id: Option<u16>,
|
||||
}
|
||||
|
||||
/// `FF` corner-sharpening pair: tenths of a millimetre (not device units), truncated, start-only
|
||||
/// first then start+end; pens send zeros.
|
||||
fn sharpen_pair(op: &Operation, t: u8) -> [Command; 2] {
|
||||
let s = &op.settings;
|
||||
let (start, end) = if op.pen { (0, 0) } else { (((s.sharpen_start_mm + 0.05) * 10.0) as i64, ((s.sharpen_end_mm + 0.05) * 10.0) as i64) };
|
||||
[
|
||||
Command::SharpenCorners { start_tenths: start, end_tenths: 0, toolholder: t },
|
||||
Command::SharpenCorners { start_tenths: start, end_tenths: end, toolholder: t },
|
||||
]
|
||||
}
|
||||
|
||||
/// Mat select, reset, feed direction and cutting region: what the Cameo 3 and newer receive
|
||||
/// before any tool command.
|
||||
fn mat_block(setup: &Setup) -> Vec<Command> {
|
||||
vec![
|
||||
Command::SelectMat(setup.mat),
|
||||
Command::Reset,
|
||||
Command::PortraitMode(setup.landscape),
|
||||
Command::BoundaryTopLeft { down_mm: 0.0, right_mm: 0.0 },
|
||||
Command::BoundaryBottomRight { down_mm: setup.area_down_mm, right_mm: setup.area_right_mm },
|
||||
]
|
||||
}
|
||||
|
||||
/// Blade depth (`TF`, holder 1 only).
|
||||
fn depth_cmd(op: &Operation) -> Option<Command> {
|
||||
(op.holder == 1).then_some(op.settings.depth).flatten().map(|d| Command::Depth(d, 1))
|
||||
}
|
||||
|
||||
fn step(v: Option<u32>) -> Option<u8> {
|
||||
v.and_then(|v| u8::try_from(v).ok())
|
||||
}
|
||||
|
||||
fn cameo4_setup(setup: &Setup, op: &Operation, first: bool, c: &mut Vec<Command>) {
|
||||
let t = op.holder;
|
||||
let (force, speed) = (step(op.settings.force), step(op.settings.speed));
|
||||
if first {
|
||||
c.extend(mat_block(setup));
|
||||
}
|
||||
if setup.two_holders {
|
||||
c.push(Command::SelectTool(t));
|
||||
}
|
||||
if let Some(p) = force {
|
||||
c.push(Command::Pressure(p, t));
|
||||
// On first connect acceleration is set to 0, then bumped later.
|
||||
c.push(Command::Acceleration(0));
|
||||
}
|
||||
if let Some(s) = speed {
|
||||
c.push(Command::Speed(s, t));
|
||||
}
|
||||
// The first cutter-offset command always reads 0 mm × 0.05 mm.
|
||||
c.push(Command::CutterOffset { x_mm: 0.0, y_mm: 0.05, toolholder: t });
|
||||
c.push(Command::Lift { lift: op.settings.sharpen_corners, toolholder: t });
|
||||
c.extend(sharpen_pair(op, t));
|
||||
// Force and acceleration go out a second time (observed behaviour).
|
||||
if let Some(p) = force {
|
||||
c.push(Command::Pressure(p, t));
|
||||
c.push(Command::Acceleration(3));
|
||||
}
|
||||
c.push(Command::CutterOffset { x_mm: blade_offset(op), y_mm: 0.05, toolholder: t });
|
||||
c.extend(depth_cmd(op));
|
||||
if first && setup.track_enhancing {
|
||||
c.push(Command::TrackEnhancing(true));
|
||||
}
|
||||
}
|
||||
|
||||
fn cameo3_setup(setup: &Setup, op: &Operation, first: bool, c: &mut Vec<Command>) {
|
||||
let t = op.holder;
|
||||
if first {
|
||||
c.extend(mat_block(setup));
|
||||
}
|
||||
c.push(Command::SelectTool(t));
|
||||
if let Some(s) = step(op.settings.speed) {
|
||||
c.push(Command::Speed(s, t));
|
||||
}
|
||||
if let Some(p) = step(op.settings.force) {
|
||||
c.push(Command::Pressure(p, t));
|
||||
}
|
||||
let zero = Command::CutterOffset { x_mm: 0.0, y_mm: 0.05, toolholder: t };
|
||||
if op.pen {
|
||||
c.push(zero.clone());
|
||||
}
|
||||
c.push(Command::Lift { lift: op.settings.sharpen_corners, toolholder: t });
|
||||
c.extend(sharpen_pair(op, t));
|
||||
if !op.pen {
|
||||
c.push(zero);
|
||||
c.push(Command::CutterOffset { x_mm: blade_offset(op), y_mm: 0.05, toolholder: t });
|
||||
}
|
||||
c.extend(depth_cmd(op));
|
||||
if first && setup.track_enhancing {
|
||||
c.push(Command::TrackEnhancing(true));
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_setup(setup: &Setup, op: &Operation, first: bool, c: &mut Vec<Command>) {
|
||||
if first && let Some(id) = setup.media_id {
|
||||
c.push(Command::MediaSelect(if (100..=300).contains(&id) { id } else { 300 }));
|
||||
}
|
||||
if let Some(s) = step(op.settings.speed) {
|
||||
c.push(Command::SpeedLegacy(s));
|
||||
}
|
||||
if let Some(p) = step(op.settings.force) {
|
||||
c.push(Command::PressureLegacy(p));
|
||||
}
|
||||
c.push(Command::CutterOffsetLegacy(blade_offset(op)));
|
||||
if first {
|
||||
c.push(Command::TrackEnhancing(setup.track_enhancing));
|
||||
c.push(Command::Reset);
|
||||
c.push(Command::PortraitMode(setup.landscape));
|
||||
c.push(Command::BoundaryTopLeft { down_mm: 0.0, right_mm: 0.0 });
|
||||
c.push(Command::BoundaryBottomRight { down_mm: setup.area_down_mm, right_mm: setup.area_right_mm });
|
||||
c.push(Command::PenUp);
|
||||
c.push(Command::Raw("FE0,0".to_string()));
|
||||
c.push(Command::Raw("FF0,0,0".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn blade_offset(op: &Operation) -> f64 {
|
||||
if op.pen { 0.0 } else { op.settings.blade_offset_mm }
|
||||
}
|
||||
|
||||
/// The preamble for one operation. `first` adds the job-level block (mat, region, media, track
|
||||
/// enhancing); later operations only re-tool. A force or speed that does not fit the command's
|
||||
/// one-byte step is left to the machine.
|
||||
pub fn setup(setup: &Setup, op: &Operation, first: bool) -> Vec<Command> {
|
||||
let mut c = Vec::new();
|
||||
match setup.generation {
|
||||
Generation::Cameo4 => cameo4_setup(setup, op, first, &mut c),
|
||||
Generation::Cameo3 => cameo3_setup(setup, op, first, &mut c),
|
||||
Generation::Legacy => legacy_setup(setup, op, first, &mut c),
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Registration-mark commands: mark geometry, then the search (or manual positioning) request.
|
||||
/// The device answers ` 0\x03` (see [`parse_status`]) once it has the marks.
|
||||
pub fn registration(reg: &Registration) -> Vec<Command> {
|
||||
let raw = |s: &str| Command::Raw(s.to_string());
|
||||
let mut c = vec![
|
||||
Command::PortraitMode(false),
|
||||
raw("TB99"),
|
||||
// Mark type 2 = Cameo/Portrait marks; arm length 400 SU (20 mm); stroke 10 SU.
|
||||
raw("TB52,2"),
|
||||
raw("TB51,400"),
|
||||
raw("TB53,10"),
|
||||
raw("TB55,1"),
|
||||
];
|
||||
let (h, w) = (su(reg.length_mm), su(reg.width_mm));
|
||||
if reg.search {
|
||||
// Search 10 mm around the expected first-mark position.
|
||||
let top = su((reg.origin_down_mm - 10.0).max(0.0));
|
||||
let left = su((reg.origin_right_mm - 10.0).max(0.0));
|
||||
let code = if reg.quad { 124 } else { 123 };
|
||||
c.push(Command::Raw(format!("TB{code},{h},{w},{top},{left}")));
|
||||
} else {
|
||||
c.push(Command::Raw(format!("TB23,{h},{w}")));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Move/draw commands for paths, offset by the job origin. Paths of fewer than two points are
|
||||
/// skipped.
|
||||
pub fn plot_body(paths: &[Path], down: f64, right: f64) -> Vec<Command> {
|
||||
let mut commands = Vec::new();
|
||||
for path in paths {
|
||||
let Some((first, rest)) = path.split_first() else {
|
||||
continue;
|
||||
};
|
||||
if rest.is_empty() {
|
||||
continue;
|
||||
}
|
||||
commands.push(Command::Move { down_mm: first.1 + down, right_mm: first.0 + right });
|
||||
for &(x, y) in rest {
|
||||
commands.push(Command::Draw { down_mm: y + down, right_mm: x + right });
|
||||
}
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
/// Park below the cut so the next job (or the cross-cutter) starts there.
|
||||
pub fn park(bb: BBoxMm, end_feed_mm: f64) -> [Command; 2] {
|
||||
[Command::Move { down_mm: bb.2 + end_feed_mm, right_mm: 0.0 }, Command::Origin]
|
||||
}
|
||||
|
||||
/// Encodes `job` for a Silhouette machine. A job with no geometry gives an empty program. With
|
||||
/// registration, [`Program::registration_wait`] says where the sender waits for the marks.
|
||||
pub fn encode(s: &Setup, job: &Job) -> Result<Program, Error> {
|
||||
let Some(prepared) = job.prepare()? else {
|
||||
return Ok(Program::default());
|
||||
};
|
||||
let mut commands = Vec::new();
|
||||
let mut registration_wait = None;
|
||||
for (i, (op, paths)) in job.operations.iter().zip(&prepared.operations).enumerate() {
|
||||
commands.extend(setup(s, op, i == 0));
|
||||
if i == 0
|
||||
&& let Some(r) = &job.registration
|
||||
{
|
||||
commands.extend(registration(r));
|
||||
registration_wait = Some(commands.len());
|
||||
}
|
||||
commands.extend(plot_body(paths, job.origin_down_mm, job.origin_right_mm));
|
||||
}
|
||||
commands.extend(park(prepared.bbox_mm, job.end_feed_mm));
|
||||
Ok(Program { commands, bbox_mm: Some(prepared.bbox_mm), framing: Framing::Etx, registration_wait })
|
||||
}
|
||||
|
||||
/// Device status from the status query.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
Ready,
|
||||
Moving,
|
||||
/// No media loaded.
|
||||
Unloaded,
|
||||
/// A status byte this crate does not know (255 when the reply had none).
|
||||
Unknown(u8),
|
||||
}
|
||||
|
||||
fn capped(reply: &[u8]) -> &[u8] {
|
||||
reply.get(..MAX_REPLY_LEN).unwrap_or(reply)
|
||||
}
|
||||
|
||||
/// Parses a status reply (e.g. `b" 0\x03"`: an ASCII digit before the ETX terminator, possibly
|
||||
/// padded with spaces). The same reply reports that the registration marks were found.
|
||||
pub fn parse_status(reply: &[u8]) -> Status {
|
||||
match capped(reply).iter().find(|b| **b != b' ' && **b != 0x03).copied() {
|
||||
Some(b'0') => Status::Ready,
|
||||
Some(b'1') => Status::Moving,
|
||||
Some(b'2') => Status::Unloaded,
|
||||
Some(other) => Status::Unknown(other),
|
||||
None => Status::Unknown(255),
|
||||
}
|
||||
}
|
||||
|
||||
fn reply_text(reply: &[u8]) -> String {
|
||||
String::from_utf8_lossy(capped(reply)).trim_matches(|c: char| c == '\u{3}' || c.is_whitespace()).to_string()
|
||||
}
|
||||
|
||||
/// The firmware version string of a version reply, trimmed of ETX and padding.
|
||||
pub fn parse_version(reply: &[u8]) -> String {
|
||||
reply_text(reply)
|
||||
}
|
||||
|
||||
/// A tool as the tool-setup query reports it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
Empty,
|
||||
RatchetBlade,
|
||||
AutoBlade,
|
||||
DeepCutBlade,
|
||||
KraftBlade,
|
||||
RotaryBlade,
|
||||
Pen,
|
||||
/// The holder reports a fault.
|
||||
Error,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
pub fn from_code(code: u8) -> Option<Tool> {
|
||||
Some(match code {
|
||||
0 => Tool::Empty,
|
||||
1 => Tool::RatchetBlade,
|
||||
2 => Tool::AutoBlade,
|
||||
3 => Tool::DeepCutBlade,
|
||||
4 => Tool::KraftBlade,
|
||||
5 => Tool::RotaryBlade,
|
||||
7 => Tool::Pen,
|
||||
255 => Tool::Error,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a tool-setup reply (`"2,7\x03"`: the tool codes of holder 1, holder 2). An unknown code
|
||||
/// is `None`. At most [`MAX_TOOL_HOLDERS`] holders are reported.
|
||||
pub fn parse_tool_setup(reply: &[u8]) -> Vec<Option<Tool>> {
|
||||
reply_text(reply)
|
||||
.split(',')
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.take(MAX_TOOL_HOLDERS)
|
||||
.map(|s| s.trim().parse::<u8>().ok().and_then(Tool::from_code))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unit_conversions() {
|
||||
assert_eq!(su(0.05), 1);
|
||||
assert_eq!(su(0.9), 18); // stock blade diameter
|
||||
assert_eq!(su(600.0), 12_000);
|
||||
assert_eq!(su(3000.0), 60_000);
|
||||
assert_eq!(18.0 / SU_PER_MM, 0.9);
|
||||
assert_eq!((24.0 * SU_PER_INCH) as i64, 12_192);
|
||||
// Out of range saturates; not-a-number is zero.
|
||||
assert_eq!(su(f64::MAX), i64::MAX);
|
||||
assert_eq!(su(f64::NAN), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_bytes() {
|
||||
assert_eq!(INIT.as_bytes(), b"\x1b\x04");
|
||||
assert_eq!(STATUS_QUERY.as_bytes(), b"\x1b\x05");
|
||||
assert_eq!(TOOL_QUERY.as_bytes(), b"\x1b\x15");
|
||||
assert_eq!(VERSION_QUERY, [VERSION_COMMAND.as_bytes(), &[crate::gpgl::ETX]].concat());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_status_decodes_known_replies() {
|
||||
assert_eq!(parse_status(b"0"), Status::Ready);
|
||||
assert_eq!(parse_status(b"1"), Status::Moving);
|
||||
assert_eq!(parse_status(b"2"), Status::Unloaded);
|
||||
assert_eq!(parse_status(b" 0\x03"), Status::Ready);
|
||||
assert_eq!(parse_status(b"9"), Status::Unknown(b'9'));
|
||||
assert_eq!(parse_status(b""), Status::Unknown(255));
|
||||
assert_eq!(parse_status(&[0xff, 0x00]), Status::Unknown(0xff));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_and_tool_setup_replies_parse() {
|
||||
assert_eq!(parse_version(b"CAMEO 4 PRO V1.02\x03"), "CAMEO 4 PRO V1.02");
|
||||
assert_eq!(parse_version(b" \x03"), "");
|
||||
// Bytes that are not UTF-8 are replaced, never a panic.
|
||||
assert_eq!(parse_version(b"CAMEO\xff\x03"), "CAMEO\u{fffd}");
|
||||
assert_eq!(parse_tool_setup(b"2,7\x03"), vec![Some(Tool::AutoBlade), Some(Tool::Pen)]);
|
||||
assert_eq!(parse_tool_setup(b"2,0\x03"), vec![Some(Tool::AutoBlade), Some(Tool::Empty)]);
|
||||
assert_eq!(parse_tool_setup(b"9\x03"), vec![None]);
|
||||
assert_eq!(parse_tool_setup(b"255,x,-1,300"), vec![Some(Tool::Error), None, None, None]);
|
||||
assert!(parse_tool_setup(b"").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_are_read_only_up_to_the_cap() {
|
||||
// A status digit past the cap is not seen.
|
||||
let mut padded = vec![b' '; MAX_REPLY_LEN];
|
||||
padded.push(b'0');
|
||||
assert_eq!(parse_status(&padded), Status::Unknown(255));
|
||||
*padded.last_mut().unwrap() = b' ';
|
||||
padded[MAX_REPLY_LEN - 1] = b'0';
|
||||
assert_eq!(parse_status(&padded), Status::Ready);
|
||||
assert_eq!(parse_version(&vec![b'A'; MAX_REPLY_LEN * 4]).len(), MAX_REPLY_LEN);
|
||||
// A reply of nothing but separators and codes reports at most MAX_TOOL_HOLDERS holders.
|
||||
assert_eq!(parse_tool_setup(&b"1,".repeat(100)).len(), MAX_TOOL_HOLDERS);
|
||||
assert_eq!(parse_tool_setup(&b"1,".repeat(MAX_TOOL_HOLDERS)).len(), MAX_TOOL_HOLDERS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mats_round_trip_and_have_codes() {
|
||||
for m in Mat::ALL {
|
||||
assert_eq!(Mat::parse(m.id()), Some(*m));
|
||||
}
|
||||
assert_eq!(Mat::parse(" Portrait8x12 "), Some(Mat::Portrait8x12));
|
||||
assert_eq!(Mat::parse("13x13"), None);
|
||||
assert_eq!(Mat::ALL.iter().map(|m| m.code()).collect::<Vec<_>>(), [0, 1, 2, 3, 8, 9, 11]);
|
||||
let (down, right) = Mat::Cameo12x24.extent_mm();
|
||||
assert!((down - 609.6).abs() < 1e-9 && (right - 304.8).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
#[test]
|
||||
fn mats_serialize_as_their_names() {
|
||||
use serde::de::value::{Error, StrDeserializer, StringDeserializer};
|
||||
use serde::{Deserialize, de::IntoDeserializer};
|
||||
for m in Mat::ALL {
|
||||
let borrowed: StrDeserializer<Error> = m.id().into_deserializer();
|
||||
assert_eq!(Mat::deserialize(borrowed), Ok(*m));
|
||||
}
|
||||
let owned: StringDeserializer<Error> = "8.5X12 ".to_string().into_deserializer();
|
||||
assert_eq!(Mat::deserialize(owned), Ok(Mat::Portrait8_5x12));
|
||||
let unknown: StrDeserializer<Error> = "13x13".into_deserializer();
|
||||
assert_eq!(Mat::deserialize(unknown).unwrap_err().to_string(), "unknown mat `13x13`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_force_or_speed_past_one_byte_is_left_to_the_machine() {
|
||||
let mut op = Operation { holder: 1, ..Operation::default() };
|
||||
op.settings.force = Some(256);
|
||||
op.settings.speed = Some(u32::MAX);
|
||||
for generation in [Generation::Legacy, Generation::Cameo3, Generation::Cameo4] {
|
||||
let lines: Vec<String> = setup(&Setup { generation, ..Setup::default() }, &op, false).iter().map(Command::to_ascii).collect();
|
||||
assert!(!lines.iter().any(|l| l.starts_with("FX") || l.starts_with('!')), "{lines:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! HP-GL, the subset every cutter that speaks it understands (`IN`, `SP`, `PU`, `PD`, which is
|
||||
//! also valid HP-GL/2): Roland, USCutter and many unbranded sign cutters, and Graphtec machines in
|
||||
//! HP-GL mode.
|
||||
//!
|
||||
//! The stream is `IN;`, then per operation `SP<holder>;` and per path `PUx,y;` to its start and
|
||||
//! one `PDx,y,…;` through the rest, then `PU;` and `SP0;`. One command per line. The origin is
|
||||
//! bottom-left and y points up, so the design is flipped about its bottom edge. Force and speed
|
||||
//! are set on the machine's panel.
|
||||
|
||||
use crate::{Command, Error, Framing, Job, Program, y_up};
|
||||
|
||||
/// Plotter units per mm (1 plu = 0.025 mm).
|
||||
pub const UNITS_PER_MM: f64 = 40.0;
|
||||
|
||||
/// Encodes `job` as HP-GL. A job with no geometry gives an empty program.
|
||||
pub fn encode(job: &Job) -> Result<Program, Error> {
|
||||
let Some(prepared) = job.prepare()? else {
|
||||
return Ok(Program::default());
|
||||
};
|
||||
let p = y_up(job, prepared.bbox_mm.2, UNITS_PER_MM);
|
||||
let mut c = vec![Command::Raw("IN;".to_string())];
|
||||
for (op, paths) in job.operations.iter().zip(&prepared.operations) {
|
||||
c.push(Command::Raw(format!("SP{};", op.holder)));
|
||||
for path in paths {
|
||||
let Some((first, rest)) = path.split_first() else {
|
||||
continue;
|
||||
};
|
||||
let (x, y) = p(first);
|
||||
c.push(Command::Raw(format!("PU{x},{y};")));
|
||||
let pts: Vec<String> = rest
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let (x, y) = p(q);
|
||||
format!("{x},{y}")
|
||||
})
|
||||
.collect();
|
||||
c.push(Command::Raw(format!("PD{};", pts.join(","))));
|
||||
}
|
||||
}
|
||||
c.push(Command::Raw("PU;".to_string()));
|
||||
c.push(Command::Raw("SP0;".to_string()));
|
||||
Ok(Program { commands: c, bbox_mm: Some(prepared.bbox_mm), framing: Framing::Newline, registration_wait: None })
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
//! Command languages of vinyl cutters and pen plotters: HP-GL ([`hpgl`]), DMPL ([`dmpl`]) and
|
||||
//! Graphtec GP-GL ([`gpgl`], plain and the Silhouette dialect in [`gpgl::silhouette`]).
|
||||
//!
|
||||
//! A [`Job`] is ordered [`Operation`]s: polylines in millimetres (`x` right, `y` down, origin at
|
||||
//! the media's top-left corner) plus the tool settings they are traced with. Each language's
|
||||
//! `encode` turns a job into a [`Program`]: the commands, how they are framed on the wire, and
|
||||
//! where a sender must wait for the device. The Silhouette module also has the bytes of the
|
||||
//! device queries and the parsers of their replies.
|
||||
//!
|
||||
//! The crate does no I/O: bytes and plain types in, bytes and plain types out. Opening a port,
|
||||
//! writing a program to it and polling for replies are the caller's business, and so is what a
|
||||
//! particular machine can do (cutting area, force range, which tools it holds).
|
||||
//!
|
||||
//! Input is not trusted. A job is bounded by [`MAX_OPERATIONS`], [`MAX_POINTS`], [`MAX_PASSES`]
|
||||
//! and [`MAX_OVERCUT_MM`], and must be finite; a reply is read only up to
|
||||
//! [`gpgl::silhouette::MAX_REPLY_LEN`] bytes. Nothing here panics.
|
||||
//!
|
||||
//! Depends on no other Craft crate. Where the protocol facts come from is in the README.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod dmpl;
|
||||
pub mod gpgl;
|
||||
pub mod hpgl;
|
||||
|
||||
use gpgl::silhouette::{Mat, su};
|
||||
|
||||
/// Most points a job may hold, over all operations, before passes and overcut multiply them.
|
||||
pub const MAX_POINTS: usize = 2_000_000;
|
||||
/// Most times an operation may trace each path.
|
||||
pub const MAX_PASSES: u8 = 10;
|
||||
/// Most operations in a job.
|
||||
pub const MAX_OPERATIONS: usize = 64;
|
||||
/// Largest line-segment overcut at sharp corners, mm.
|
||||
pub const MAX_OVERCUT_MM: f64 = 2.0;
|
||||
|
||||
/// Why a job was refused. Nothing is encoded from a refused job.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("{0} operations exceeds the {MAX_OPERATIONS} cap")]
|
||||
TooManyOperations(usize),
|
||||
#[error("{0} points exceeds the {MAX_POINTS} cap")]
|
||||
TooManyPoints(usize),
|
||||
#[error("passes {0} outside 1..={MAX_PASSES}")]
|
||||
Passes(u8),
|
||||
#[error("overcut {0} mm outside 0..={MAX_OVERCUT_MM}")]
|
||||
Overcut(f64),
|
||||
#[error("a coordinate, offset or setting is not finite")]
|
||||
NotFinite,
|
||||
}
|
||||
|
||||
/// A polyline in mm, `(x right, y down)`.
|
||||
pub type Path = Vec<(f64, f64)>;
|
||||
|
||||
/// Bounding box in mm `(down-min, right-min, down-max, right-max)`.
|
||||
pub type BBoxMm = (f64, f64, f64, f64);
|
||||
|
||||
/// Tool settings for one operation. `None` leaves the value to the machine's panel.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ToolSettings {
|
||||
/// Force in the device's own steps.
|
||||
pub force: Option<u32>,
|
||||
/// Speed in the device's own steps.
|
||||
pub speed: Option<u32>,
|
||||
/// Trace each path this many times, back to back (0 counts as 1; at most [`MAX_PASSES`]).
|
||||
pub passes: u8,
|
||||
/// Blade depth 0..10 for a blade the machine adjusts itself (Silhouette `TF`, holder 1 only).
|
||||
pub depth: Option<u8>,
|
||||
/// Line-segment overcut at sharp corners, mm (0 = off; at most [`MAX_OVERCUT_MM`]).
|
||||
pub overcut_mm: f64,
|
||||
/// Blade swivel offset, mm (0.9 for the stock Silhouette blade).
|
||||
pub blade_offset_mm: f64,
|
||||
/// Lift the head at sharp corners.
|
||||
pub sharpen_corners: bool,
|
||||
pub sharpen_start_mm: f64,
|
||||
pub sharpen_end_mm: f64,
|
||||
}
|
||||
|
||||
impl Default for ToolSettings {
|
||||
fn default() -> Self {
|
||||
ToolSettings {
|
||||
force: None,
|
||||
speed: None,
|
||||
passes: 1,
|
||||
depth: None,
|
||||
overcut_mm: 0.0,
|
||||
blade_offset_mm: 0.9,
|
||||
sharpen_corners: false,
|
||||
sharpen_start_mm: 0.1,
|
||||
sharpen_end_mm: 0.1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One ordered step of a job: geometry plus how to trace it.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Operation {
|
||||
/// A pen, not a blade: no blade offset and no corner sharpening.
|
||||
pub pen: bool,
|
||||
/// Tool holder, 1-based.
|
||||
pub holder: u8,
|
||||
/// Polylines in mm. A path of fewer than two points is skipped.
|
||||
pub paths: Vec<Path>,
|
||||
pub settings: ToolSettings,
|
||||
}
|
||||
|
||||
/// Print-and-cut registration: the optical sensor finds the printed marks before cutting. All mm.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Registration {
|
||||
/// Distance between the marks across the media (right axis).
|
||||
pub width_mm: f64,
|
||||
/// Distance between the marks along the feed (down axis).
|
||||
pub length_mm: f64,
|
||||
/// Position of the top-left mark on the page; the design is shifted so that the mark becomes
|
||||
/// the origin.
|
||||
pub origin_right_mm: f64,
|
||||
pub origin_down_mm: f64,
|
||||
/// Automatic search; `false` = manual positioning.
|
||||
pub search: bool,
|
||||
/// Four-mark search (only machines that have it).
|
||||
pub quad: bool,
|
||||
}
|
||||
|
||||
/// What to trace, in which order, and where on the media.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Job {
|
||||
pub operations: Vec<Operation>,
|
||||
/// Offset of the job origin from the media's top-left corner, mm.
|
||||
pub origin_down_mm: f64,
|
||||
pub origin_right_mm: f64,
|
||||
/// Extra feed after the last cut (GP-GL park position), mm.
|
||||
pub end_feed_mm: f64,
|
||||
/// Trace only the bounding rectangle, with the first operation's tool (placement preview).
|
||||
pub bbox_only: bool,
|
||||
/// Registration marks. Every language shifts the design by the mark origin; only the
|
||||
/// Silhouette dialect has the commands that make the device look for the marks.
|
||||
pub registration: Option<Registration>,
|
||||
}
|
||||
|
||||
/// The paths a job traces, ready to encode: see [`Job::prepare`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Prepared {
|
||||
/// One path list per operation, in job order (only the first, holding the bounding rectangle,
|
||||
/// for a `bbox_only` job).
|
||||
pub operations: Vec<Vec<Path>>,
|
||||
/// Bounding box of everything the job traces, before the `bbox_only` substitution.
|
||||
pub bbox_mm: BBoxMm,
|
||||
}
|
||||
|
||||
impl Job {
|
||||
/// Checks the job against the crate's limits. Every `encode` runs this first.
|
||||
pub fn check(&self) -> Result<(), Error> {
|
||||
if self.operations.len() > MAX_OPERATIONS {
|
||||
return Err(Error::TooManyOperations(self.operations.len()));
|
||||
}
|
||||
let reg = self.registration.map_or([0.0; 4], |r| [r.width_mm, r.length_mm, r.origin_right_mm, r.origin_down_mm]);
|
||||
let mut finite = reg.iter().chain(&[self.origin_down_mm, self.origin_right_mm, self.end_feed_mm]).all(|v| v.is_finite());
|
||||
let mut points = 0usize;
|
||||
for op in &self.operations {
|
||||
let s = &op.settings;
|
||||
if s.passes > MAX_PASSES {
|
||||
return Err(Error::Passes(s.passes));
|
||||
}
|
||||
if s.overcut_mm.is_nan() || s.overcut_mm > MAX_OVERCUT_MM {
|
||||
return Err(Error::Overcut(s.overcut_mm));
|
||||
}
|
||||
finite &= [s.overcut_mm, s.blade_offset_mm, s.sharpen_start_mm, s.sharpen_end_mm].iter().all(|v| v.is_finite());
|
||||
for path in &op.paths {
|
||||
points = points.saturating_add(path.len());
|
||||
finite &= path.iter().all(|(x, y)| x.is_finite() && y.is_finite());
|
||||
}
|
||||
}
|
||||
if points > MAX_POINTS {
|
||||
return Err(Error::TooManyPoints(points));
|
||||
}
|
||||
if !finite {
|
||||
return Err(Error::NotFinite);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks the job, then gives each operation's paths as the tool traces them: overcut applied,
|
||||
/// each path repeated once per pass, shifted so the first registration mark is the origin.
|
||||
/// `None` when the job has no geometry.
|
||||
pub fn prepare(&self) -> Result<Option<Prepared>, Error> {
|
||||
self.check()?;
|
||||
let reg = self.registration.as_ref();
|
||||
let mut operations: Vec<Vec<Path>> = self.operations.iter().map(|op| prepared_paths(op, reg)).collect();
|
||||
let Some(bbox_mm) = operations.iter().fold(None, |bb, paths| grow_bbox(bb, paths)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if self.bbox_only {
|
||||
operations.truncate(1);
|
||||
if let Some(first) = operations.first_mut() {
|
||||
*first = vec![bbox_rect(bbox_mm)];
|
||||
}
|
||||
}
|
||||
Ok(Some(Prepared { operations, bbox_mm }))
|
||||
}
|
||||
}
|
||||
|
||||
fn grow_bbox(mut bbox: Option<BBoxMm>, paths: &[Path]) -> Option<BBoxMm> {
|
||||
for &(x, y) in paths.iter().flatten() {
|
||||
bbox = Some(match bbox {
|
||||
None => (y, x, y, x),
|
||||
Some((d0, r0, d1, r1)) => (d0.min(y), r0.min(x), d1.max(y), r1.max(x)),
|
||||
});
|
||||
}
|
||||
bbox
|
||||
}
|
||||
|
||||
/// The mm bounding box of a path list, or `None` when it has no point.
|
||||
pub fn find_bbox(paths: &[Path]) -> Option<BBoxMm> {
|
||||
grow_bbox(None, paths)
|
||||
}
|
||||
|
||||
/// The closed rectangle path `(x, y)` around a bounding box.
|
||||
pub fn bbox_rect(bb: BBoxMm) -> Path {
|
||||
vec![(bb.1, bb.0), (bb.3, bb.0), (bb.3, bb.2), (bb.1, bb.2), (bb.1, bb.0)]
|
||||
}
|
||||
|
||||
/// Corners sharper than this turn (30°) get a line-segment overcut; gentler joints are flattened
|
||||
/// curves and must not be overcut.
|
||||
const OVERCUT_MIN_TURN_COS: f64 = 0.866;
|
||||
|
||||
fn prepared_paths(op: &Operation, reg: Option<&Registration>) -> Vec<Path> {
|
||||
let passes = op.settings.passes.max(1);
|
||||
let (sd, sr) = reg.map_or((0.0, 0.0), |r| (r.origin_down_mm, r.origin_right_mm));
|
||||
let mut out = Vec::with_capacity(op.paths.len().saturating_mul(usize::from(passes)));
|
||||
for path in op.paths.iter().filter(|p| p.len() >= 2) {
|
||||
let cut = if op.settings.overcut_mm > 0.0 { overcut_path(path, op.settings.overcut_mm) } else { path.clone() };
|
||||
let cut: Path = cut.into_iter().map(|(x, y)| (x - sr, y - sd)).collect();
|
||||
for _ in 0..passes {
|
||||
out.push(cut.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Line-segment overcut: at every corner sharper than 30° the blade runs `o` mm past the corner
|
||||
/// and back, so a drag knife cuts the corner through. A closed path's closing corner counts.
|
||||
pub fn overcut_path(path: &[(f64, f64)], o: f64) -> Path {
|
||||
let closed = path.len() > 2 && path.first() == path.last();
|
||||
let n = path.len();
|
||||
let mut out = Vec::with_capacity(n.saturating_mul(3));
|
||||
for (i, &b) in path.iter().enumerate() {
|
||||
out.push(b);
|
||||
// The segment arriving at b, and the one leaving it (wrapping on closed paths).
|
||||
let Some(&a) = i.checked_sub(1).and_then(|j| path.get(j)) else {
|
||||
continue;
|
||||
};
|
||||
let next = if i + 1 < n {
|
||||
path.get(i + 1)
|
||||
} else if closed {
|
||||
path.get(1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(&c) = next else { continue };
|
||||
let (ux, uy, ul) = (b.0 - a.0, b.1 - a.1, (b.0 - a.0).hypot(b.1 - a.1));
|
||||
let (vx, vy, vl) = (c.0 - b.0, c.1 - b.1, (c.0 - b.0).hypot(c.1 - b.1));
|
||||
if ul <= f64::EPSILON || vl <= f64::EPSILON {
|
||||
continue;
|
||||
}
|
||||
let cos = (ux * vx + uy * vy) / (ul * vl);
|
||||
if cos < OVERCUT_MIN_TURN_COS {
|
||||
out.push((b.0 + ux / ul * o, b.1 + uy / ul * o));
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Millimetres to a language's integer device units, rounded.
|
||||
fn units(mm: f64, per_mm: f64) -> i64 {
|
||||
(mm * per_mm).round() as i64
|
||||
}
|
||||
|
||||
/// HP-GL and DMPL are y-up: flip about the design's bottom edge so the cut reads the right way
|
||||
/// round with the origin at the bottom-left.
|
||||
fn y_up(job: &Job, bottom: f64, per: f64) -> impl Fn(&(f64, f64)) -> (i64, i64) {
|
||||
let (dm, rm) = (job.origin_down_mm, job.origin_right_mm);
|
||||
move |&(x, y)| (units(x + rm, per), units(bottom + dm - (y + dm), per))
|
||||
}
|
||||
|
||||
/// One device command. [`Command::to_ascii`] gives the exact text, without the terminator the
|
||||
/// program's [`Framing`] adds. The typed variants are the Silhouette GP-GL command set, with
|
||||
/// lengths in millimetres; every other language builds its commands as [`Command::Raw`] text.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Command {
|
||||
/// `J<toolholder>`: select the active tool holder.
|
||||
SelectTool(u8),
|
||||
/// `FX<p>,<toolholder>`: cutting force.
|
||||
Pressure(u8, u8),
|
||||
/// `!<speed>,<toolholder>`: cut speed.
|
||||
Speed(u8, u8),
|
||||
/// `TF<depth>,<toolholder>`: blade depth.
|
||||
Depth(u8, u8),
|
||||
/// `FC<x>,<y>,<toolholder>`: blade swivel circle (in device units).
|
||||
CutterOffset { x_mm: f64, y_mm: f64, toolholder: u8 },
|
||||
/// `FE<n>,<toolholder>`: lift head between paths when `n == 1`.
|
||||
Lift { lift: bool, toolholder: u8 },
|
||||
/// `FF<start>,<end>,<toolholder>`: corner sharpening range, tenths of mm.
|
||||
SharpenCorners { start_tenths: i64, end_tenths: i64, toolholder: u8 },
|
||||
/// `TJ<acceleration>`: acceleration ramp.
|
||||
Acceleration(i64),
|
||||
/// `M<down>,<right>`: head-up move, mm coordinates.
|
||||
Move { down_mm: f64, right_mm: f64 },
|
||||
/// `D<down>,<right>`: head-down draw/cut to point, mm coordinates.
|
||||
Draw { down_mm: f64, right_mm: f64 },
|
||||
/// `\<down>,<right>`: cutting-region upper-left bound.
|
||||
BoundaryTopLeft { down_mm: f64, right_mm: f64 },
|
||||
/// `Z<down>,<right>`: cutting-region lower-right bound.
|
||||
BoundaryBottomRight { down_mm: f64, right_mm: f64 },
|
||||
/// `FY<0|1>`: roller track enhancing (`FY0` = on).
|
||||
TrackEnhancing(bool),
|
||||
/// `TG<code>`: select cutting mat.
|
||||
SelectMat(Mat),
|
||||
/// `FN0`: device soft reset.
|
||||
Reset,
|
||||
/// `TB50,0` / `TB50,1`: portrait/landscape feed direction.
|
||||
PortraitMode(bool),
|
||||
/// `L0`: pen-up marker between plot sections.
|
||||
PenUp,
|
||||
/// `SO0`: mark origin/service position.
|
||||
Origin,
|
||||
/// `H`: full home (Cameo 3 and newer).
|
||||
Home,
|
||||
/// `FW<id>`: media select (legacy generation only).
|
||||
MediaSelect(u16),
|
||||
/// `FX<p>`: force without a holder (legacy generation).
|
||||
PressureLegacy(u8),
|
||||
/// `!<s>`: speed without a holder (legacy generation).
|
||||
SpeedLegacy(u8),
|
||||
/// `FC<d>`: blade offset without a holder (legacy generation).
|
||||
CutterOffsetLegacy(f64),
|
||||
/// A command of another language (HP-GL, DMPL, plain GP-GL, G-code) or a fixed-text Silhouette
|
||||
/// command (`TB99`, …), already as text.
|
||||
Raw(String),
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn to_ascii(&self) -> String {
|
||||
match self {
|
||||
Command::SelectTool(t) => format!("J{t}"),
|
||||
Command::Pressure(p, t) => format!("FX{p},{t}"),
|
||||
Command::Speed(s, t) => format!("!{s},{t}"),
|
||||
Command::Depth(d, t) => format!("TF{d},{t}"),
|
||||
Command::CutterOffset { x_mm, y_mm, toolholder } => format!("FC{},{},{}", su(*x_mm), su(*y_mm), toolholder),
|
||||
Command::Lift { lift, toolholder } => format!("FE{},{}", u8::from(*lift), toolholder),
|
||||
Command::SharpenCorners { start_tenths, end_tenths, toolholder } => format!("FF{start_tenths},{end_tenths},{toolholder}"),
|
||||
Command::Acceleration(a) => format!("TJ{a}"),
|
||||
Command::Move { down_mm, right_mm } => format!("M{},{}", su(*down_mm), su(*right_mm)),
|
||||
Command::Draw { down_mm, right_mm } => format!("D{},{}", su(*down_mm), su(*right_mm)),
|
||||
Command::BoundaryTopLeft { down_mm, right_mm } => format!("\\{},{}", su(*down_mm), su(*right_mm)),
|
||||
Command::BoundaryBottomRight { down_mm, right_mm } => format!("Z{},{}", su(*down_mm), su(*right_mm)),
|
||||
Command::TrackEnhancing(on) => format!("FY{}", if *on { 0 } else { 1 }),
|
||||
Command::SelectMat(mat) => format!("TG{}", mat.code()),
|
||||
Command::Reset => "FN0".to_string(),
|
||||
Command::PortraitMode(landscape) => format!("TB50,{}", u8::from(*landscape)),
|
||||
Command::PenUp => "L0".to_string(),
|
||||
Command::Origin => "SO0".to_string(),
|
||||
Command::Home => "H".to_string(),
|
||||
Command::MediaSelect(id) => format!("FW{id}"),
|
||||
Command::PressureLegacy(p) => format!("FX{p}"),
|
||||
Command::SpeedLegacy(s) => format!("!{s}"),
|
||||
Command::CutterOffsetLegacy(d) => format!("FC{}", su(*d)),
|
||||
Command::Raw(s) => s.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a program's commands are framed on the wire.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Framing {
|
||||
/// GP-GL: each command followed by ETX.
|
||||
#[default]
|
||||
Etx,
|
||||
/// HP-GL / DMPL / G-code: commands carry their own terminators; newline between.
|
||||
Newline,
|
||||
}
|
||||
|
||||
/// An encoded program: the commands, their framing, and where a sender must pause for the
|
||||
/// registration-mark search.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Program {
|
||||
pub commands: Vec<Command>,
|
||||
pub bbox_mm: Option<BBoxMm>,
|
||||
pub framing: Framing,
|
||||
/// Registration: after this many commands the sender must wait for the device to report the
|
||||
/// marks found before streaming the rest.
|
||||
pub registration_wait: Option<usize>,
|
||||
}
|
||||
|
||||
impl Program {
|
||||
/// The exact byte stream to write to the device.
|
||||
pub fn to_stream(&self) -> Vec<u8> {
|
||||
stream(&self.commands, self.framing)
|
||||
}
|
||||
|
||||
/// One command per line, for dry-run transcripts.
|
||||
pub fn transcript(&self) -> Vec<String> {
|
||||
self.commands.iter().map(Command::to_ascii).collect()
|
||||
}
|
||||
|
||||
/// The commands to send before waiting for the registration marks, and the commands to send
|
||||
/// once the device has found them. Without registration everything is in the second part.
|
||||
pub fn split_at_registration(&self) -> (&[Command], &[Command]) {
|
||||
let at = self.registration_wait.unwrap_or(0).min(self.commands.len());
|
||||
self.commands.split_at_checked(at).unwrap_or((&[], &self.commands))
|
||||
}
|
||||
}
|
||||
|
||||
/// The byte stream of `commands` under `framing`.
|
||||
pub fn stream(commands: &[Command], framing: Framing) -> Vec<u8> {
|
||||
let end = match framing {
|
||||
Framing::Etx => gpgl::ETX,
|
||||
Framing::Newline => b'\n',
|
||||
};
|
||||
let mut out = Vec::with_capacity(commands.len().saturating_mul(8));
|
||||
for cmd in commands {
|
||||
out.extend_from_slice(cmd.to_ascii().as_bytes());
|
||||
out.push(end);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A GP-GL byte stream: each command's ASCII followed by an ETX byte.
|
||||
pub fn delimit(commands: &[Command]) -> Vec<u8> {
|
||||
stream(commands, Framing::Etx)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn square() -> Path {
|
||||
vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)]
|
||||
}
|
||||
|
||||
fn job(paths: Vec<Path>) -> Job {
|
||||
Job { operations: vec![Operation { holder: 1, paths, ..Operation::default() }], ..Job::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overcut_extends_sharp_corners_only() {
|
||||
// Every square corner gets out-and-back, including the closing corner.
|
||||
assert_eq!(
|
||||
overcut_path(&square(), 1.0),
|
||||
vec![
|
||||
(0.0, 0.0),
|
||||
(10.0, 0.0),
|
||||
(11.0, 0.0),
|
||||
(10.0, 0.0),
|
||||
(10.0, 10.0),
|
||||
(10.0, 11.0),
|
||||
(10.0, 10.0),
|
||||
(0.0, 10.0),
|
||||
(-1.0, 10.0),
|
||||
(0.0, 10.0),
|
||||
(0.0, 0.0),
|
||||
(0.0, -1.0),
|
||||
(0.0, 0.0),
|
||||
]
|
||||
);
|
||||
// A gentle polyline (10° turns) is left alone.
|
||||
let gentle = vec![(0.0, 0.0), (10.0, 0.0), (19.85, 1.74)];
|
||||
assert_eq!(overcut_path(&gentle, 1.0), gentle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_repeats_passes_shifts_by_the_mark_origin_and_boxes() {
|
||||
let mut j = job(vec![vec![(20.0, 25.0), (30.0, 35.0)], vec![(1.0, 1.0)]]);
|
||||
j.operations[0].settings.passes = 3;
|
||||
j.registration =
|
||||
Some(Registration { width_mm: 180.0, length_mm: 230.0, origin_right_mm: 15.0, origin_down_mm: 20.0, search: true, quad: false });
|
||||
let p = j.prepare().unwrap().unwrap();
|
||||
// The one-point path is dropped; the other is traced three times, shifted.
|
||||
assert_eq!(p.operations, vec![vec![vec![(5.0, 5.0), (15.0, 15.0)]; 3]]);
|
||||
assert_eq!(p.bbox_mm, (5.0, 5.0, 15.0, 15.0));
|
||||
// Zero passes count as one.
|
||||
j.operations[0].settings.passes = 0;
|
||||
assert_eq!(j.prepare().unwrap().unwrap().operations[0].len(), 1);
|
||||
j.bbox_only = true;
|
||||
j.operations.push(j.operations[0].clone());
|
||||
assert_eq!(j.prepare().unwrap().unwrap().operations, vec![vec![bbox_rect((5.0, 5.0, 15.0, 15.0))]]);
|
||||
assert_eq!(job(vec![]).prepare(), Ok(None));
|
||||
assert_eq!(find_bbox(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_job_limit_is_enforced() {
|
||||
let op = Operation { holder: 1, paths: vec![square()], ..Operation::default() };
|
||||
let mut j = Job { operations: vec![op.clone(); MAX_OPERATIONS], ..Job::default() };
|
||||
assert_eq!(j.check(), Ok(()));
|
||||
j.operations.push(op);
|
||||
assert_eq!(j.check(), Err(Error::TooManyOperations(MAX_OPERATIONS + 1)));
|
||||
|
||||
let mut j = job(vec![vec![(0.0, 0.0); MAX_POINTS / 2]; 2]);
|
||||
assert_eq!(j.check(), Ok(()));
|
||||
j.operations[0].paths.push(vec![(0.0, 0.0)]);
|
||||
assert_eq!(j.check(), Err(Error::TooManyPoints(MAX_POINTS + 1)));
|
||||
|
||||
let mut j = job(vec![square()]);
|
||||
j.operations[0].settings.passes = MAX_PASSES;
|
||||
assert_eq!(j.check(), Ok(()));
|
||||
j.operations[0].settings.passes = MAX_PASSES + 1;
|
||||
assert_eq!(j.check(), Err(Error::Passes(MAX_PASSES + 1)));
|
||||
|
||||
let mut j = job(vec![square()]);
|
||||
j.operations[0].settings.overcut_mm = MAX_OVERCUT_MM;
|
||||
assert_eq!(j.check(), Ok(()));
|
||||
j.operations[0].settings.overcut_mm = MAX_OVERCUT_MM + 0.01;
|
||||
assert_eq!(j.check(), Err(Error::Overcut(MAX_OVERCUT_MM + 0.01)));
|
||||
j.operations[0].settings.overcut_mm = f64::NAN;
|
||||
assert!(matches!(j.check(), Err(Error::Overcut(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_finite_job_is_refused_by_every_language() {
|
||||
let bad: [fn(&mut Job); 6] = [
|
||||
|j| j.operations[0].paths[0][1].0 = f64::NAN,
|
||||
|j| j.origin_down_mm = f64::INFINITY,
|
||||
|j| j.origin_right_mm = f64::NAN,
|
||||
|j| j.end_feed_mm = f64::NEG_INFINITY,
|
||||
|j| j.operations[0].settings.blade_offset_mm = f64::NAN,
|
||||
|j| {
|
||||
j.registration =
|
||||
Some(Registration { width_mm: f64::NAN, length_mm: 1.0, origin_right_mm: 0.0, origin_down_mm: 0.0, search: false, quad: false })
|
||||
},
|
||||
];
|
||||
for spoil in bad {
|
||||
let mut j = job(vec![square()]);
|
||||
spoil(&mut j);
|
||||
assert_eq!(hpgl::encode(&j), Err(Error::NotFinite));
|
||||
assert_eq!(dmpl::encode(&j), Err(Error::NotFinite));
|
||||
assert_eq!(gpgl::encode(&j), Err(Error::NotFinite));
|
||||
assert_eq!(gpgl::silhouette::encode(&gpgl::silhouette::Setup::default(), &j), Err(Error::NotFinite));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_coordinates_saturate_instead_of_overflowing() {
|
||||
let j = job(vec![vec![(0.0, 0.0), (f64::MAX, f64::MAX)]]);
|
||||
for p in [hpgl::encode(&j), dmpl::encode(&j), gpgl::encode(&j)] {
|
||||
assert!(p.unwrap().transcript().iter().any(|l| l.contains(&i64::MAX.to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framing_and_the_registration_split() {
|
||||
assert_eq!(delimit(&[Command::Home, Command::Origin]), b"H\x03SO0\x03".to_vec());
|
||||
let mut p = Program { commands: vec![Command::Home, Command::Origin], ..Program::default() };
|
||||
assert_eq!(p.to_stream(), b"H\x03SO0\x03".to_vec());
|
||||
assert_eq!(p.split_at_registration(), (&[][..], &p.commands[..]));
|
||||
p.registration_wait = Some(1);
|
||||
assert_eq!(p.split_at_registration(), (&[Command::Home][..], &[Command::Origin][..]));
|
||||
// A wait index past the end sends everything before the wait, never panics.
|
||||
p.registration_wait = Some(usize::MAX);
|
||||
assert_eq!(p.split_at_registration(), (&p.commands[..], &[][..]));
|
||||
p.framing = Framing::Newline;
|
||||
assert_eq!(p.to_stream(), b"H\nSO0\n".to_vec());
|
||||
assert_eq!(p.transcript(), ["H", "SO0"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Golden programs: the exact commands each language emits for fixed jobs. A change here changes
|
||||
//! what reaches a machine, so review any diff by hand.
|
||||
|
||||
use craft_plotter::gpgl::silhouette::{self, Generation, Mat, Setup};
|
||||
use craft_plotter::{Command, Error, Framing, Job, Operation, Path, Program, Registration, ToolSettings, dmpl, gpgl, hpgl};
|
||||
|
||||
/// A closed square and an open triangle.
|
||||
fn design() -> Vec<Path> {
|
||||
vec![vec![(5.0, 5.0), (15.0, 5.0), (15.0, 15.0), (5.0, 15.0), (5.0, 5.0)], vec![(20.0, 5.0), (30.0, 15.0), (20.0, 15.0)]]
|
||||
}
|
||||
|
||||
fn square() -> Vec<Path> {
|
||||
vec![vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)]]
|
||||
}
|
||||
|
||||
fn job(paths: Vec<Path>, force: Option<u32>, speed: Option<u32>) -> Job {
|
||||
let settings = ToolSettings { force, speed, ..ToolSettings::default() };
|
||||
Job { operations: vec![Operation { holder: 1, paths, settings, ..Operation::default() }], ..Job::default() }
|
||||
}
|
||||
|
||||
/// A 12 in wide machine with the 12x24 mat, vinyl preset 102.
|
||||
fn machine(generation: Generation, area_right_mm: f64) -> Setup {
|
||||
Setup {
|
||||
generation,
|
||||
two_holders: generation != Generation::Legacy,
|
||||
area_down_mm: 3000.0,
|
||||
area_right_mm,
|
||||
mat: Mat::Cameo12x24,
|
||||
media_id: Some(102),
|
||||
..Setup::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn lines(p: Result<Program, Error>) -> String {
|
||||
p.unwrap().transcript().join(" ")
|
||||
}
|
||||
|
||||
const BODY: &str = "M100,100 D100,300 D300,300 D300,100 D100,100 M100,400 D300,600 D300,400 M300,0 SO0";
|
||||
|
||||
#[test]
|
||||
fn silhouette_cameo4_program() {
|
||||
let p = silhouette::encode(&machine(Generation::Cameo4, 304.8), &job(design(), Some(10), Some(5)));
|
||||
assert_eq!(lines(p), format!("TG2 FN0 TB50,0 \\0,0 Z60000,6096 J1 FX10,1 TJ0 !5,1 FC0,1,1 FE0,1 FF1,0,1 FF1,1,1 FX10,1 TJ3 FC18,1,1 {BODY}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silhouette_cameo3_program() {
|
||||
let p = silhouette::encode(&machine(Generation::Cameo3, 304.8), &job(design(), Some(10), Some(5)));
|
||||
assert_eq!(lines(p), format!("TG2 FN0 TB50,0 \\0,0 Z60000,6096 J1 !5,1 FX10,1 FE0,1 FF1,0,1 FF1,1,1 FC0,1,1 FC18,1,1 {BODY}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silhouette_legacy_program() {
|
||||
let p = silhouette::encode(&machine(Generation::Legacy, 304.0), &job(design(), Some(10), Some(5)));
|
||||
assert_eq!(lines(p), format!("FW102 !5 FX10 FC18 FY1 FN0 TB50,0 \\0,0 Z60000,6080 L0 FE0,0 FF0,0,0 {BODY}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silhouette_stream_is_etx_framed() {
|
||||
let p = silhouette::encode(&machine(Generation::Legacy, 304.0), &job(vec![vec![(0.0, 0.0), (1.0, 0.0)]], None, None)).unwrap();
|
||||
assert_eq!(p.framing, Framing::Etx);
|
||||
assert_eq!(p.bbox_mm, Some((0.0, 0.0, 0.0, 1.0)));
|
||||
assert_eq!(
|
||||
p.to_stream(),
|
||||
b"FW102\x03FC18\x03FY1\x03FN0\x03TB50,0\x03\\0,0\x03Z60000,6080\x03L0\x03FE0,0\x03FF0,0,0\x03M0,0\x03D0,20\x03M0,0\x03SO0\x03".to_vec()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silhouette_setup_variants() {
|
||||
let op = &job(vec![], Some(10), Some(5)).operations[0];
|
||||
let ascii = |s: &Setup, op: &Operation| silhouette::setup(s, op, true).iter().map(Command::to_ascii).collect::<Vec<_>>().join(" ");
|
||||
// The 24 in Pro: its own mat, 600 mm across.
|
||||
let pro = Setup {
|
||||
generation: Generation::Cameo4,
|
||||
two_holders: true,
|
||||
area_down_mm: 3000.0,
|
||||
area_right_mm: 600.0,
|
||||
mat: Mat::CameoPro24x24,
|
||||
..Setup::default()
|
||||
};
|
||||
assert_eq!(ascii(&pro, op), "TG9 FN0 TB50,0 \\0,0 Z60000,12000 J1 FX10,1 TJ0 !5,1 FC0,1,1 FE0,1 FF1,0,1 FF1,1,1 FX10,1 TJ3 FC18,1,1");
|
||||
// A one-holder machine of the Cameo 4 line gets no tool select.
|
||||
let portrait = Setup { two_holders: false, mat: Mat::Portrait8_5x12, ..pro.clone() };
|
||||
assert!(ascii(&portrait, op).starts_with("TG11 FN0 TB50,0 \\0,0 Z60000,12000 FX10,1 "));
|
||||
// Blade depth and track enhancing close the preamble; depth is for holder 1 only.
|
||||
let mut deep = op.clone();
|
||||
deep.settings.depth = Some(4);
|
||||
let enhanced = Setup { track_enhancing: true, landscape: true, ..pro.clone() };
|
||||
assert!(ascii(&enhanced, &deep).ends_with(" FC18,1,1 TF4,1 FY0"));
|
||||
assert!(ascii(&enhanced, &deep).contains(" TB50,1 "));
|
||||
deep.holder = 2;
|
||||
assert!(!ascii(&enhanced, &deep).contains("TF"));
|
||||
// A pen: no blade offset, no corner sharpening.
|
||||
let pen = Operation { pen: true, holder: 2, ..op.clone() };
|
||||
assert!(ascii(&pro, &pen).ends_with("J2 FX10,2 TJ0 !5,2 FC0,1,2 FE0,2 FF0,0,2 FF0,0,2 FX10,2 TJ3 FC0,1,2"));
|
||||
let cameo3 = Setup { generation: Generation::Cameo3, ..pro.clone() };
|
||||
assert!(ascii(&cameo3, &pen).ends_with("J2 !5,2 FX10,2 FC0,1,2 FE0,2 FF0,0,2 FF0,0,2"));
|
||||
// Legacy: a media id outside the device table is sent as custom; track enhancing is FY0.
|
||||
let legacy = Setup { generation: Generation::Legacy, media_id: Some(999), track_enhancing: true, ..Setup::default() };
|
||||
assert_eq!(ascii(&legacy, &pen), "FW300 !5 FX10 FC0 FY0 FN0 TB50,0 \\0,0 Z0,0 L0 FE0,0 FF0,0,0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silhouette_retools_between_operations() {
|
||||
let mut j = job(square(), Some(10), Some(10));
|
||||
j.operations[0].pen = true;
|
||||
j.operations[0].holder = 2;
|
||||
let settings = ToolSettings { force: Some(20), speed: Some(5), depth: Some(3), passes: 2, ..ToolSettings::default() };
|
||||
j.operations.push(Operation { holder: 1, paths: square(), settings, ..Operation::default() });
|
||||
let setup = Setup { mat: Mat::Cameo12x12, ..machine(Generation::Cameo4, 304.8) };
|
||||
let square = "M0,0 D0,200 D200,200 D200,0 D0,0";
|
||||
assert_eq!(
|
||||
lines(silhouette::encode(&setup, &j)),
|
||||
format!(
|
||||
"TG1 FN0 TB50,0 \\0,0 Z60000,6096 J2 FX10,2 TJ0 !10,2 FC0,1,2 FE0,2 FF0,0,2 FF0,0,2 FX10,2 TJ3 FC0,1,2 {square} \
|
||||
J1 FX20,1 TJ0 !5,1 FC0,1,1 FE0,1 FF1,0,1 FF1,1,1 FX20,1 TJ3 FC18,1,1 TF3,1 {square} {square} M200,0 SO0"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silhouette_registration_marks() {
|
||||
let pro = Setup {
|
||||
generation: Generation::Cameo4,
|
||||
two_holders: true,
|
||||
area_down_mm: 3000.0,
|
||||
area_right_mm: 600.0,
|
||||
mat: Mat::CameoPro24x24,
|
||||
..Setup::default()
|
||||
};
|
||||
let mut j = job(vec![vec![(20.0, 25.0), (30.0, 25.0)]], Some(10), Some(5));
|
||||
let mut reg = Registration { width_mm: 180.0, length_mm: 230.0, origin_right_mm: 15.0, origin_down_mm: 20.0, search: true, quad: false };
|
||||
j.registration = Some(reg);
|
||||
let p = silhouette::encode(&pro, &j).unwrap();
|
||||
let (head, rest) = p.split_at_registration();
|
||||
let text = |c: &[Command]| c.iter().map(Command::to_ascii).collect::<Vec<_>>().join(" ");
|
||||
assert!(text(head).ends_with(" FC18,1,1 TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB55,1 TB123,4600,3600,200,100"));
|
||||
// The design is shifted so the first mark is the origin.
|
||||
assert_eq!(text(rest), "M100,100 D100,300 M100,0 SO0");
|
||||
assert_eq!(p.registration_wait, Some(head.len()));
|
||||
|
||||
let ascii = |r: &Registration| text(&silhouette::registration(r));
|
||||
reg.quad = true;
|
||||
assert!(ascii(®).ends_with(" TB124,4600,3600,200,100"));
|
||||
// The search margin never goes negative.
|
||||
reg.origin_down_mm = 4.0;
|
||||
reg.origin_right_mm = 0.0;
|
||||
assert!(ascii(®).ends_with(" TB124,4600,3600,0,0"));
|
||||
reg.search = false;
|
||||
assert_eq!(ascii(®), "TB50,0 TB99 TB52,2 TB51,400 TB53,10 TB55,1 TB23,4600,3600");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silhouette_plot_body_and_park() {
|
||||
let text = |c: &[Command]| c.iter().map(Command::to_ascii).collect::<Vec<_>>().join(" ");
|
||||
// Axes are swapped: down first. One-point and empty paths are skipped.
|
||||
let mut paths = square();
|
||||
paths.extend([vec![(1.0, 1.0)], vec![]]);
|
||||
assert_eq!(text(&silhouette::plot_body(&paths, 0.0, 0.0)), "M0,0 D0,200 D200,200 D200,0 D0,0");
|
||||
assert_eq!(text(&silhouette::plot_body(&square(), 1.0, 2.0)), "M20,40 D20,240 D220,240 D220,40 D20,40");
|
||||
assert_eq!(text(&silhouette::park((0.0, 0.0, 10.0, 10.0), 5.0)), "M300,0 SO0");
|
||||
assert_eq!(text(&silhouette::park((0.0, 0.0, 10.0, 10.0), -5.0)), "M100,0 SO0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bbox_only_traces_the_rectangle_with_the_first_tool() {
|
||||
let mut j = job(vec![vec![(2.0, 1.0), (6.0, 5.0)]], None, None);
|
||||
j.operations.push(Operation { holder: 2, paths: square(), ..Operation::default() });
|
||||
j.bbox_only = true;
|
||||
let s = Setup::default();
|
||||
assert!(lines(silhouette::encode(&s, &j)).ends_with(" M0,0 D0,200 D200,200 D200,0 D0,0 M200,0 SO0"));
|
||||
assert_eq!(lines(hpgl::encode(&j)), "IN; SP1; PU0,400; PD400,400,400,0,0,0,0,400; PU; SP0;");
|
||||
assert_eq!(lines(dmpl::encode(&j)), ";: EC1 U0,394 D394,394,394,0,0,0,0,394 U0,0 @");
|
||||
assert_eq!(lines(gpgl::encode(&j)), "M0,0 D0,100 D100,100 D100,0 D0,0 M100,0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hpgl_program() {
|
||||
let p = hpgl::encode(&job(design(), None, None)).unwrap();
|
||||
assert_eq!(p.transcript().join(" "), "IN; SP1; PU200,400; PD600,400,600,0,200,0,200,400; PU800,400; PD1200,0,800,0; PU; SP0;");
|
||||
assert_eq!(p.framing, Framing::Newline);
|
||||
assert_eq!(p.to_stream(), b"IN;\nSP1;\nPU200,400;\nPD600,400,600,0,200,0,200,400;\nPU800,400;\nPD1200,0,800,0;\nPU;\nSP0;\n".to_vec());
|
||||
assert_eq!(p.bbox_mm, Some((5.0, 5.0, 15.0, 30.0)));
|
||||
assert_eq!(p.registration_wait, None);
|
||||
// A second operation selects its own pen; margins move the design.
|
||||
let mut j = job(square(), None, None);
|
||||
j.operations.push(Operation { holder: 2, paths: square(), ..Operation::default() });
|
||||
j.origin_down_mm = 1.0;
|
||||
j.origin_right_mm = 2.0;
|
||||
assert_eq!(lines(hpgl::encode(&j)), "IN; SP1; PU80,400; PD480,400,480,0,80,0,80,400; SP2; PU80,400; PD480,400,480,0,80,0,80,400; PU; SP0;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dmpl_program() {
|
||||
let p = dmpl::encode(&job(design(), None, None)).unwrap();
|
||||
assert_eq!(p.transcript().join(" "), ";: EC1 U197,394 D591,394,591,0,197,0,197,394 U787,394 D1181,0,787,0 U0,0 @");
|
||||
assert_eq!(p.framing, Framing::Newline);
|
||||
assert_eq!(p.to_stream(), b";:\nEC1\nU197,394\nD591,394,591,0,197,0,197,394\nU787,394\nD1181,0,787,0\nU0,0\n@\n".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpgl_program() {
|
||||
let p = gpgl::encode(&job(design(), None, None)).unwrap();
|
||||
assert_eq!(p.transcript().join(" "), "M50,50 D50,150 D150,150 D150,50 D50,50 M50,200 D150,300 D150,200 M150,0");
|
||||
assert_eq!(p.framing, Framing::Etx);
|
||||
assert_eq!(p.to_stream().last(), Some(&gpgl::ETX));
|
||||
// Speed when set; the park never backs into the design.
|
||||
let mut j = job(square(), Some(9), Some(4));
|
||||
j.end_feed_mm = -50.0;
|
||||
assert_eq!(lines(gpgl::encode(&j)), "!4 M0,0 D0,100 D100,100 D100,0 D0,0 M100,0");
|
||||
j.end_feed_mm = 5.0;
|
||||
j.operations[0].settings.passes = 2;
|
||||
assert_eq!(lines(gpgl::encode(&j)), "!4 M0,0 D0,100 D100,100 D100,0 D0,0 M0,0 D0,100 D100,100 D100,0 D0,0 M150,0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_without_geometry_is_an_empty_program() {
|
||||
let empty = job(vec![vec![(1.0, 1.0)]], Some(1), Some(1));
|
||||
for p in [hpgl::encode(&empty), dmpl::encode(&empty), gpgl::encode(&empty), silhouette::encode(&Setup::default(), &empty)] {
|
||||
assert_eq!(p, Ok(Program::default()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_job_encodes_nothing() {
|
||||
let mut j = job(square(), None, None);
|
||||
j.operations[0].settings.passes = 11;
|
||||
for p in [hpgl::encode(&j), dmpl::encode(&j), gpgl::encode(&j), silhouette::encode(&Setup::default(), &j)] {
|
||||
assert_eq!(p, Err(Error::Passes(11)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user