Files
cadcraft/crates/engine/src/cmd/plotters.rs
T
26179f7428 Draw object transparency on screen, in exports and in plots (#764)
* Draw object transparency on screen, in exports and in plots

Transparency was read and saved (entity group 440, layer xdata, ByLayer and
ByBlock) but never drawn. Display lists now carry each object's effective
transparency (its own, its layer's, or the block reference's), and:

- the canvas (GPU and CPU paths), ui.render and PNG/SVG exports blend it;
  TRANSPARENCYDISPLAY turns it off on screen and is now on by default, as in
  AutoCAD (initial value 1);
- plots draw it only when "Plot transparency" is on: a page setup option
  (PAGESETUP `plotTransparency`, a checkbox in the Page Setup Manager and the
  Plot dialog), overridable per plot, with PLOTTRANSPARENCYOVERRIDE (0 never,
  1 the setting, 2 always). PDF gets /ExtGState alpha, PNG plots blend, SVG
  uses opacity. Plots without it are byte-identical to before.

The public DXF Reference has no plot layout flag for plot transparency, so
the option is kept in the session and in JSON, not in DXF/DWG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Transparency fixes: attributes, PDF overlaps, drag previews

- Attributes draw with their own transparency (their definition's, layer 0
  meaning the reference's layer, ByBlock the reference's) instead of the
  block reference's.
- PDF plots stroke overlapping transparent objects one by one, so they build
  up where they cross as in PNG plots; opaque output is unchanged.
- Drag and rubber-band previews follow TRANSPARENCYDISPLAY.
- Effective transparency is worked out only when it is drawn, reusing the
  layer the property lookup already found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Transparency: one PDF stroke per transparent primitive; opaque rebuilt previews

- PDF plots never batch two transparent primitives into one path. Block and
  viewport contents share their reference's handle, so the per-object flush
  missed them; each primitive now blends on its own, as in PNG plots and on
  screen. Opaque output is unchanged.
- Previews the session rebuilds with the current properties (rubber bands,
  copies of objects) are drawn opaque again: they carry CLAYER's
  transparency, not the objects'. The GPU drag of the objects themselves
  keeps following TRANSPARENCYDISPLAY.
- Tests for attributes on a transparent reference layer, attributes without a
  definition, and the drag cache across the TRANSPARENCYDISPLAY toggle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Selection like AutoCAD: colour highlight and the Options Selection tab

Selecting or hovering an object outlined each filled primitive (TrueType
glyphs, dimension and leader arrowheads, solid hatches, wide polylines)
by the rectangle around its triangles, and dashed everything else. A
selected dimension's text became a row of dashed boxes, one per glyph,
and its closed arrowheads small rectangles.

Selected objects are now redrawn in the selection colour: lines solid,
fills filled half see-through (so the lines and text on a highlighted
hatch stay visible), and the whole object reads as selected, text and
arrowheads included. A wipeout is always outlined, since filling it would
hide the drawing under it.

The Options dialog's Selection tab is laid out as AutoCAD's, with the
settings CADCraft can act on, each one AutoCAD's variable, kept in the
user profile:

- Selection modes: noun/verb selection (PICKFIRST), Shift to add
  (PICKADD), Object grouping (PICKSTYLE bit 1, now saved in the profile).
- Selection effect color (selectionColor, an interface preference; null
  is the theme's).
- Grips: Show grips (GRIPS) and the object selection limit for display of
  grips (GRIPOBJLIMIT, 100; 0 no limit): grips are neither drawn nor
  picked beyond it.
- Selection preview (SELECTIONPREVIEW): the object under the cursor is
  highlighted when a command is active (2) and when none is (1).
- Visual Effect Settings: the selection area effect (SELECTIONAREA,
  WINDOWAREACOLOR 150, CROSSINGAREACOLOR 100, SELECTIONAREAOPACITY 25)
  and the Selection Preview Filter (PREVIEWFILTER 3: 1 objects on locked
  layers, 2 xrefs, 4 tables, 8 multiline text, 16 hatches, 32 groups).

As in AutoCAD these are typed, not on the dialog: HIGHLIGHT (0 shows only
the grips of selected objects; not saved, so every session starts
highlighted), SELECTIONEFFECT (1 the selection colour, 0 dashed lines with
fills outlined along their real boundary) and PREVIEWEFFECT (the preview
dashed 0, thicker 1 or both 2).

Left for later, as CADCraft lacks what they control: lasso, grip tips and
menus, group grips, grips within blocks, associative hatch selection,
command and property preview, PICKDRAG, PICKAUTO and PROPOBJLIMIT.

DisplayList::fill_outlines returns the boundary of the region a filled
primitive covers as closed polylines (edges only one triangle has; the
slab filler's horizontal cuts, which need not line up between trapezoids,
are split at every x on their line and kept where an odd number cover
them), falling back to the bounding box over 600,000 vertices. The GPU
and CPU highlight paths and the drawing comparison overlay share the same
looks.

The new labels have Ukrainian rows (the required catalog); other
languages fall back to English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: EP995LAB <293597958+EP995LAB@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 06:12:48 -04:00

750 lines
32 KiB
Rust

//! Plotter configurations: PLOTTERMANAGER / -PLOTTERMANAGER, and the device and paper a page
//! setup or a plot uses.
//!
//! The built-in devices (`CADCraft PDF`, `CADCraft PNG`, `CADCraft SVG`) come first. They offer
//! the standard ISO and ANSI/ARCH paper sizes with 5 mm (ISO) or quarter-inch (inch sizes)
//! margins. User devices are `*.plotter.json` files ([`cadcraft_doc::PlotterConfig`],
//! `docs/plotters.md`) in a folder the host provides through [`set_plotter_store`]. Without
//! one, native builds use the `plotters` folder of the CADCraft settings directory and the web
//! keeps them for the session.
use std::sync::{Mutex, OnceLock};
use cadcraft_doc::{DevicePaper, Orientation, PageSetup, PlotOutput, PlotterConfig};
use cadcraft_geom::{Bounds2, Vec2};
use serde_json::{Value, json};
use super::*;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Session, Step};
/// Where plotter configuration files live. Names are file names ending in
/// [`cadcraft_doc::PLOTTER_FILE_EXT`]; the engine never passes paths.
#[derive(Clone, Copy)]
pub struct PlotterStore {
/// File names in the folder.
pub list: fn() -> std::result::Result<Vec<String>, String>,
pub read: fn(&str) -> std::result::Result<String, String>,
pub write: fn(&str, &str) -> std::result::Result<(), String>,
pub delete: fn(&str) -> std::result::Result<(), String>,
}
static STORE: OnceLock<PlotterStore> = OnceLock::new();
static MEMORY: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
/// Install the host's plotter folder (first call wins).
pub fn set_plotter_store(s: PlotterStore) {
let _ = STORE.set(s);
}
impl PlotterStore {
/// A folder kept in memory for the life of the process.
pub fn memory() -> PlotterStore {
fn lock() -> std::result::Result<std::sync::MutexGuard<'static, Vec<(String, String)>>, String> {
MEMORY.lock().map_err(|_| "the plotter folder is unavailable".to_string())
}
PlotterStore {
list: || Ok(lock()?.iter().map(|(n, _)| n.clone()).collect()),
read: |n| lock()?.iter().find(|(f, _)| f == n).map(|(_, t)| t.clone()).ok_or_else(|| format!("{n}: no such file")),
write: |n, t| {
let mut m = lock()?;
m.retain(|(f, _)| f != n);
m.push((n.to_string(), t.to_string()));
Ok(())
},
delete: |n| {
let mut m = lock()?;
let before = m.len();
m.retain(|(f, _)| f != n);
if m.len() == before { Err(format!("{n}: no such file")) } else { Ok(()) }
},
}
}
/// The `plotters` folder of the CADCraft settings directory (`CADCRAFT_CONFIG_DIR`, else
/// the platform's settings directory).
#[cfg(not(target_arch = "wasm32"))]
pub fn settings_folder() -> PlotterStore {
fn dir() -> std::result::Result<std::path::PathBuf, String> {
use std::path::PathBuf;
let get = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
let root = match std::env::var_os("CADCRAFT_CONFIG_DIR") {
Some(r) if !r.is_empty() => Some(PathBuf::from(r)),
Some(_) => None,
None => match std::env::consts::OS {
"macos" => get("HOME").map(|h| h.join("Library/Application Support/CADCraft")),
"windows" => get("APPDATA").map(|r| r.join("CADCraft")),
_ => get("XDG_CONFIG_HOME").map(|r| r.join("cadcraft")).or_else(|| get("HOME").map(|h| h.join(".config/cadcraft"))),
},
};
root.map(|r| r.join("plotters")).ok_or_else(|| "there is no settings folder for plotter configurations".to_string())
}
/// A plain file name of ours (no separators, no parent references).
fn file(n: &str) -> std::result::Result<std::path::PathBuf, String> {
let ok = n.ends_with(cadcraft_doc::PLOTTER_FILE_EXT) && !n.starts_with('.') && !n.contains(['/', '\\', ':']);
if !ok {
return Err(format!("`{n}` is not a plotter configuration file name"));
}
Ok(dir()?.join(n))
}
PlotterStore {
list: || {
let rd = match std::fs::read_dir(dir()?) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.to_string()),
};
Ok(rd
.filter_map(|e| e.ok()?.file_name().into_string().ok())
.filter(|n| n.ends_with(cadcraft_doc::PLOTTER_FILE_EXT))
.take(MAX_DEVICES)
.collect())
},
read: |n| {
let p = file(n)?;
let len = std::fs::metadata(&p).map_err(|e| format!("{n}: {e}"))?.len();
if len > cadcraft_doc::MAX_PLOTTER_FILE as u64 {
return Err(format!("{n}: the plotter configuration file is too large"));
}
std::fs::read_to_string(&p).map_err(|e| format!("{n}: {e}"))
},
write: |n, t| {
let p = file(n)?;
std::fs::create_dir_all(dir()?).map_err(|e| e.to_string())?;
let tmp = p.with_extension("json.cadcraft-tmp");
std::fs::write(&tmp, t).map_err(|e| format!("{n}: {e}"))?;
std::fs::rename(&tmp, &p).map_err(|e| format!("{n}: {e}"))
},
delete: |n| std::fs::remove_file(file(n)?).map_err(|e| format!("{n}: {e}")),
}
}
}
fn store() -> PlotterStore {
*STORE.get_or_init(|| {
#[cfg(not(target_arch = "wasm32"))]
{
PlotterStore::settings_folder()
}
#[cfg(target_arch = "wasm32")]
{
PlotterStore::memory()
}
})
}
/// Most user devices listed.
const MAX_DEVICES: usize = 1000;
/// The built-in devices: name, output, description.
const BUILTIN: [(&str, PlotOutput, &str); 3] = [
("CADCraft PDF", PlotOutput::Pdf, "Vector PDF"),
("CADCraft PNG", PlotOutput::Png, "Raster PNG image"),
("CADCraft SVG", PlotOutput::Svg, "Vector SVG image"),
];
/// A page setup's "no device" name.
const NO_DEVICE: &str = "None";
fn builtin(name: &str, output: PlotOutput, description: &str) -> PlotterConfig {
let papers = cadcraft_render::PAPER_SIZES
.iter()
.map(|p| DevicePaper { name: p.name.into(), width: p.width_mm, height: p.height_mm, margins: p.margins_mm() })
.collect();
let default_paper = cadcraft_render::paper_size("A4").map(|p| p.name.to_string()).unwrap_or_default();
PlotterConfig {
name: name.into(),
description: description.into(),
output,
resolution: 300,
orientation: Orientation::Landscape,
default_paper,
papers,
}
}
fn builtins() -> Vec<PlotterConfig> {
BUILTIN.iter().map(|(n, o, d)| builtin(n, *o, d)).collect()
}
fn is_builtin(name: &str) -> bool {
BUILTIN.iter().any(|(n, _, _)| n.eq_ignore_ascii_case(name.trim()))
}
/// The user's devices (unreadable files are skipped), sorted by name.
fn user_devices() -> Vec<PlotterConfig> {
let st = store();
let mut out: Vec<PlotterConfig> = (st.list)()
.unwrap_or_default()
.iter()
.take(MAX_DEVICES)
.filter_map(|f| PlotterConfig::from_file_text(&(st.read)(f).ok()?).ok())
.filter(|c| !is_builtin(&c.name))
.collect();
out.sort_by_key(|c| c.name.to_lowercase());
out.dedup_by(|a, b| a.name.eq_ignore_ascii_case(&b.name));
out
}
/// Every device: the built-in ones first, then the user's.
pub fn devices() -> Vec<PlotterConfig> {
let mut v = builtins();
v.extend(user_devices());
v
}
/// Device names, built-in ones first.
pub fn device_names() -> Vec<String> {
devices().into_iter().map(|c| c.name).collect()
}
/// The device called `name` (any case; a `.plotter.json` suffix is ignored).
pub fn find_device(name: &str) -> Option<PlotterConfig> {
let n = name.trim();
let n = n.strip_suffix(cadcraft_doc::PLOTTER_FILE_EXT).unwrap_or(n);
if n.is_empty() || n.eq_ignore_ascii_case(NO_DEVICE) {
return None;
}
if let Some((b, o, d)) = BUILTIN.iter().find(|(b, _, _)| b.eq_ignore_ascii_case(n)) {
return Some(builtin(b, *o, d));
}
let st = store();
let direct = cadcraft_doc::plotter_file_name(n).and_then(|f| PlotterConfig::from_file_text(&(st.read)(&f).ok()?).ok());
direct.filter(|c| c.name.eq_ignore_ascii_case(n)).or_else(|| user_devices().into_iter().find(|c| c.name.eq_ignore_ascii_case(n)))
}
/// The paper of `dev` called `name`, also by a standard size's short name (`A3`, `Letter`…).
fn device_paper<'a>(dev: &'a PlotterConfig, name: &str) -> Option<&'a DevicePaper> {
dev.paper(name).or_else(|| dev.paper(cadcraft_render::paper_size(name)?.name))
}
fn set_paper(page: &mut PageSetup, p: &DevicePaper) {
page.paper = p.name.clone();
page.width_mm = p.width;
page.height_mm = p.height;
page.margins_mm = p.margins;
}
fn unknown_device(cmd: &str, name: &str) -> EngineError {
bad(cmd, format!("unknown plotter `{name}` (devices: {})", device_names().join(", ")))
}
/// PAGESETUP's `device` and the device's papers. Sets `page.device` from `device` (`"None"`:
/// no device) and, from the page's device, the paper: `paper` when the device has it, else on a
/// device change the current paper if the device has it or the device's default paper, with the
/// paper's printable area. A device change also takes the device's default orientation unless
/// `landscape` is given. Returns whether the paper was set here (else `paper`, if any, is a
/// standard size for the caller to look up).
pub fn page_device(cmd: &str, p: &Value, page: &mut PageSetup) -> Result<bool> {
let given = match p.get("device") {
Some(v) => Some(v.as_str().ok_or_else(|| bad(cmd, "`device` must be a plotter name"))?.trim()),
None => None,
};
let dev = match given {
Some(n) if n.is_empty() || n.eq_ignore_ascii_case(NO_DEVICE) => {
page.device = NO_DEVICE.into();
return Ok(false);
}
Some(n) => Some(find_device(n).ok_or_else(|| unknown_device(cmd, n))?),
None => find_device(&page.device),
};
let Some(dev) = dev else { return Ok(false) };
let switched = !page.device.eq_ignore_ascii_case(&dev.name);
page.device = dev.name.clone();
if switched && p.get("landscape").is_none() {
page.landscape = dev.orientation == Orientation::Landscape;
}
if let Some(name) = p.get("paper") {
let name = name.as_str().ok_or_else(|| bad(cmd, "`paper` must be a paper name"))?;
return match device_paper(&dev, name) {
Some(pp) => {
set_paper(page, pp);
Ok(true)
}
None if given.is_some() => {
let known: Vec<&str> = dev.papers.iter().map(|x| x.name.as_str()).take(50).collect();
Err(bad(cmd, format!("plotter `{}` has no paper `{name}` (papers: {})", dev.name, known.join(", "))))
}
None => Ok(false),
};
}
if given.is_some() {
if let Some(pp) = device_paper(&dev, &page.paper).or_else(|| dev.default_paper()) {
set_paper(page, pp);
}
return Ok(true);
}
Ok(false)
}
/// The options PLOT / EXPORTPDF hand the plotter with `device` and the device's papers
/// resolved: `device` (else the layout's page setup device) picks the output (`output`: pdf,
/// png or svg; EXPORTPDF always writes PDF) and `resolution`; a paper of the device becomes
/// `width`/`height`/`margins` (mm) and, when not given, the device's orientation. Plots
/// without a device are unchanged.
pub(crate) fn plot_options(s: &Session, p: &Value, cmd: &str) -> Result<Value> {
let given = str_param(p, "device").map(str::trim);
let layout = match str_param(p, "layout") {
Some(n) if n.eq_ignore_ascii_case("model") => None,
Some(n) => s.doc()?.layout(n).map(|l| l.page.clone()),
None => match s.space().layout() {
Some(n) => s.doc()?.layout(n).map(|l| l.page.clone()),
None => None,
},
};
let dev = match given {
Some(n) if n.is_empty() || n.eq_ignore_ascii_case(NO_DEVICE) => None,
Some(n) => Some(find_device(n).ok_or_else(|| unknown_device(cmd, n))?),
None => layout.as_ref().and_then(|pg| find_device(&pg.device)),
};
let mut out = if p.is_object() { p.clone() } else { json!({}) };
let (Some(dev), Some(o)) = (dev, out.as_object_mut()) else { return Ok(out) };
o.remove("device");
let output = if cmd == "exportpdf" { PlotOutput::Pdf } else { dev.output };
o.insert("output".into(), json!(output.as_str()));
o.insert("resolution".into(), json!(dev.resolution));
let paper = match str_param(p, "paper") {
Some(name) => match device_paper(&dev, name) {
Some(pp) => Some(pp),
None => {
let known: Vec<&str> = dev.papers.iter().map(|x| x.name.as_str()).take(50).collect();
return Err(bad(cmd, format!("plotter `{}` has no paper `{name}` (papers: {})", dev.name, known.join(", "))));
}
},
// A layout keeps its page setup's paper; model space takes the device's default.
None if layout.is_none() => dev.default_paper(),
None => None,
};
if let Some(pp) = paper {
o.remove("paper");
o.insert("width".into(), json!(pp.width));
o.insert("height".into(), json!(pp.height));
o.insert("margins".into(), json!(pp.margins));
if p.get("landscape").is_none() && layout.is_none() {
o.insert("landscape".into(), json!(dev.orientation == Orientation::Landscape));
}
}
Ok(out)
}
/// Whether a plot draws transparency, as PLOTTRANSPARENCYOVERRIDE decides: 0 never, 2 always,
/// otherwise the plot's `plotTransparency` (`None` when it has none: the page setup decides).
pub(crate) fn plot_transparency(s: &Session, p: &Value) -> Option<bool> {
match s.settings.plot_transparency_override {
0 => Some(false),
2 => Some(true),
_ => p.get("plotTransparency").and_then(Value::as_bool),
}
}
// ---------- PLOTWINDOW ----------
fn run_plotwindow(s: &mut Session, p: &Value) -> Result<Value> {
let (a, b) = (point_req("plotwindow", p, "p1")?, point_req("plotwindow", p, "p2")?);
set_plot_window(s, a, b)
}
fn set_plot_window(s: &mut Session, a: Vec2, b: Vec2) -> Result<Value> {
let w = Bounds2::new(a, b);
if !(w.width() > 0.0 && w.height() > 0.0) {
return Err(bad("plotwindow", "the window needs a width and a height"));
}
s.plot_window = Some(w);
Ok(json!({ "window": [[w.min.x, w.min.y], [w.max.x, w.max.y]] }))
}
/// PLOTWINDOW: two corners picked in the drawing, with the window's rectangle following the cursor.
#[derive(Default)]
struct PlotWindowM {
first: Option<Vec2>,
}
impl Interactive for PlotWindowM {
fn name(&self) -> &'static str {
"PLOTWINDOW"
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.first {
None => Prompt::new("Specify first corner", Accept::POINT),
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match (i, self.first) {
(Input::Point(p), None) => {
self.first = Some(p);
Ok(Step::Continue)
}
(Input::Point(p), Some(a)) => {
set_plot_window(s, a, p)?;
Ok(Step::Done)
}
(Input::Cancel, _) => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<cadcraft_doc::EntityKind> {
let Some(a) = self.first else { return Vec::new() };
let corners = [a, Vec2::new(c.x, a.y), c, Vec2::new(a.x, c.y), a];
corners
.windows(2)
.filter_map(|w| match w {
[p, q] => Some(super::helpers::line(*p, *q)),
_ => None,
})
.collect()
}
}
// ---------- PLOTTERMANAGER ----------
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("plotwindow", "Plot Window", run_plotwindow)
.params("{p1: [x,y], p2: [x,y]} (typed: two corners): the window the Plot dialog's \"Window <\" plots, kept in the session")
.enabled(has_doc)
.noundo()
.interactive(|_| Ok(Box::new(PlotWindowM::default()))),
CommandSpec::new("plottermanager", "Plotter Manager...", run_plottermanager)
.menu(&["File", "Plotter Manager..."])
.params(
"{action?: list (default)|get|new|set|delete|load|save, name?: device, from?: device to copy (new), output?: pdf|png|svg, description?, resolution?: dpi, orientation?: portrait|landscape, defaultPaper?, paper?: {name, width, height (mm), margins?: [l,b,r,t] mm} (set: add or replace), removePaper?: name (set), text? (load), path? (save: native only; without it returns `text`)} (typed: option prompts)",
)
.enabled(always)
.noundo()
.interactive(|_| Ok(Box::new(PlotterManagerM::default()))),
CommandSpec::new("-plottermanager", "Plotter Manager", run_plottermanager)
.params("as PLOTTERMANAGER (typed: option prompts)")
.enabled(always)
.noundo()
.interactive(|_| Ok(Box::new(PlotterManagerM::default()))),
]
}
fn summary(c: &PlotterConfig) -> Value {
json!({
"name": c.name,
"description": c.description,
"output": c.output.as_str(),
"builtin": is_builtin(&c.name),
"papers": c.papers.len(),
"defaultPaper": c.default_paper,
})
}
fn device_name(cmd: &str, p: &Value) -> Result<String> {
let n = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "`name` (a plotter name) is required"))?;
Ok(n.chars().take(cadcraft_doc::MAX_PLOTTER_NAME).collect())
}
/// Write a user device to the store.
fn save_device(cmd: &str, c: &PlotterConfig) -> Result<()> {
let file = cadcraft_doc::plotter_file_name(&c.name).ok_or_else(|| bad(cmd, "the plotter has no name"))?;
(store().write)(&file, &c.to_file_text()).map_err(|e| bad(cmd, e))
}
fn mm_param(cmd: &str, v: &Value, key: &str) -> Result<Option<f64>> {
match v.get(key) {
None => Ok(None),
Some(x) => {
let f = x.as_f64().filter(|f| f.is_finite() && *f > 0.0 && *f <= cadcraft_doc::MAX_PAPER_MM);
f.map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a size in mm, up to {}", cadcraft_doc::MAX_PAPER_MM)))
}
}
}
/// Apply the editable fields of `p` to `c`.
fn edit_device(cmd: &str, c: &mut PlotterConfig, p: &Value) -> Result<()> {
if let Some(d) = str_param(p, "description") {
c.description = d.chars().take(1000).collect();
}
if let Some(o) = p.get("output") {
c.output = o.as_str().and_then(PlotOutput::parse).ok_or_else(|| bad(cmd, "`output` must be pdf, png or svg"))?;
}
if let Some(r) = p.get("resolution") {
let r = r.as_u64().filter(|r| (50..=2400).contains(r)).ok_or_else(|| bad(cmd, "`resolution` must be 50-2400 dpi"))?;
c.resolution = u32::try_from(r).unwrap_or(300);
}
if let Some(o) = p.get("orientation") {
c.orientation = match o.as_str().map(|s| s.trim().to_ascii_lowercase()).as_deref() {
Some("portrait") => Orientation::Portrait,
Some("landscape") => Orientation::Landscape,
_ => return Err(bad(cmd, "`orientation` must be portrait or landscape")),
};
}
if let Some(rm) = str_param(p, "removePaper") {
let before = c.papers.len();
let target = c.paper(rm).map(|x| x.name.clone());
c.papers.retain(|x| Some(&x.name) != target.as_ref());
if c.papers.len() == before {
return Err(bad(cmd, format!("`{}` has no paper `{rm}`", c.name)));
}
}
if let Some(pv) = p.get("paper") {
let name = pv.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty());
let name = name.ok_or_else(|| bad(cmd, "`paper.name` is required"))?;
let existing = c.paper(name).cloned();
let (w, h) = match (mm_param(cmd, pv, "width")?, mm_param(cmd, pv, "height")?, &existing) {
(Some(w), Some(h), _) => (w, h),
(None, None, Some(e)) => (e.width, e.height),
_ => return Err(bad(cmd, "`paper.width` and `paper.height` (mm) are both required")),
};
let margins = match pv.get("margins") {
None => existing.as_ref().map(|e| e.margins).unwrap_or([0.0; 4]),
Some(m) => {
let a: Vec<f64> = m.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
match a[..] {
[l, b, r, t] if a.iter().all(|v| v.is_finite() && (0.0..=1000.0).contains(v)) && l + r < w.min(h) && b + t < w.max(h) => {
[l, b, r, t]
}
_ => return Err(bad(cmd, "`paper.margins` must be [left, bottom, right, top] in mm, leaving a printable area")),
}
}
};
let new = DevicePaper { name: name.into(), width: w, height: h, margins };
match existing.and_then(|e| c.papers.iter().position(|x| x.name == e.name)) {
Some(i) => {
if let Some(slot) = c.papers.get_mut(i) {
*slot = new;
}
}
None if c.papers.len() >= cadcraft_doc::MAX_DEVICE_PAPERS => return Err(bad(cmd, "the plotter has too many papers")),
None => c.papers.push(new),
}
}
if let Some(d) = str_param(p, "defaultPaper") {
let n = c.paper(d).map(|x| x.name.clone()).ok_or_else(|| bad(cmd, format!("`{}` has no paper `{d}`", c.name)))?;
c.default_paper = n;
}
Ok(())
}
fn run_plottermanager(_s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plottermanager";
let action = str_param(p, "action").unwrap_or("list").trim().to_ascii_lowercase();
match action.as_str() {
"list" => Ok(json!({ "devices": devices().iter().map(summary).collect::<Vec<_>>() })),
"get" => {
let n = device_name(cmd, p)?;
let c = find_device(&n).ok_or_else(|| unknown_device(cmd, &n))?;
let mut v = serde_json::to_value(&c).unwrap_or(Value::Null);
if let Some(o) = v.as_object_mut() {
o.insert("builtin".into(), json!(is_builtin(&c.name)));
}
Ok(v)
}
"new" => {
let name = device_name(cmd, p)?;
if find_device(&name).is_some() {
return Err(bad(cmd, format!("plotter `{name}` already exists")));
}
let file = cadcraft_doc::plotter_file_name(&name).ok_or_else(|| bad(cmd, "`name` is empty"))?;
if (store().list)().unwrap_or_default().iter().any(|f| f.eq_ignore_ascii_case(&file)) {
return Err(bad(cmd, format!("a plotter file `{file}` already exists")));
}
let mut c = match str_param(p, "from") {
Some(f) => find_device(f).ok_or_else(|| unknown_device(cmd, f))?,
None => {
// A copy of the built-in device for the output: the standard papers.
let output = match p.get("output") {
Some(o) => o.as_str().and_then(PlotOutput::parse).ok_or_else(|| bad(cmd, "`output` must be pdf, png or svg"))?,
None => PlotOutput::Pdf,
};
let (b, o, _) = BUILTIN.iter().find(|(_, o, _)| *o == output).copied().unwrap_or(("CADCraft PDF", PlotOutput::Pdf, ""));
builtin(b, o, "")
}
};
c.name = name;
c.description = String::new();
edit_device(cmd, &mut c, p)?;
let c = c.sanitized();
save_device(cmd, &c)?;
Ok(summary(&c))
}
"set" => {
let name = device_name(cmd, p)?;
if is_builtin(&name) {
return Err(bad(cmd, format!("`{name}` is built in: make a copy with action new, from `{name}`")));
}
let mut c = find_device(&name).ok_or_else(|| unknown_device(cmd, &name))?;
edit_device(cmd, &mut c, p)?;
if c.papers.is_empty() {
return Err(bad(cmd, "a plotter needs at least one paper"));
}
let c = c.sanitized();
save_device(cmd, &c)?;
Ok(serde_json::to_value(&c).unwrap_or(Value::Null))
}
"delete" => {
let name = device_name(cmd, p)?;
if is_builtin(&name) {
return Err(bad(cmd, format!("`{name}` is built in and can't be deleted")));
}
let c = find_device(&name).ok_or_else(|| unknown_device(cmd, &name))?;
let file = cadcraft_doc::plotter_file_name(&c.name).ok_or_else(|| bad(cmd, "the plotter has no name"))?;
(store().delete)(&file).map_err(|e| bad(cmd, e))?;
Ok(json!({ "deleted": c.name }))
}
"load" => {
let text = str_param(p, "text").ok_or_else(|| bad(cmd, "`text` (the file's contents) is required"))?;
let mut c = PlotterConfig::from_file_text(text).map_err(|e| bad(cmd, e))?;
if let Some(n) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
c.name = n.chars().take(cadcraft_doc::MAX_PLOTTER_NAME).collect();
}
if is_builtin(&c.name) {
return Err(bad(cmd, format!("`{}` is a built-in plotter's name: pass another `name`", c.name)));
}
if c.papers.is_empty() {
return Err(bad(cmd, "the plotter configuration has no papers"));
}
save_device(cmd, &c)?;
Ok(summary(&c))
}
"save" => {
let name = device_name(cmd, p)?;
let text = find_device(&name).ok_or_else(|| unknown_device(cmd, &name))?.to_file_text();
match str_param(p, "path") {
None => Ok(json!({ "name": name, "text": text })),
Some(_path) => {
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::write(_path, &text).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
Ok(json!({ "name": name, "path": _path }))
}
#[cfg(target_arch = "wasm32")]
Err(bad(cmd, "paths are not available on the web; omit `path` to get the file as `text`"))
}
}
}
other => Err(bad(cmd, format!("unknown action `{other}` (list, get, new, set, delete, load, save)"))),
}
}
// ---------- -PLOTTERMANAGER prompts ----------
const OPTIONS: &[&str] = &["?", "New", "Delete", "Paper"];
const KW: Accept = Accept { point: false, number: false, text: false, select: false, enter: true };
#[derive(Clone, Debug, Default)]
enum Stage {
#[default]
Option,
NewName,
NewOutput(String),
Delete,
PaperDevice,
PaperName(String),
PaperSize(String, String),
}
/// PLOTTERMANAGER typed: list the devices (`?`), create one (`New`: name, output), delete one
/// or add a paper size to one (`Paper`: device, paper name, `width,height` in mm).
#[derive(Default)]
pub struct PlotterManagerM {
stage: Stage,
}
fn typed(i: &Input) -> Option<String> {
match i {
Input::Text(t) | Input::Keyword(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()),
_ => None,
}
}
/// A command refusal as a re-prompt message.
fn retry(e: EngineError) -> EngineError {
match e {
EngineError::BadParams { msg, .. } => EngineError::Other(msg),
other => other,
}
}
impl Interactive for PlotterManagerM {
fn name(&self) -> &'static str {
"PLOTTERMANAGER"
}
fn prompt(&self, _s: &Session) -> Prompt {
match &self.stage {
Stage::Option => Prompt::new("Enter a plotter option", KW).kw(OPTIONS).default("?"),
Stage::NewName => Prompt::new("Enter a name for the new plotter", Accept::TEXT),
Stage::NewOutput(_) => Prompt::new("Enter the output", KW).kw(&["Pdf", "PNg", "Svg"]).default("Pdf"),
Stage::Delete => Prompt::new("Enter the plotter to delete", Accept::TEXT),
Stage::PaperDevice => Prompt::new("Enter the plotter to add a paper size to", Accept::TEXT),
Stage::PaperName(_) => Prompt::new("Enter the paper name", Accept::TEXT),
Stage::PaperSize(..) => Prompt::new("Enter the paper width,height in mm", Accept::TEXT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if i == Input::Cancel {
return Ok(Step::Cancel);
}
let t = typed(&i);
let need = |t: Option<String>| t.ok_or_else(|| EngineError::Other("Requires a name.".into()));
match self.stage.clone() {
Stage::Option => {
let k = match i {
Input::Enter => "?".to_string(),
Input::Keyword(k) => k,
_ => return Err(EngineError::Other("Invalid option keyword.".into())),
};
self.stage = match k.as_str() {
"New" => Stage::NewName,
"Delete" => Stage::Delete,
"Paper" => Stage::PaperDevice,
_ => {
for c in devices() {
let kind = if is_builtin(&c.name) { "built-in" } else { "user" };
s.echo(format!("{} ({}, {kind}, {} paper sizes)", c.name, c.output.as_str().to_uppercase(), c.papers.len()));
}
return Ok(Step::Done);
}
};
Ok(Step::Continue)
}
Stage::NewName => {
let n = need(t)?;
if find_device(&n).is_some() {
return Err(EngineError::Other(format!("Plotter \"{n}\" already exists.")));
}
self.stage = Stage::NewOutput(n);
Ok(Step::Continue)
}
Stage::NewOutput(name) => {
let output = t.unwrap_or_else(|| "pdf".into());
run_plottermanager(s, &json!({ "action": "new", "name": name, "output": output })).map_err(retry)?;
s.echo(format!("Plotter \"{name}\" created."));
Ok(Step::Done)
}
Stage::Delete => {
let n = need(t)?;
let r = run_plottermanager(s, &json!({ "action": "delete", "name": n })).map_err(retry)?;
s.echo(format!("Plotter \"{}\" deleted.", r.get("deleted").and_then(Value::as_str).unwrap_or_default()));
Ok(Step::Done)
}
Stage::PaperDevice => {
let n = need(t)?;
let c = find_device(&n).ok_or_else(|| EngineError::Other(format!("Plotter \"{n}\" not found.")))?;
if is_builtin(&c.name) {
return Err(EngineError::Other(format!("\"{}\" is built in; create a new plotter first.", c.name)));
}
self.stage = Stage::PaperName(c.name);
Ok(Step::Continue)
}
Stage::PaperName(dev) => {
self.stage = Stage::PaperSize(dev, need(t)?);
Ok(Step::Continue)
}
Stage::PaperSize(dev, paper) => {
let text = need(t)?;
let nums: Vec<f64> = text.split(',').map(|x| crate::units::parse_distance(x.trim())).collect::<Option<Vec<_>>>().unwrap_or_default();
let [w, h] = nums[..] else {
return Err(EngineError::Other("Enter width,height in mm.".into()));
};
let p = json!({ "action": "set", "name": dev, "paper": { "name": paper, "width": w, "height": h } });
run_plottermanager(s, &p).map_err(retry)?;
s.echo(format!("Paper \"{paper}\" added to \"{dev}\"."));
Ok(Step::Done)
}
}
}
}