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:
Dylan M. Taylor authored and GitHub committed 2026-11-11 17:29:14 -05:00
1 parent 9ab7768771
commit 5375f49ffc
14 files changed
+1814 -10

No files matched your search

Generated
+8
View File
@@ -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"
+1
View File
@@ -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" }
+6 -1
View File
@@ -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 -1
View File
@@ -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
+140
View File
@@ -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
View File
@@ -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));
+20
View File
@@ -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"]
+80
View File
@@ -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.
+40
View File
@@ -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 })
}
+47
View File
@@ -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 })
}
+544
View File
@@ -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:?}");
}
}
}
+43
View File
@@ -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 })
}
+565
View File
@@ -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"]);
}
}
+232
View File
@@ -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(&reg).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(&reg).ends_with(" TB124,4600,3600,0,0"));
reg.search = false;
assert_eq!(ascii(&reg), "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)));
}
}