diff --git a/Cargo.lock b/Cargo.lock index aa8c292ef..4f9ae5ec6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index e64b06d1d..1b76e31d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index 1d23a3afd..19a447003 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " = { git = "https://github.com/storytold/craft-libs", rev = "` 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, diff --git a/crates/gcode/Cargo.toml b/crates/gcode/Cargo.toml index 44b58e501..524510211 100644 --- a/crates/gcode/Cargo.toml +++ b/crates/gcode/Cargo.toml @@ -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 diff --git a/crates/gcode/src/grbl.rs b/crates/gcode/src/grbl.rs new file mode 100644 index 000000000..efcac8b44 --- /dev/null +++ b/crates/gcode/src/grbl.rs @@ -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 +//! (): a line is answered with `ok` or +//! `error:`, a fault with `ALARM:`, and `?` with a status report ``. + +/// 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 (``, ``). `None` when `reply` is not a +/// status report. +pub fn parse_status(reply: &str) -> Option { + 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:` or `ALARM:`: 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 { + 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(""), Some(State::Idle)); + assert_eq!(parse_status(""), Some(State::Hold)); + assert_eq!(parse_status(" \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(""), 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}")), Some(State::Other("I".into()))); + assert_eq!(parse_status(&format!("{pad} ")), 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)))); + } +} diff --git a/crates/gcode/src/lib.rs b/crates/gcode/src/lib.rs index 4bd817c23..9e1b4e1cb 100644 --- a/crates/gcode/src/lib.rs +++ b/crates/gcode/src/lib.rs @@ -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 { 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 { + 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)); diff --git a/crates/plotter/Cargo.toml b/crates/plotter/Cargo.toml new file mode 100644 index 000000000..1c8649c28 --- /dev/null +++ b/crates/plotter/Cargo.toml @@ -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"] + diff --git a/crates/plotter/README.md b/crates/plotter/README.md new file mode 100644 index 000000000..59438d366 --- /dev/null +++ b/crates/plotter/README.md @@ -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;` 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` | `!` when set, `M,` / `D,` at 0.1 mm steps, park `M,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,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): + . + 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. diff --git a/crates/plotter/src/dmpl.rs b/crates/plotter/src/dmpl.rs new file mode 100644 index 000000000..af62d1a17 --- /dev/null +++ b/crates/plotter/src/dmpl.rs @@ -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 { + 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 = 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 }) +} diff --git a/crates/plotter/src/gpgl.rs b/crates/plotter/src/gpgl.rs new file mode 100644 index 000000000..5aae41a8e --- /dev/null +++ b/crates/plotter/src/gpgl.rs @@ -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 `!` when an operation sets one, then per path `M,` to +//! its start and `D,` to each further point, then `M,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 { + 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 }) +} diff --git a/crates/plotter/src/gpgl/silhouette.rs b/crates/plotter/src/gpgl/silhouette.rs new file mode 100644 index 000000000..9d5dff2d1 --- /dev/null +++ b/crates/plotter/src/gpgl/silhouette.rs @@ -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 { + 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(&self, s: S) -> Result { + s.serialize_str(self.id()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Mat { + fn deserialize>(d: D) -> Result { + let name = >::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, +} + +/// `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 { + 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 { + (op.holder == 1).then_some(op.settings.depth).flatten().map(|d| Command::Depth(d, 1)) +} + +fn step(v: Option) -> Option { + v.and_then(|v| u8::try_from(v).ok()) +} + +fn cameo4_setup(setup: &Setup, op: &Operation, first: bool, c: &mut Vec) { + 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) { + 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) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + reply_text(reply) + .split(',') + .filter(|s| !s.trim().is_empty()) + .take(MAX_TOOL_HOLDERS) + .map(|s| s.trim().parse::().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::>(), [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 = m.id().into_deserializer(); + assert_eq!(Mat::deserialize(borrowed), Ok(*m)); + } + let owned: StringDeserializer = "8.5X12 ".to_string().into_deserializer(); + assert_eq!(Mat::deserialize(owned), Ok(Mat::Portrait8_5x12)); + let unknown: StrDeserializer = "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 = 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:?}"); + } + } +} diff --git a/crates/plotter/src/hpgl.rs b/crates/plotter/src/hpgl.rs new file mode 100644 index 000000000..728f6b153 --- /dev/null +++ b/crates/plotter/src/hpgl.rs @@ -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;` 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 { + 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 = 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 }) +} diff --git a/crates/plotter/src/lib.rs b/crates/plotter/src/lib.rs new file mode 100644 index 000000000..00a6b2743 --- /dev/null +++ b/crates/plotter/src/lib.rs @@ -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, + /// Speed in the device's own steps. + pub speed: Option, + /// 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, + /// 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, + 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, + /// 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, +} + +/// 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>, + /// 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, Error> { + self.check()?; + let reg = self.registration.as_ref(); + let mut operations: Vec> = 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, paths: &[Path]) -> Option { + 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 { + 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 { + 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`: select the active tool holder. + SelectTool(u8), + /// `FX

,`: cutting force. + Pressure(u8, u8), + /// `!,`: cut speed. + Speed(u8, u8), + /// `TF,`: blade depth. + Depth(u8, u8), + /// `FC,,`: blade swivel circle (in device units). + CutterOffset { x_mm: f64, y_mm: f64, toolholder: u8 }, + /// `FE,`: lift head between paths when `n == 1`. + Lift { lift: bool, toolholder: u8 }, + /// `FF,,`: corner sharpening range, tenths of mm. + SharpenCorners { start_tenths: i64, end_tenths: i64, toolholder: u8 }, + /// `TJ`: acceleration ramp. + Acceleration(i64), + /// `M,`: head-up move, mm coordinates. + Move { down_mm: f64, right_mm: f64 }, + /// `D,`: head-down draw/cut to point, mm coordinates. + Draw { down_mm: f64, right_mm: f64 }, + /// `\,`: cutting-region upper-left bound. + BoundaryTopLeft { down_mm: f64, right_mm: f64 }, + /// `Z,`: cutting-region lower-right bound. + BoundaryBottomRight { down_mm: f64, right_mm: f64 }, + /// `FY<0|1>`: roller track enhancing (`FY0` = on). + TrackEnhancing(bool), + /// `TG`: 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`: media select (legacy generation only). + MediaSelect(u16), + /// `FX

`: force without a holder (legacy generation). + PressureLegacy(u8), + /// `!`: speed without a holder (legacy generation). + SpeedLegacy(u8), + /// `FC`: 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, + pub bbox_mm: Option, + 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, +} + +impl Program { + /// The exact byte stream to write to the device. + pub fn to_stream(&self) -> Vec { + stream(&self.commands, self.framing) + } + + /// One command per line, for dry-run transcripts. + pub fn transcript(&self) -> Vec { + 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 { + 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 { + 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) -> 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"]); + } +} diff --git a/crates/plotter/tests/golden.rs b/crates/plotter/tests/golden.rs new file mode 100644 index 000000000..26aaae554 --- /dev/null +++ b/crates/plotter/tests/golden.rs @@ -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 { + 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 { + 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, force: Option, speed: Option) -> 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) -> 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::>().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::>().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::>().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))); + } +}