IMPORT, EXPORT, Plot Preview and Batch Publish; ATTACH, SHARE, 3DORBIT say not available yet (part of #9) (#186)

Squash of PR #186 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 04:26:39 -04:00
1 parent 50ffb767c7
commit 699dd1aeca
8 files changed
+798 -5

No files matched your search

+612
View File
@@ -0,0 +1,612 @@
//! Moving drawings in and out: EXPORT (DXF, DWG, SVG, PNG or PDF), IMPORT (the objects of another
//! DXF/DWG file), PREVIEW (a picture of the plot) and PUBLISH (plot several sheets at once).
//! ATTACH, SHARE and 3DORBIT are registered so the tool bar and command line say they are not
//! available yet.
//!
//! Files go through the host's io hooks ([`super::file::io`]) like OPEN and SAVEAS, so the engine
//! stays I/O-agnostic; the JSON forms never open dialogs (the UI asks for files when these
//! commands come from a menu, the tool bar or the command line).
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use cadcraft_color::Rgb;
use cadcraft_doc::{Drawing, Entity, EntityKind, PageSetup, Space};
use cadcraft_geom::{Bounds2, Mat3, Vec2};
use cadcraft_render::paper::{self, Sheet};
use cadcraft_render::raster::{RasterOptions, View};
use serde_json::{Value, json};
use super::file::{base64_decode, base64_encode, io};
use super::*;
use crate::{EngineError, Result, Session};
/// Formats EXPORT writes (the io layer picks the writer from the extension).
pub const EXPORT_FORMATS: &[&str] = &["dxf", "dwg", "svg", "png", "pdf"];
/// Default and largest plot preview width, in pixels.
const PREVIEW_PX: u64 = 1200;
const MAX_PREVIEW_PX: u64 = 4096;
/// Most sheets one PUBLISH plots.
const MAX_SHEETS: usize = 256;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("import", "Import...", run_import)
.menu(&["File", "Import..."])
.alias(&["imp"])
.params("{path | data: base64, name?: \"x.dwg\"} (DXF or DWG), at?: [x,y] (default 0,0), scale?: (default 1) → {imported, handles, layers, styles, blocks}"),
CommandSpec::new("export", "Export...", run_export)
.menu(&["File", "Export..."])
.alias(&["exp"])
.params("{path? (else returns base64 `data`), format?: dxf|dwg|svg|png|pdf (default: the path's extension)} → {format, bytes, path? | data}")
.noundo(),
CommandSpec::new("preview", "Plot Preview", run_preview)
.menu(&["File", "Plot Preview"])
.alias(&["pre"])
.params("{layout?: current|\"Model\", paper?, landscape?, fit?, scale?, lineweights?, width?: pixels (default 1200)} → {data: base64 PNG, width, height, layout, paper}")
.noundo(),
CommandSpec::new("publish", "Batch Publish...", run_publish)
.menu(&["File", "Batch Publish..."])
.params("{path?: \"NAME.pdf\" (writes NAME-<sheet>.pdf per sheet; else returns base64 `data` per sheet), layouts?: [name | \"Model\"] (default: Model if it has objects, then every layout in tab order)} → {sheets: [{layout, bytes, path? | data}]}")
.noundo(),
CommandSpec::new("attach", "Attach", |_, _| {
Err(EngineError::Other("ATTACH is not available yet: raster images and PDF/DWF/DGN underlays can't be shown yet".into()))
})
.params("not available yet")
.noundo(),
CommandSpec::new("share", "Share", |_, _| {
Err(EngineError::Other("SHARE is not available yet: there is no online service to share through; EXPORT or PUBLISH the drawing and send the file".into()))
})
.params("not available yet")
.noundo(),
CommandSpec::new("3dorbit", "Orbit", |_, _| Err(EngineError::Other("3DORBIT is not available yet: CADCraft draws in 2D (plan view) only".into())))
.alias(&["3do", "orbit"])
.params("not available yet")
.noundo(),
]
}
fn extension(path: &str) -> String {
std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
}
/// Write `bytes` to `path` through a temporary file, so a failed write keeps the old file.
fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
{
let tmp = format!("{path}.cadcraft-tmp");
std::fs::write(&tmp, bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
Ok(())
}
#[cfg(target_arch = "wasm32")]
{
let _ = (path, bytes);
Err(bad(cmd, "paths are not available on the web; omit `path` to get the file as base64 `data`"))
}
}
// ---------- EXPORT ----------
fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
let hooks = io().ok_or_else(|| bad("export", "file formats are not available in this build"))?;
let path = str_param(p, "path");
let format = match str_param(p, "format") {
Some(f) => f.trim().trim_start_matches('.').to_ascii_lowercase(),
None => path.map(extension).unwrap_or_default(),
};
if !EXPORT_FORMATS.contains(&format.as_str()) {
return Err(bad("export", "`format` must be dxf, dwg, svg, png or pdf (or give a `path` with one of those extensions)"));
}
let bytes = (hooks.write)(s.doc()?, &format!("export.{format}")).map_err(|e| bad("export", e))?;
match path {
Some(path) => {
write_file("export", path, &bytes)?;
Ok(json!({ "path": path, "format": format, "bytes": bytes.len(), "message": format!("Exported to {path}") }))
}
None => Ok(json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() })),
}
}
// ---------- IMPORT ----------
fn run_import(s: &mut Session, p: &Value) -> Result<Value> {
let hooks = io().ok_or_else(|| bad("import", "file formats are not available in this build"))?;
let at = match p.get("at") {
Some(v) => point_value(v).ok_or_else(|| bad("import", "`at` must be a point [x, y]"))?,
None => Vec2::ZERO,
};
let scale = match p.get("scale") {
Some(v) => v
.as_f64()
.filter(|k| k.is_finite() && (1e-9..=1e9).contains(&k.abs()))
.ok_or_else(|| bad("import", "`scale` must be a non-zero number"))?,
None => 1.0,
};
let (bytes, name) = if let Some(path) = str_param(p, "path") {
(read_file(path)?, path.to_string())
} else {
let data = str_param(p, "data").ok_or_else(|| bad("import", "`path` or `data` (base64) is required"))?;
(base64_decode(data).ok_or_else(|| bad("import", "invalid base64"))?, str_param(p, "name").unwrap_or("import.dxf").to_string())
};
let src = (hooks.read)(&bytes, &name).map_err(|e| bad("import", e))?;
let file = std::path::Path::new(&name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
import_drawing(s, &src, at, scale, &file)
}
/// Copy `src`'s model space into the current space (scaled about the origin, then moved to
/// `at`), with the layers, linetypes, styles and blocks it needs.
fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &str) -> Result<Value> {
let m = Mat3::scale(scale, scale).then(Mat3::translate(at));
let moved = at != Vec2::ZERO || scale != 1.0;
let space = s.space();
let d = s.doc_mut()?;
let (layers, styles) = merge_tables(d, src);
let renames = block_names(d, src);
for (from, to) in &renames {
let Some(b) = src.blocks.get(from) else { continue };
let mut nb = (**b).clone();
nb.name = to.clone();
nb.entities = Default::default();
for e in b.entities.iter() {
let e = adopt(d, e, &renames);
nb.entities.push(e);
}
d.blocks.insert(to.clone(), Arc::new(nb));
}
let mut handles = Vec::new();
for e in src.model.iter() {
let mut e = adopt(d, e, &renames);
if moved {
e.kind.transform(&m);
}
d.ensure_layer(&e.common.layer);
handles.push(e.handle);
if let Some(st) = d.space_mut(&space) {
st.push(e);
}
}
s.set_selection(handles.clone());
Ok(json!({
"imported": handles.len(),
"handles": handles.iter().map(|h| h.hex()).collect::<Vec<_>>(),
"layers": layers,
"styles": styles,
"blocks": renames.len(),
"message": format!("{} objects imported from {file}", handles.len()),
}))
}
fn read_file(_path: &str) -> Result<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::read(_path).map_err(|e| bad("import", format!("{_path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
Err(bad("import", "paths are not available on the web; pass `data`"))
}
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
/// exists keeps this drawing's definition). Returns how many layers and how many other records were added.
fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
let mut n = 0;
for t in theirs {
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
ours.push(t.clone());
n += 1;
}
}
n
}
let layers = merge(&mut d.layers, &src.layers, |l| &l.name);
let others = merge(&mut d.linetypes, &src.linetypes, |l| &l.name)
+ merge(&mut d.text_styles, &src.text_styles, |t| &t.name)
+ merge(&mut d.dim_styles, &src.dim_styles, |t| &t.name)
+ merge(&mut d.mleader_styles, &src.mleader_styles, |t| &t.name)
+ merge(&mut d.table_styles, &src.table_styles, |t| &t.name);
(layers, others)
}
/// Which source blocks to copy, and under which name: new names keep theirs, generated
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
/// already has keeps this drawing's definition (not copied).
fn block_names(d: &Drawing, src: &Drawing) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
for (name, b) in &src.blocks {
let upper = name.to_ascii_uppercase();
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
continue;
}
let anonymous = b.anonymous || name.starts_with('*');
let to = if !taken.contains(&upper) {
name.clone()
} else if anonymous {
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
Some(n) => n,
None => continue,
}
} else {
continue;
};
taken.insert(to.to_ascii_uppercase());
out.insert(name.clone(), to);
}
out
}
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
/// links to other source objects (associative dimension points) dropped.
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
let mut e = e.clone();
e.handle = d.new_handle();
match &mut e.kind {
EntityKind::Insert(i) => {
if let Some(n) = renames.get(&i.block) {
i.block = n.clone();
}
}
EntityKind::Dimension(dm) => {
dm.assoc.clear();
if let Some(n) = dm.block.as_ref().and_then(|b| renames.get(b)) {
dm.block = Some(n.clone());
}
}
_ => {}
}
e
}
// ---------- PREVIEW ----------
/// The space a plot command works on: `layout` ("Model" or a layout name) or the current one.
fn plot_space(cmd: &str, s: &Session, p: &Value) -> Result<Space> {
Ok(match str_param(p, "layout") {
Some(n) if n.eq_ignore_ascii_case("model") => Space::Model,
Some(n) => Space::Paper(s.doc()?.layout(n).map(|l| l.name.clone()).ok_or_else(|| bad(cmd, format!("no layout `{n}`")))?),
None => s.space(),
})
}
/// Paper units per drawing unit, as the PDF plotter computes it.
fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option<f64>) -> f64 {
if let Some(s) = scale
&& !fit
{
return s;
}
if !fit || b.is_empty() {
return 1.0;
}
let s = (sheet.printable.width() / b.width().max(1e-12)).min(sheet.printable.height() / b.height().max(1e-12));
if s.is_finite() && s > 0.0 { s } else { 1.0 }
}
fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
let space = plot_space("preview", s, p)?;
let d = s.doc()?;
let mut page = match &space {
Space::Paper(n) => d.layout(n).map(|l| l.page.clone()).unwrap_or_default(),
Space::Model => {
// Model space plots on A4 (metric) or ANSI A, like the PDF plotter.
let mut page = PageSetup::default();
if paper::paper_unit_mm(d) == 1.0
&& let Some(a4) = paper::paper_size("A4")
{
page.paper = a4.name.into();
page.width_mm = a4.width_mm;
page.height_mm = a4.height_mm;
}
page
}
};
if let Some(name) = str_param(p, "paper") {
let ps = paper::paper_size(name).ok_or_else(|| bad("preview", format!("unknown paper size `{name}`")))?;
page.paper = ps.name.into();
page.width_mm = ps.width_mm;
page.height_mm = ps.height_mm;
}
if let Some(l) = p.get("landscape").and_then(Value::as_bool) {
page.landscape = l;
}
let sheet = Sheet::from_page(&page, paper::paper_unit_mm(d));
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(match space {
Space::Paper(_) => page.lineweights,
Space::Model => true,
});
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model));
let scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
let width = p.get("width").and_then(Value::as_u64).unwrap_or(PREVIEW_PX).clamp(16, MAX_PREVIEW_PX);
let height = ((width as f64) * sheet.size.y / sheet.size.x).round();
let height = if height.is_finite() { (height as u64).clamp(16, MAX_PREVIEW_PX) } else { width };
let px_per_paper_unit = width as f64 / sheet.size.x;
// Curves are flattened to about half a pixel.
let est = plot_scale(&d.extents(&space), &sheet, fit, scale);
let tol = 0.5 / (px_per_paper_unit * est);
let ropts = cadcraft_render::Options {
tolerance: if tol.is_finite() && tol > 0.0 { tol } else { 0.01 },
min_dash: 0.0,
text: true,
fill: true,
lineweights,
..Default::default()
};
let list = cadcraft_render::build_plot(d, &space, &ropts);
let b = list.bounds;
// Paper point q shows drawing point (q - to) / k + from.
let (from, k, to) = if fit || space == Space::Model {
(if b.is_empty() { Vec2::ZERO } else { b.center() }, plot_scale(&b, &sheet, fit, scale), sheet.printable.center())
} else {
(Vec2::ZERO, 1.0, Vec2::ZERO)
};
let world = Bounds2::new((Vec2::ZERO - to) / k + from, (sheet.size - to) / k + from);
if ![world.min.x, world.min.y, world.max.x, world.max.y].iter().all(|v| v.is_finite()) {
return Err(bad("preview", "the drawing is too large to preview at this scale"));
}
let (w, h) = (u32::try_from(width).unwrap_or(1), u32::try_from(height).unwrap_or(1));
let view = View::fit(&world, w, h, 0.0);
let px_per_mm = (px_per_paper_unit / sheet.unit_mm) as f32;
let ro = RasterOptions {
background: Rgb(255, 255, 255),
hairline: 1.0,
px_per_mm: if lineweights && px_per_mm.is_finite() { px_per_mm } else { 0.0 },
antialias: true,
};
let png = cadcraft_render::raster::render_png(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
let layout = match &space {
Space::Model => "Model".to_string(),
Space::Paper(n) => n.clone(),
};
Ok(json!({ "data": base64_encode(&png), "width": w, "height": h, "layout": layout, "paper": page.paper, "landscape": page.landscape }))
}
// ---------- PUBLISH ----------
/// A layout name made safe for a file name.
fn file_part(name: &str) -> String {
name.chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.') { c } else { '_' }).collect()
}
fn run_publish(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.doc()?;
let sheets: Vec<String> = match p.get("layouts") {
Some(v) => {
let a = v.as_array().ok_or_else(|| bad("publish", "`layouts` must be a list of layout names"))?;
a.iter()
.map(|n| {
let n = n.as_str().ok_or_else(|| bad("publish", "`layouts` must be a list of layout names"))?;
if n.eq_ignore_ascii_case("model") {
return Ok("Model".to_string());
}
d.layout(n).map(|l| l.name.clone()).ok_or_else(|| bad("publish", format!("no layout `{n}`")))
})
.collect::<Result<_>>()?
}
None => {
let mut layouts: Vec<_> = d.layouts.iter().collect();
layouts.sort_by_key(|l| l.tab_order);
let model = (!d.model.is_empty()).then(|| "Model".to_string());
model.into_iter().chain(layouts.iter().map(|l| l.name.clone())).collect()
}
};
if sheets.is_empty() {
return Err(bad("publish", "there are no sheets to publish"));
}
if sheets.len() > MAX_SHEETS {
return Err(bad("publish", format!("at most {MAX_SHEETS} sheets can be published at once")));
}
let base = str_param(p, "path").map(|path| {
let pb = std::path::Path::new(path);
let stem = pb.file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "Drawing".into());
(pb.parent().map(std::path::Path::to_path_buf).unwrap_or_default(), stem)
});
let mut out = Vec::new();
for name in &sheets {
let mut q = json!({ "layout": name });
if let Some((dir, stem)) = &base {
q["path"] = json!(dir.join(format!("{stem}-{}.pdf", file_part(name))).to_string_lossy());
}
let mut r = s.execute("plot", &q).map_err(|e| bad("publish", format!("{name}: {e}")))?;
if let Some(o) = r.as_object_mut() {
o.insert("layout".into(), json!(name));
}
out.push(r);
}
let message = match &base {
Some(_) => format!("Published {} sheet(s): {}", out.len(), out.iter().filter_map(|r| r["path"].as_str()).collect::<Vec<_>>().join(", ")),
None => format!("Published {} sheet(s)", out.len()),
};
Ok(json!({ "sheets": out, "message": message }))
}
#[cfg(test)]
mod tests {
use cadcraft_doc::{Block, Circle, Common, Handle, Insert, Layer, Line};
use cadcraft_geom::Vec3;
use super::super::file::{IoHooks, set_io};
use super::*;
fn fake_plot(_: &Drawing, space: &Space, opts: &Value) -> std::result::Result<Vec<u8>, String> {
// Same output as the layout tests' stand-in: whichever test installs first wins.
Ok(format!("%PDF-1.4 fake {space:?} {opts}").into_bytes())
}
/// Install stand-in io hooks; true when they are the ones installed (the hooks are
/// process-wide, and other tests install writers that always fail).
fn our_hooks() -> bool {
set_io(IoHooks {
read: |_, _| Err("no reader in tests".into()),
write: |d, name| Ok(if name == "probe.test" { b"exchange".to_vec() } else { format!("{name}:{}", d.model.len()).into_bytes() }),
plot: Some(fake_plot),
});
io().is_some_and(|h| (h.write)(&Drawing::new_imperial(), "probe.test").is_ok_and(|b| b == b"exchange"))
}
fn session() -> Session {
let mut s = Session::new();
s.cmdline("line 0,0 100,0").unwrap();
s.cmdline("").unwrap();
s.cmdline("circle 50,25 20").unwrap();
s
}
fn temp(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("cadcraft-exchange-{}-{name}", std::process::id()))
}
#[test]
fn export_writes_the_chosen_format_and_is_not_save_as() {
let ours = our_hooks();
let mut s = session();
assert!(s.execute("export", &json!({})).is_err(), "a format or a path is required");
assert!(s.execute("export", &json!({ "path": "x.txt" })).is_err());
assert!(s.execute("export", &json!({ "format": "exe" })).is_err());
if !ours {
return;
}
let r = s.execute("export", &json!({ "format": ".SVG" })).unwrap();
assert_eq!(r["format"], "svg");
assert_eq!(base64_decode(r["data"].as_str().unwrap()).unwrap(), b"export.svg:2");
let path = temp("out.png");
let ps = path.to_string_lossy().to_string();
let r = s.execute("export", &json!({ "path": ps })).unwrap();
assert_eq!(r["format"], "png");
assert_eq!(std::fs::read(&path).unwrap(), b"export.png:2");
let _ = std::fs::remove_file(&path);
let st = s.state().unwrap();
assert!(st.path.is_none() && st.title == "Drawing1", "the drawing keeps its name");
}
fn line(a: (f64, f64), b: (f64, f64)) -> EntityKind {
EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) })
}
fn block(name: &str, r: f64) -> Arc<Block> {
let mut b = Block::new(name);
b.entities.push(Entity::new(Handle(0x100), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: r })));
Arc::new(b)
}
fn insert(name: &str) -> EntityKind {
EntityKind::Insert(Insert {
block: name.into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
})
}
#[test]
fn import_brings_objects_with_their_layers_and_blocks() {
let mut src = Drawing::new_imperial();
src.layers.push(Layer::new("WALLS"));
src.blocks.insert("PART".into(), block("PART", 1.0));
src.blocks.insert("NEW".into(), block("NEW", 2.0));
src.blocks.insert("*U1".into(), block("*U1", 3.0));
let walls = Common { layer: "WALLS".into(), ..Common::default() };
src.add(&Space::Model, walls, line((0.0, 0.0), (10.0, 0.0))).unwrap();
for b in ["PART", "NEW", "*U1"] {
src.add(&Space::Model, Common::default(), insert(b)).unwrap();
}
let mut s = session();
s.doc_mut().unwrap().blocks.insert("PART".into(), block("PART", 5.0));
s.doc_mut().unwrap().blocks.insert("*U1".into(), block("*U1", 6.0));
let r = import_drawing(&mut s, &src, Vec2::new(100.0, 0.0), 2.0, "parts.dxf").unwrap();
assert_eq!(r["imported"], 4);
assert_eq!(r["message"], "4 objects imported from parts.dxf");
let d = s.doc().unwrap();
assert_eq!(d.model.len(), 6);
assert!(d.layer("WALLS").is_some());
let mut handles: Vec<Handle> = d.model.handles();
handles.sort();
handles.dedup();
assert_eq!(handles.len(), 6, "imported objects get handles of their own");
let imported: Vec<&Entity> = d.model.iter().skip(2).map(|e| &**e).collect();
match &imported[0].kind {
EntityKind::Line(l) => {
assert_eq!((l.a.x, l.b.x), (100.0, 120.0), "scaled about the origin, then moved");
assert_eq!(imported[0].common.layer, "WALLS");
}
k => panic!("expected the line, got {k:?}"),
}
let names: Vec<String> =
imported.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }).collect();
assert_eq!(names, ["PART", "NEW", "*U2"], "same-named blocks keep ours; generated ones get a new name");
let radius = |n: &str| match d.block(n).and_then(|b| b.entities.iter().next().map(|e| e.kind.clone())) {
Some(EntityKind::Circle(c)) => c.radius,
_ => 0.0,
};
assert_eq!((radius("PART"), radius("NEW"), radius("*U1"), radius("*U2")), (5.0, 2.0, 6.0, 3.0));
assert_eq!(s.selection().len(), 4);
s.execute("undo", &json!({})).ok();
// The JSON form checks its parameters before reading anything.
our_hooks();
for p in [json!({}), json!({ "data": "%%" }), json!({ "data": "", "scale": 0 }), json!({ "data": "", "at": "x" })] {
assert!(s.execute("import", &p).is_err(), "{p}");
}
}
#[test]
fn preview_draws_the_sheet_as_it_plots() {
let mut s = session();
let r = s.execute("preview", &json!({ "width": 400 })).unwrap();
assert_eq!((r["layout"].as_str(), r["width"].as_u64()), (Some("Model"), Some(400)));
// Model space plots on ANSI A landscape in an imperial drawing.
assert_eq!(r["height"], 309);
let png = base64_decode(r["data"].as_str().unwrap()).unwrap();
assert!(png.starts_with(b"\x89PNG"));
assert_eq!(png.get(16..24), Some(&[0, 0, 1, 144, 0, 0, 1, 53][..]), "IHDR is 400 x 309");
let r = s.execute("preview", &json!({ "layout": "layout1", "paper": "A4", "landscape": true, "width": 600 })).unwrap();
assert_eq!((r["layout"].as_str(), r["width"].as_u64(), r["height"].as_u64()), (Some("Layout1"), Some(600), Some(424)));
assert_eq!(r["paper"], "ISO A4 (210.00 x 297.00 MM)");
assert!(s.execute("preview", &json!({ "layout": "Nope" })).is_err());
assert!(s.execute("preview", &json!({ "paper": "Nope" })).is_err());
for p in [json!({ "scale": -1, "fit": false }), json!({ "scale": 1e-300, "fit": false }), json!({ "width": -5 }), json!({ "width": 1e30 })] {
let _ = s.execute("preview", &p);
}
}
#[test]
fn publish_plots_every_sheet() {
our_hooks();
let mut s = session();
if io().and_then(|h| h.plot).is_none() {
return;
}
let r = s.execute("publish", &json!({})).unwrap();
let sheets: Vec<&str> = r["sheets"].as_array().unwrap().iter().filter_map(|x| x["layout"].as_str()).collect();
assert_eq!(sheets, ["Model", "Layout1", "Layout2"]);
let base = temp("set.pdf");
let r = s.execute("publish", &json!({ "path": base.to_string_lossy(), "layouts": ["layout2", "MODEL"] })).unwrap();
for (sheet, name) in r["sheets"].as_array().unwrap().iter().zip(["Layout2", "Model"]) {
let path = temp(&format!("set-{name}.pdf"));
assert_eq!(sheet["path"].as_str(), Some(path.to_string_lossy().as_ref()));
let text = String::from_utf8(std::fs::read(&path).unwrap()).unwrap();
assert!(text.starts_with("%PDF") && text.contains(if name == "Model" { "Model" } else { "Paper(\"Layout2\")" }));
let _ = std::fs::remove_file(&path);
}
assert!(r["message"].as_str().unwrap().starts_with("Published 2 sheet(s)"));
for p in [json!({ "layouts": [] }), json!({ "layouts": ["Nope"] }), json!({ "layouts": "Layout1" }), json!({ "layouts": [1] })] {
assert!(s.execute("publish", &p).is_err(), "{p}");
}
}
#[test]
fn attach_share_and_orbit_say_they_are_not_available_yet() {
let mut s = session();
for c in ["attach", "share", "3dorbit", "orbit"] {
let e = s.execute(&resolve_alias(c), &Value::Null).unwrap_err().to_string();
assert!(e.contains("not available yet"), "{c}: {e}");
}
}
}
+2
View File
@@ -10,6 +10,7 @@ pub mod constraints;
mod draw;
mod draw2;
mod edit;
mod exchange;
pub mod file;
mod gripcmds;
pub(crate) mod groups;
@@ -173,6 +174,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(gripcmds::specs());
v.extend(layer::specs());
v.extend(layout::specs());
v.extend(exchange::specs());
v.extend(props::specs());
v.extend(inquiry::specs());
v.extend(settings::specs());
+1
View File
@@ -21,6 +21,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
"dsettings" => dsettings(app, ctx, &mut open),
"units" => units(app, ctx, &mut open),
"about" => about(ctx, &mut open),
"preview" => crate::exchange::dialog(app, ctx, &mut open),
"commands" => commands(app, ctx, &mut open),
"blocks" => blocks(app, ctx, &mut open),
"stylesmanager" => crate::plotstyles::dialog(app, ctx, &mut open),
+172
View File
@@ -0,0 +1,172 @@
//! EXPORT, IMPORT, PUBLISH and PREVIEW from a menu, the tool bar or the command line: the first
//! three ask for a file, PREVIEW shows the plot in a window. Calls with parameters (scripts, the
//! control channel, MCP) never get here and never open a dialog: they run the engine commands.
use std::sync::atomic::{AtomicU32, Ordering};
use egui::TextureHandle;
use serde_json::{Value, json};
use crate::CadApp;
/// Bumped each time PREVIEW is started, so the window renders the drawing as it is now.
static PREVIEW_GEN: AtomicU32 = AtomicU32::new(0);
/// Handle a typed or menu-invoked file exchange command (`id` may be an alias).
pub fn route(app: &mut CadApp, id: &str) -> Result<Value, String> {
let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default();
let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty());
let stem = stem.unwrap_or_else(|| "Drawing".into());
match id {
"preview" | "pre" => {
PREVIEW_GEN.fetch_add(1, Ordering::Relaxed);
app.ui.dialog = Some("preview".into());
Ok(Value::Null)
}
"import" | "imp" => {
let Some(pick) = app.services.pick_open.as_ref() else { return no_picker(app, "IMPORT", r#"import {"path": "parts.dxf"}"#) };
match pick() {
Some(path) => app.run("import", json!({ "path": path })),
None => Ok(Value::Null),
}
}
"publish" => {
let Some(pick) = app.services.pick_save.as_ref() else { return no_picker(app, "PUBLISH", "publish {}") };
match pick(&format!("{stem}.pdf")) {
Some(path) => app.run("publish", json!({ "path": path })),
None => Ok(Value::Null),
}
}
_ => {
let Some(pick) = app.services.pick_save.as_ref() else { return no_picker(app, "EXPORT", r#"export {"format": "svg"}"#) };
match pick(&format!("{stem}.svg")) {
Some(path) => app.run("export", json!({ "path": path })),
None => Ok(Value::Null),
}
}
}
}
fn no_picker(app: &mut CadApp, name: &str, example: &str) -> Result<Value, String> {
let msg = format!("{name}: there is no file dialog here; run it with parameters, e.g. {example}");
app.session.echo(msg.clone());
Err(msg)
}
#[derive(Clone)]
struct Preview {
generation: u32,
shown: Result<(TextureHandle, String), String>,
}
/// Render the plot preview of the current layout (or model) as a texture.
fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, String), String> {
let r = app.session.execute("preview", &json!({ "width": 1600 })).map_err(|e| e.to_string())?;
let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?;
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8();
let size = [img.width() as usize, img.height() as usize];
let tex = ctx.load_texture("plot-preview", egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR);
let mut info = format!("{} · {}", r["layout"].as_str().unwrap_or(""), r["paper"].as_str().unwrap_or(""));
if r["landscape"].as_bool() == Some(true) {
info.push_str(" · landscape");
}
Ok((tex, info))
}
/// The Plot Preview window: the sheet as it plots, with Print and Close.
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let id = egui::Id::new("cc_plot_preview");
let generation = PREVIEW_GEN.load(Ordering::Relaxed);
let cached = ctx.data(|d| d.get_temp::<Preview>(id)).filter(|p| p.generation == generation);
let preview = match cached {
Some(p) => p,
None => {
let p = Preview { generation, shown: render(app, ctx) };
ctx.data_mut(|d| d.insert_temp(id, p.clone()));
p
}
};
let (mut close, mut print) = (false, false);
egui::Window::new("Plot Preview").open(open).collapsible(false).resizable(false).show(ctx, |ui| {
match &preview.shown {
Ok((tex, info)) => {
ui.label(info);
let size = tex.size_vec2();
let k = (760.0 / size.x).min(540.0 / size.y).min(1.0);
ui.image((tex.id(), size * k));
}
Err(e) => {
ui.label(e);
}
}
ui.add_space(4.0);
ui.horizontal(|ui| {
print = ui.button("Print...").clicked();
close = ui.button("Close").clicked();
});
});
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
close = true;
}
if close || print {
*open = false;
}
if !*open {
ctx.data_mut(|d| d.remove::<Preview>(id));
}
if print {
app.start("plot");
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use cadcraft_engine::Session;
use serde_json::json;
use crate::{CadApp, Services};
/// An app whose file dialogs record what they were asked and cancel.
fn app() -> (CadApp, Rc<RefCell<Vec<String>>>) {
let asked = Rc::new(RefCell::new(Vec::new()));
let (a, b) = (asked.clone(), asked.clone());
let services = Services {
pick_open: Some(Box::new(move || {
a.borrow_mut().push("open".into());
None
})),
pick_save: Some(Box::new(move |name: &str| {
b.borrow_mut().push(name.to_string());
None
})),
};
(CadApp::new(Session::new(), services), asked)
}
#[test]
fn typed_and_menu_commands_ask_json_calls_never_do() {
let (mut app, asked) = app();
app.start("export");
app.cmdline("PUBLISH");
app.start("imp");
assert_eq!(*asked.borrow(), ["Drawing1.svg", "Drawing1.pdf", "open"]);
assert!(app.ui.dialog.is_none());
app.cmdline("pre");
assert_eq!(app.ui.dialog.as_deref(), Some("preview"));
app.ui.dialog = None;
let _ = app.run("export", json!({ "format": "svg" }));
let _ = app.run("import", json!({}));
let _ = app.run("publish", json!({ "layouts": [] }));
let r = app.run("preview", json!({ "width": 64 })).unwrap();
assert!(r["data"].is_string());
assert_eq!(asked.borrow().len(), 3, "JSON calls never open a file dialog");
assert!(app.ui.dialog.is_none(), "nor the preview window");
let e = app.run("share", json!({})).unwrap_err();
assert!(e.contains("not available yet"), "{e}");
}
}
+3
View File
@@ -993,3 +993,6 @@
Hide Others Приховати інші
Accessibility Спеціальні можливості
Show System Cursor Показувати системний курсор
Import... Імпорт...
Export... Експорт...
Batch Publish... Пакетна публікація...
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
993
994
995
996
997
998
+2
View File
@@ -33,6 +33,8 @@ pub mod quick;
pub mod theme;
pub mod viewcube;
pub mod exchange;
use std::sync::mpsc::Receiver;
use cadcraft_engine::Session;
+4
View File
@@ -156,6 +156,10 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
toggle(&mut app.ui.show_file_tabs, params);
Ok(Value::Null)
}
// Typed or chosen from a menu or the tool bar: ask for the file (or show the preview).
"export" | "exp" | "import" | "imp" | "publish" | "preview" | "pre" if params.is_null() && app.session.state().is_ok() => {
return Some(crate::exchange::route(app, id));
}
"ui.toggle.statusbar" => {
toggle(&mut app.ui.show_status_bar, params);
Ok(Value::Null)
+2 -5
View File
@@ -4,11 +4,11 @@
Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment.
**Menu breadth: 233 / 491 items (47%)** · 295 registered commands.
**Menu breadth: 236 / 491 items (48%)** · 302 registered commands.
| Menu | Items | Covered | % |
|---|---|---|---|
| File | 22 | 9 | 41% |
| File | 22 | 12 | 55% |
| Edit | 13 | 10 | 77% |
| View | 75 | 27 | 36% |
| Insert | 11 | 2 | 18% |
@@ -25,15 +25,12 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
- File > New Sheet Set...
- File > Open Sheet Set...
- File > Open Recent > Clear Menu
- File > Import...
- File > Attach...
- File > Export...
- File > Export Layout to Model...
- File > Package Drawing...
- File > DWG Compare...
- File > Markup Import...
- File > Plot Styles...
- File > Batch Publish...
- File > Share...
- Edit > Undo Group of commands
- Edit > Cut with Base Point