mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-04 01:21:42 -06:00
Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2099288f54 | ||
|
|
de753897c4 | ||
|
|
39ee2e5960 | ||
|
|
5f8999f303 | ||
|
|
75049718c9 | ||
|
|
c2d62a2842 | ||
|
|
68e01dd487 | ||
|
|
6d3507f98d | ||
|
|
26e96cfb56 | ||
|
|
945c7f456b | ||
|
|
f10e41409d | ||
|
|
bd22363d5e | ||
|
|
a6ef259fec | ||
|
|
dad7d1c138 | ||
|
|
b1b3754ffa | ||
|
|
0e19f9d1d1 | ||
|
|
8232290830 | ||
|
|
457fc2319e | ||
|
|
15afc47296 | ||
|
|
d3226e629e | ||
|
|
238aa1789c | ||
|
|
f1312113d2 | ||
|
|
28421dd1d7 | ||
|
|
162cc4792b | ||
|
|
4c00b6732f | ||
|
|
58bb1e065e | ||
|
|
0955078f56 | ||
|
|
f04be0548f | ||
|
|
852688e000 | ||
|
|
6d31491952 | ||
|
|
c8e1671eb4 | ||
|
|
f7494840dc | ||
|
|
b639b18f69 | ||
|
|
578da3ba9c | ||
|
|
f2afcd4f02 | ||
|
|
1619e24381 | ||
|
|
ffc2c553e8 | ||
|
|
7bb6c2f06f | ||
|
|
e10f394b28 | ||
|
|
9a112830c5 | ||
|
|
b04273e897 | ||
|
|
a4ff30d11e | ||
|
|
9d83f21910 | ||
|
|
7caf369555 | ||
|
|
cbc9db75b1 | ||
|
|
9d4b3bb091 | ||
|
|
2a85653976 | ||
|
|
12fd74a60f | ||
|
|
e1d0a4747e | ||
|
|
b7c62eb93d | ||
|
|
52c1e6de98 | ||
|
|
9107479ade | ||
|
|
1823eeab19 | ||
|
|
97761eb2bf | ||
|
|
8b97b4f47e | ||
|
|
4c61c3c889 | ||
|
|
94b6c485b4 | ||
|
|
4c75916c75 | ||
|
|
ecd1bdc214 | ||
|
|
212f6cc988 |
Generated
+3
@@ -729,6 +729,7 @@ name = "cadcraft-fonts"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"encoding_rs",
|
||||
"harfrust 0.14.0",
|
||||
"icu_segmenter",
|
||||
"skrifa 0.48.0",
|
||||
@@ -755,6 +756,7 @@ dependencies = [
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"craft-pdf",
|
||||
"image",
|
||||
"miniz_oxide 0.9.1",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
@@ -821,6 +823,7 @@ name = "cadcraft-web"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-io",
|
||||
"cadcraft-ui-egui",
|
||||
"eframe",
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ unicode-bidi = "0.3.18"
|
||||
# Line-break opportunities between Thai words for MTEXT word wrap (Thai writes no spaces between
|
||||
# words): UAX #14 with ICU4X's compiled-in LSTM word model (not its dictionaries).
|
||||
icu_segmenter = { version = "~2.3", default-features = false, features = ["compiled_data", "lstm"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
|
||||
encoding_rs = "0.8.35"
|
||||
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
|
||||
quick-xml = "0.42"
|
||||
|
||||
@@ -81,6 +81,7 @@ pub(crate) enum Command {
|
||||
Mcp(McpArgs),
|
||||
Perf(PerfArgs),
|
||||
Sample(SampleArgs),
|
||||
Roundtrip(RoundtripArgs),
|
||||
}
|
||||
|
||||
/// Print a drawing's summary as JSON
|
||||
@@ -308,6 +309,40 @@ fn perf_target(p: PathBuf) -> PerfTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open each DXF/DWG, save it as DXF and DWG, reopen and compare; writes report.md and report.json
|
||||
///
|
||||
/// FOLDER defaults to $CADCRAFT_CORPUS_DIR. With CADCRAFT_CORPUS_STRICT=1 it fails when a file
|
||||
/// doesn't open, save or reopen, or loses records on save.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RoundtripArgs {
|
||||
/// A drawing, or a folder of them
|
||||
#[arg(value_name = "FILE|FOLDER")]
|
||||
pub(crate) path: Option<PathBuf>,
|
||||
/// Where the report goes (default: `report` beside the files)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub(crate) out: Option<PathBuf>,
|
||||
/// Keep the saved files, in `saved` under the report folder
|
||||
#[arg(long)]
|
||||
pub(crate) keep_saved: bool,
|
||||
/// Save only in each file's own format
|
||||
#[arg(long)]
|
||||
pub(crate) same_format: bool,
|
||||
/// Don't count what each file holds (faster; no "read lost" column)
|
||||
#[arg(long)]
|
||||
pub(crate) no_census: bool,
|
||||
/// How far numbers may move and still count as equal
|
||||
#[arg(long, value_name = "T", default_value_t = cadcraft_io::roundtrip::Options::default().abs_tol, value_parser = tolerance)]
|
||||
pub(crate) tolerance: f64,
|
||||
}
|
||||
|
||||
/// `--tolerance`: a finite number, 0 or more.
|
||||
fn tolerance(v: &str) -> Result<f64, ValueError> {
|
||||
match v.parse::<f64>() {
|
||||
Ok(t) if t.is_finite() && t >= 0.0 => Ok(t),
|
||||
_ => Err(ValueError::Tolerance(v.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a built-in sample drawing
|
||||
#[derive(Args)]
|
||||
pub(crate) struct SampleArgs {
|
||||
@@ -360,6 +395,8 @@ pub(crate) enum ValueError {
|
||||
CmdId,
|
||||
#[error(transparent)]
|
||||
CmdJson(#[from] serde_json::Error),
|
||||
#[error("expected a number of 0 or more, got `{0}`")]
|
||||
Tolerance(String),
|
||||
}
|
||||
|
||||
/// Why `parse` refuses flags clap accepted: clap prints it as a usage error (exit 2).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::output::OutputError;
|
||||
use crate::perf::PerfError;
|
||||
use crate::roundtrip::RoundtripError;
|
||||
use crate::run::RunError;
|
||||
|
||||
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
|
||||
@@ -19,6 +20,8 @@ pub(crate) enum CliError {
|
||||
Output(#[from] OutputError),
|
||||
#[error(transparent)]
|
||||
Perf(#[from] PerfError),
|
||||
#[error(transparent)]
|
||||
Roundtrip(#[from] RoundtripError),
|
||||
/// The engine and io crates take file names as text.
|
||||
#[error("{}: the file name is not valid Unicode", .0.display())]
|
||||
NotUnicode(std::path::PathBuf),
|
||||
|
||||
@@ -16,6 +16,7 @@ mod info;
|
||||
mod mcp;
|
||||
mod output;
|
||||
mod perf;
|
||||
mod roundtrip;
|
||||
mod run;
|
||||
mod sample;
|
||||
|
||||
@@ -32,6 +33,7 @@ use output::Streams;
|
||||
#[cfg(test)]
|
||||
use output::{export_window, model_views, write_atomic};
|
||||
use perf::perf;
|
||||
use roundtrip::roundtrip;
|
||||
use run::run_cmds;
|
||||
#[cfg(test)]
|
||||
use run::{fill_last, returned_handle};
|
||||
@@ -43,6 +45,7 @@ fn install_io() {
|
||||
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
|
||||
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
});
|
||||
cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string()));
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
|
||||
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
@@ -99,7 +102,7 @@ where
|
||||
Command::Run(a) => a.settings.apply(),
|
||||
Command::Mcp(a) => a.settings.apply(),
|
||||
Command::Sample(a) => a.settings.apply(),
|
||||
Command::Info(_) | Command::Commands(_) | Command::Perf(_) => {}
|
||||
Command::Info(_) | Command::Commands(_) | Command::Perf(_) | Command::Roundtrip(_) => {}
|
||||
}
|
||||
let mut io = Streams::new(out, err);
|
||||
let result = match &cli.command {
|
||||
@@ -110,6 +113,7 @@ where
|
||||
Command::Mcp(a) => mcp(a),
|
||||
Command::Perf(a) => perf(a.target.as_ref(), &mut io),
|
||||
Command::Sample(a) => sample(a, &mut io),
|
||||
Command::Roundtrip(a) => roundtrip(a, &mut io),
|
||||
};
|
||||
let mut code = 0;
|
||||
if let Err(e) = result {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! `roundtrip`: the corpus harness of `cadcraft_io::roundtrip::corpus` over a file or folder.
|
||||
|
||||
use super::args::RoundtripArgs;
|
||||
use super::error::{CliError, Result};
|
||||
use super::output::Streams;
|
||||
|
||||
/// Why `roundtrip` stopped.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum RoundtripError {
|
||||
#[error("roundtrip: give a FILE or FOLDER, or set CADCRAFT_CORPUS_DIR")]
|
||||
NoPath,
|
||||
#[error("roundtrip: no such file or folder: {}", .0.display())]
|
||||
Missing(std::path::PathBuf),
|
||||
#[error("roundtrip: no DXF or DWG files in {}", .0.display())]
|
||||
Empty(std::path::PathBuf),
|
||||
#[error("roundtrip: can't write the report to {}: {source}", .path.display())]
|
||||
Report { path: std::path::PathBuf, source: std::io::Error },
|
||||
#[error("roundtrip: {} strict failures:\n{}", .0.len(), .0.join("\n"))]
|
||||
Strict(Vec<String>),
|
||||
}
|
||||
|
||||
/// What the walk couldn't look at, so the report isn't taken for the whole corpus: said on
|
||||
/// stderr, and failures of a strict run.
|
||||
fn left_out(w: &cadcraft_io::roundtrip::corpus::Walk) -> Vec<String> {
|
||||
let mut out: Vec<String> = w.unreadable.iter().map(|u| format!("not read: {u}")).collect();
|
||||
if w.truncated {
|
||||
out.push("left out: files or folders past the harness limits".to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn roundtrip(a: &RoundtripArgs, io: &mut Streams) -> Result {
|
||||
use cadcraft_io::roundtrip::corpus;
|
||||
let path = a.path.clone().or_else(|| std::env::var_os("CADCRAFT_CORPUS_DIR").map(Into::into)).ok_or(RoundtripError::NoPath)?;
|
||||
let mut missed = Vec::new();
|
||||
let (root, files, out) = if path.is_dir() {
|
||||
let out = a.out.clone().unwrap_or_else(|| path.join("report"));
|
||||
// The report folder, and the saved copies in it, are never read back as corpus files.
|
||||
let walk = corpus::walk(&path, &[out.clone(), out.join("saved")]);
|
||||
missed = left_out(&walk);
|
||||
for m in &missed {
|
||||
io.errln(format_args!("roundtrip: {m}"));
|
||||
}
|
||||
(path, walk.files, out)
|
||||
} else if path.is_file() {
|
||||
let root = path.parent().map(std::path::Path::to_path_buf).unwrap_or_default();
|
||||
let out = a.out.clone().unwrap_or_else(|| root.join("report"));
|
||||
(root, vec![path], out)
|
||||
} else {
|
||||
return Err(RoundtripError::Missing(path).into());
|
||||
};
|
||||
if files.is_empty() {
|
||||
// A folder it couldn't read isn't an empty one.
|
||||
return Err(if missed.is_empty() { RoundtripError::Empty(root) } else { RoundtripError::Strict(missed) }.into());
|
||||
}
|
||||
let mut opts = corpus::CorpusOptions { cross_format: !a.same_format, census: !a.no_census, ..Default::default() };
|
||||
opts.compare.abs_tol = a.tolerance;
|
||||
if a.keep_saved {
|
||||
opts.keep_saved = Some(out.join("saved"));
|
||||
}
|
||||
let report = corpus::run(&root, &files, &opts, &mut |i, n, rel| eprintln!("[{}/{n}] {rel}", i + 1));
|
||||
for f in &report.files {
|
||||
let routes: Vec<String> = f
|
||||
.routes
|
||||
.iter()
|
||||
.map(|r| match (&r.save_error, &r.reopen_error) {
|
||||
(Some(_), _) => format!("{}: save failed", r.route),
|
||||
(None, Some(_)) => format!("{}: reopen failed", r.route),
|
||||
(None, None) => {
|
||||
let t = r.comparison.total();
|
||||
format!("{}: {} lost, {} added, {} changed", r.route, t.lost, t.added, t.changed)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
match &f.open_error {
|
||||
Some(e) => io.outln(format_args!("{}: open failed: {e}", f.path)),
|
||||
None => io.outln(format_args!("{}: opened; {}", f.path, routes.join("; "))),
|
||||
}
|
||||
}
|
||||
let (md, json) = report.write(&out).map_err(|source| RoundtripError::Report { path: out.clone(), source })?;
|
||||
io.outln(format_args!("report: {}", md.display()));
|
||||
io.outln(format_args!(" {}", json.display()));
|
||||
// CADCRAFT_CORPUS_STRICT=1: a file that doesn't open, save or reopen, or records a writer
|
||||
// leaves out, fail the run (the report is written first).
|
||||
if std::env::var("CADCRAFT_CORPUS_STRICT").is_ok_and(|v| v == "1") {
|
||||
let mut failed = report.strict_failures();
|
||||
failed.extend(missed);
|
||||
if !failed.is_empty() {
|
||||
return Err(CliError::from(RoundtripError::Strict(failed)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -20,7 +20,7 @@ fn the_example_writes_the_files_the_packages_install() {
|
||||
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
|
||||
written.sort();
|
||||
let completions = ["cadcraft-cli.bash", "_cadcraft-cli", "cadcraft-cli.fish", "cadcraft-cli.elv", "_cadcraft-cli.ps1"];
|
||||
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample"];
|
||||
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample", "roundtrip"];
|
||||
let mut expected: Vec<String> = completions.map(String::from).into();
|
||||
expected.push("cadcraft-cli.1".into());
|
||||
expected.extend(subcommands.iter().map(|s| format!("cadcraft-cli-{s}.1")));
|
||||
|
||||
@@ -18,6 +18,7 @@ path = "src/main.rs"
|
||||
cadcraft-engine = { workspace = true }
|
||||
cadcraft-ui-egui = { workspace = true }
|
||||
cadcraft-io = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -119,6 +119,7 @@ mod web {
|
||||
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
|
||||
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
});
|
||||
cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string()));
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
|
||||
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
//! a path from, so the picker is a hidden `<input type="file">`; the chosen file (or one dropped on
|
||||
//! the page) is read in the background and handed to the app on its next frame, which opens it
|
||||
//! with OPEN `{data, name}` ([`CadApp::open_bytes`]).
|
||||
//!
|
||||
//! SHX fonts (text fonts, big fonts, shape files) chosen or dropped with a drawing are the web
|
||||
//! build's font folder: they are registered with the font table
|
||||
//! ([`cadcraft_fonts::shx::register`]) under their file name, so the styles and linetypes that
|
||||
//! name them draw with them, and the drawings are regenerated.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
@@ -12,8 +17,14 @@ use web_sys::js_sys;
|
||||
|
||||
/// The hidden file input (one at a time; a cancelled pick leaves it until the next one).
|
||||
const INPUT_ID: &str = "cadcraft_open_input";
|
||||
/// What the picker offers: the formats OPEN reads.
|
||||
const ACCEPT: &str = ".dxf,.dwg";
|
||||
/// What the picker offers: the formats OPEN reads, and the SHX fonts drawings name.
|
||||
const ACCEPT: &str = ".dxf,.dwg,.shx";
|
||||
|
||||
/// Most files taken from one pick.
|
||||
const MAX_PICKED: u32 = 64;
|
||||
/// Most bytes being read at once, all files together: a large pick must not exhaust the page's
|
||||
/// memory (each file is copied once more into the app).
|
||||
const MAX_READING_BYTES: u64 = 512 << 20;
|
||||
|
||||
/// A file read in the browser (name, contents), or why it couldn't be read.
|
||||
type Upload = Result<(String, Vec<u8>), String>;
|
||||
@@ -22,12 +33,14 @@ type Upload = Result<(String, Vec<u8>), String>;
|
||||
#[derive(Clone)]
|
||||
pub struct Inbox {
|
||||
files: Rc<Cell<Vec<Upload>>>,
|
||||
/// Bytes of the files being read now.
|
||||
reading: Rc<Cell<u64>>,
|
||||
ctx: egui::Context,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
pub fn new(ctx: &egui::Context) -> Self {
|
||||
Inbox { files: Rc::default(), ctx: ctx.clone() }
|
||||
Inbox { files: Rc::default(), reading: Rc::default(), ctx: ctx.clone() }
|
||||
}
|
||||
|
||||
/// Show the browser's file picker and return at once. A chosen file arrives through
|
||||
@@ -51,13 +64,23 @@ impl Inbox {
|
||||
.map_err(|_| "could not create a file input".to_string())?;
|
||||
input.set_type("file");
|
||||
input.set_accept(ACCEPT);
|
||||
// A drawing and the fonts it uses can be chosen together.
|
||||
input.set_multiple(true);
|
||||
input.set_id(INPUT_ID);
|
||||
input.set_attribute("style", "display:none").map_err(js_error)?;
|
||||
let (inbox, chosen) = (self.clone(), input.clone());
|
||||
// Called once, when a file was chosen (browsers don't fire `change` on cancel).
|
||||
// Called once, when files were chosen (browsers don't fire `change` on cancel).
|
||||
let on_change = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
if let Some(file) = chosen.files().and_then(|f| f.get(0)) {
|
||||
inbox.read(file);
|
||||
if let Some(files) = chosen.files() {
|
||||
for i in 0..files.length().min(MAX_PICKED) {
|
||||
if let Some(file) = files.get(i) {
|
||||
inbox.read(file);
|
||||
}
|
||||
}
|
||||
let skipped: Vec<String> = (MAX_PICKED..files.length()).filter_map(|i| files.get(i)).map(|f| f.name()).collect();
|
||||
if !skipped.is_empty() {
|
||||
inbox.push(Err(format!("Open: at most {MAX_PICKED} files at a time; not opened: {}", skipped.join(", "))));
|
||||
}
|
||||
}
|
||||
chosen.remove();
|
||||
});
|
||||
@@ -87,12 +110,18 @@ impl Inbox {
|
||||
if let Some(e) = CadApp::open_size_error(&name, len) {
|
||||
return self.push(Err(e));
|
||||
}
|
||||
let reading = self.reading.get();
|
||||
if reading.saturating_add(len) > MAX_READING_BYTES {
|
||||
return self.push(Err(format!("Open: {name} was not read: the files being read together are too large; open it on its own")));
|
||||
}
|
||||
self.reading.set(reading.saturating_add(len));
|
||||
let inbox = self.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let r = match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
|
||||
Ok(buf) => Ok((name, js_sys::Uint8Array::new(&buf).to_vec())),
|
||||
Err(e) => Err(format!("Open: could not read {name}: {}", js_error(e))),
|
||||
};
|
||||
inbox.reading.set(inbox.reading.get().saturating_sub(len));
|
||||
inbox.push(r);
|
||||
});
|
||||
}
|
||||
@@ -118,9 +147,21 @@ impl Inbox {
|
||||
self.ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Open what arrived since the last frame (or report why it couldn't be read).
|
||||
/// Open what arrived since the last frame (or report why it couldn't be read). Fonts are
|
||||
/// registered first, so drawings opened with them draw with them.
|
||||
pub fn drain(&self, app: &mut CadApp) {
|
||||
for upload in self.files.take() {
|
||||
let (fonts, files): (Vec<Upload>, Vec<Upload>) = self.files.take().into_iter().partition(|u| u.as_ref().is_ok_and(|(n, _)| is_shx(n)));
|
||||
let mut registered = false;
|
||||
for (name, bytes) in fonts.into_iter().flatten() {
|
||||
match cadcraft_fonts::shx::register(&name, &bytes) {
|
||||
Ok(()) => registered = true,
|
||||
Err(e) => app.open_failed(format!("{name}: {e}")),
|
||||
}
|
||||
}
|
||||
if registered {
|
||||
let _ = app.run("regenall", serde_json::json!({}));
|
||||
}
|
||||
for upload in files {
|
||||
match upload {
|
||||
Ok((name, bytes)) => app.open_bytes(&name, &bytes),
|
||||
Err(e) => app.open_failed(e),
|
||||
@@ -129,6 +170,11 @@ impl Inbox {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` is an SHX font or shape file.
|
||||
fn is_shx(name: &str) -> bool {
|
||||
name.to_ascii_lowercase().ends_with(".shx")
|
||||
}
|
||||
|
||||
fn js_error(e: wasm_bindgen::JsValue) -> String {
|
||||
e.as_string().unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ pub fn install_io() {
|
||||
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
|
||||
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
});
|
||||
cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string()));
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
|
||||
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
|
||||
@@ -362,9 +362,13 @@ impl EntityKind {
|
||||
i.scale.y *= s;
|
||||
i.scale.z *= s;
|
||||
i.rotation = cadcraft_geom::norm_angle(i.rotation + rot);
|
||||
// Mirrored as AutoCAD 2024 mirrors a block reference: the X scale factor
|
||||
// negated, the rotation 180 degrees plus twice the mirror line's angle less the
|
||||
// old one, and a MINSERT grid's columns running the other way.
|
||||
if mirror {
|
||||
i.scale.y = -i.scale.y;
|
||||
i.rotation = cadcraft_geom::norm_angle(rot - (i.rotation - rot));
|
||||
i.scale.x = -i.scale.x;
|
||||
i.rotation = cadcraft_geom::norm_angle(std::f64::consts::PI + rot - (i.rotation - rot));
|
||||
i.col_spacing = -i.col_spacing;
|
||||
}
|
||||
for a in &mut i.attribs {
|
||||
transform_text(&mut a.text, m, s, rot, false);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use cadcraft_geom::Mat3;
|
||||
|
||||
use super::common::Handle;
|
||||
use super::{Color, Deserialize, Serialize, Vec2, Vec3, standard, yes};
|
||||
|
||||
@@ -25,6 +27,39 @@ pub struct Viewport {
|
||||
/// Display on (MVIEW ON/OFF). An off viewport shows its border but no model space.
|
||||
#[serde(default = "yes")]
|
||||
pub on: bool,
|
||||
/// The view twist (DVIEW TWist inside the viewport; DXF group 51): radians counterclockwise,
|
||||
/// the model view seen through the viewport is turned on the sheet by it. 0 is untwisted.
|
||||
#[serde(default, skip_serializing_if = "is_zero_twist")]
|
||||
pub twist: f64,
|
||||
}
|
||||
fn is_zero_twist(v: &f64) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
impl Viewport {
|
||||
/// The view twist, usable: 0 when it isn't finite.
|
||||
pub fn view_twist(&self) -> f64 {
|
||||
if self.twist.is_finite() { self.twist } else { 0.0 }
|
||||
}
|
||||
/// Paper units per model unit.
|
||||
pub fn scale(&self) -> f64 {
|
||||
self.height / self.view_height
|
||||
}
|
||||
/// Where model point `m` is on the sheet: `center + scale · R(twist)(m − view_center)`.
|
||||
pub fn model_to_paper(&self, m: Vec2) -> Vec2 {
|
||||
self.center.xy() + (m - self.view_center).rotate(self.view_twist()) * self.scale()
|
||||
}
|
||||
/// The model point at sheet point `p` (the inverse of [`Viewport::model_to_paper`]).
|
||||
pub fn paper_to_model(&self, p: Vec2) -> Vec2 {
|
||||
self.view_center + ((p - self.center.xy()) / self.scale()).rotate(-self.view_twist())
|
||||
}
|
||||
/// [`Viewport::model_to_paper`] as a matrix.
|
||||
pub fn model_to_paper_matrix(&self) -> Mat3 {
|
||||
let s = self.scale();
|
||||
Mat3::translate(self.center.xy())
|
||||
.then_before(Mat3::scale(s, s))
|
||||
.then_before(Mat3::rotate(self.view_twist()))
|
||||
.then_before(Mat3::translate(-self.view_center))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Viewport {
|
||||
@@ -41,6 +76,7 @@ impl Default for Viewport {
|
||||
layer_colors: Vec::new(),
|
||||
clip: None,
|
||||
on: true,
|
||||
twist: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,8 +95,9 @@ pub struct Image {
|
||||
/// The image definition's name (its key in the drawing's image dictionary).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub name: String,
|
||||
/// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down):
|
||||
/// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image.
|
||||
/// Clip boundary in pixel coordinates (y down, counted from pixel centres: the centre of the
|
||||
/// top-left pixel is the origin, so the whole image is -0.5 to size - 0.5): two opposite
|
||||
/// corners of a rectangle, or a polygon's vertices. Empty: the whole image.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub clip: Vec<Vec2>,
|
||||
/// The clip boundary is applied.
|
||||
|
||||
@@ -16,6 +16,7 @@ mod header;
|
||||
pub mod library;
|
||||
mod plotstyle;
|
||||
mod plotter;
|
||||
mod raster;
|
||||
mod refedit;
|
||||
mod store;
|
||||
mod tables;
|
||||
@@ -33,6 +34,7 @@ pub use gdt::*;
|
||||
pub use header::{HVal, Header};
|
||||
pub use plotstyle::*;
|
||||
pub use plotter::*;
|
||||
pub use raster::{Raster, Rasters};
|
||||
pub use refedit::{RefEdit, WorkItem};
|
||||
pub use store::EntityStore;
|
||||
pub use tables::*;
|
||||
@@ -136,6 +138,8 @@ pub struct Drawing {
|
||||
/// for the Linux fonts of text styles, and each style keeps its own in CADCraft xdata, so the
|
||||
/// drawing reopens with it. Never set on an open drawing.
|
||||
pub portable_fonts: bool,
|
||||
/// The pixels of the raster images that are loaded (IMAGE). Never written to a file.
|
||||
pub rasters: Rasters,
|
||||
}
|
||||
|
||||
impl Default for Drawing {
|
||||
@@ -196,6 +200,7 @@ impl Drawing {
|
||||
refedit: None,
|
||||
skipped_records: BTreeMap::new(),
|
||||
portable_fonts: false,
|
||||
rasters: Rasters::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Decoded raster image pixels (IMAGE entities). The drawing keeps them for the session only:
|
||||
//! they are loaded from the image files after a drawing opens and are never written to a file.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The pixels of a raster image: 8-bit RGBA, not premultiplied, rows from the top.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Raster {
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgba: Vec<u8>,
|
||||
opaque: bool,
|
||||
}
|
||||
|
||||
impl Raster {
|
||||
/// `None` unless both sides are at least one pixel and `rgba` holds exactly four bytes per pixel.
|
||||
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Option<Raster> {
|
||||
let pixels = usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?;
|
||||
if pixels == 0 || rgba.len() != pixels.checked_mul(4)? {
|
||||
return None;
|
||||
}
|
||||
let opaque = rgba.as_chunks::<4>().0.iter().all(|[_, _, _, alpha]| *alpha == u8::MAX);
|
||||
Some(Raster { width, height, rgba, opaque })
|
||||
}
|
||||
pub fn width(&self) -> u32 {
|
||||
self.width
|
||||
}
|
||||
pub fn height(&self) -> u32 {
|
||||
self.height
|
||||
}
|
||||
/// Four bytes per pixel (red, green, blue, alpha), `width * height` pixels.
|
||||
pub fn rgba(&self) -> &[u8] {
|
||||
&self.rgba
|
||||
}
|
||||
/// No pixel is transparent.
|
||||
pub fn opaque(&self) -> bool {
|
||||
self.opaque
|
||||
}
|
||||
/// The pixel in column `x` of row `y` (from the top): red, green, blue, alpha.
|
||||
pub fn pixel(&self, x: u32, y: u32) -> Option<[u8; 4]> {
|
||||
if x >= self.width || y >= self.height {
|
||||
return None;
|
||||
}
|
||||
let (x, y, width) = (usize::try_from(x).ok()?, usize::try_from(y).ok()?, usize::try_from(self.width).ok()?);
|
||||
let pixels = self.rgba.as_chunks::<4>().0;
|
||||
pixels.get(y.checked_mul(width)?.checked_add(x)?).copied()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Raster {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Raster({}x{})", self.width, self.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// The loaded pixels of a drawing's raster images, by image file path ([`crate::Image::path`]).
|
||||
/// Session data: two drawings are equal whatever images they have loaded, and copying a drawing
|
||||
/// (undo) shares the pixels.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Rasters(Arc<BTreeMap<String, Arc<Raster>>>);
|
||||
|
||||
impl Rasters {
|
||||
pub fn get(&self, path: &str) -> Option<&Arc<Raster>> {
|
||||
self.0.get(path)
|
||||
}
|
||||
pub fn insert(&mut self, path: &str, raster: Arc<Raster>) {
|
||||
Arc::make_mut(&mut self.0).insert(path.to_string(), raster);
|
||||
}
|
||||
pub fn remove(&mut self, path: &str) {
|
||||
if self.0.contains_key(path) {
|
||||
Arc::make_mut(&mut self.0).remove(path);
|
||||
}
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Rasters {
|
||||
fn eq(&self, _: &Rasters) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Rasters {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Rasters({})", self.0.len())
|
||||
}
|
||||
}
|
||||
@@ -863,7 +863,7 @@ pub struct NamedView {
|
||||
/// The layer state restored with the view (its layer snapshot).
|
||||
#[serde(default)]
|
||||
pub layer_state: Option<String>,
|
||||
/// View twist (radians, counterclockwise); kept for files, not applied to the 2D display.
|
||||
/// View twist (radians, counterclockwise); VIEW Restore applies it to the model space view.
|
||||
#[serde(default)]
|
||||
pub twist: f64,
|
||||
/// The layout a paper space view belongs to (`None`: a model space view; an empty name: a
|
||||
@@ -884,6 +884,9 @@ pub struct VportTile {
|
||||
/// Width / height of the tile on screen when it was saved (0: unknown).
|
||||
#[serde(default)]
|
||||
pub aspect: f64,
|
||||
/// The tile's view twist (radians, counterclockwise; DXF VPORT group 51 in degrees).
|
||||
#[serde(default)]
|
||||
pub twist: f64,
|
||||
}
|
||||
|
||||
/// A model space viewport configuration (VPORTS): `*Active` is the one shown, other names are
|
||||
|
||||
+84
-12
@@ -65,7 +65,7 @@ fn mb(n: u64) -> String {
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub mod worker;
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, serve_if_asked, use_own_worker, use_worker};
|
||||
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_noted, serve_if_asked, use_own_worker, use_worker};
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
mod native {
|
||||
@@ -544,15 +544,22 @@ mod native {
|
||||
/// Leave out what acadrust can't write as this DWG version: other programs' custom entities
|
||||
/// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file
|
||||
/// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types
|
||||
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many.
|
||||
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> usize {
|
||||
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many
|
||||
/// records of each DXF type were left out, so a save can say so.
|
||||
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> super::Unwritten {
|
||||
let version = doc.version;
|
||||
let mut dropped = super::Unwritten::new();
|
||||
let mut count = |name: &str| {
|
||||
let n = dropped.entry(name.trim().to_ascii_uppercase()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
};
|
||||
let unwritable: Vec<acadrust::Handle> = doc
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::entities::EntityType::Unknown(u)
|
||||
if u.raw_dwg_data.is_none() || u.dwg_source_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
|
||||
{
|
||||
count(&u.dxf_name);
|
||||
Some(u.common.handle)
|
||||
}
|
||||
_ => None,
|
||||
@@ -565,9 +572,10 @@ mod native {
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|(h, o)| match o {
|
||||
acadrust::objects::ObjectType::Unknown { raw_dwg_data, raw_dwg_version, .. }
|
||||
acadrust::objects::ObjectType::Unknown { type_name, raw_dwg_data, raw_dwg_version, .. }
|
||||
if raw_dwg_data.is_none() || raw_dwg_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
|
||||
{
|
||||
count(type_name);
|
||||
Some(*h)
|
||||
}
|
||||
_ => None,
|
||||
@@ -576,7 +584,7 @@ mod native {
|
||||
for h in &objects {
|
||||
doc.objects.remove(h);
|
||||
}
|
||||
unwritable.len() + objects.len()
|
||||
dropped
|
||||
}
|
||||
|
||||
/// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of
|
||||
@@ -644,12 +652,19 @@ mod native {
|
||||
/// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004
|
||||
/// (AC1018).
|
||||
pub fn dxf_to_dwg(dxf: &[u8]) -> Result<Vec<u8>, String> {
|
||||
convert(dxf, None)
|
||||
convert(dxf, None).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// Convert DXF bytes into a DWG file of `version`, a code from [`super::VERSIONS`] ("AC1032"),
|
||||
/// whatever version the DXF declares.
|
||||
pub fn dxf_to_dwg_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
dxf_to_dwg_noted(dxf, version).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// [`dxf_to_dwg_as`], also returning what the DWG writer left out: how many records of each
|
||||
/// DXF type it can't write (other programs' custom entities and objects that the DWG reader
|
||||
/// didn't read from a DWG of this version), empty when the DWG keeps everything.
|
||||
pub fn dxf_to_dwg_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
let v = match version.trim().to_ascii_uppercase().as_str() {
|
||||
"AC1015" => acadrust::DxfVersion::AC1015,
|
||||
"AC1018" => acadrust::DxfVersion::AC1018,
|
||||
@@ -707,7 +722,7 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
|
||||
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
// Reject a pathologically large DXF before handing it to the third-party decoder, which
|
||||
// allocates tables and section buffers from the input's declared sizes and cannot be
|
||||
// interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder
|
||||
@@ -732,21 +747,23 @@ mod native {
|
||||
None => {}
|
||||
}
|
||||
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
|
||||
guarded(AssertUnwindSafe(|| {
|
||||
let unwritten = guarded(AssertUnwindSafe(|| {
|
||||
super::fail_in_test("convert");
|
||||
other_layout_owners(&mut doc);
|
||||
table_style_rows(&mut doc, true);
|
||||
dimension_flags(&mut doc);
|
||||
drop_unwritable(&mut doc);
|
||||
let unwritten = drop_unwritable(&mut doc);
|
||||
current_dimstyle(&mut doc);
|
||||
add_variable_dictionary(&mut doc);
|
||||
current_text_style(&mut doc);
|
||||
current_layer(&mut doc);
|
||||
unwritten
|
||||
}))
|
||||
.map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?;
|
||||
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
|
||||
let dwg = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
|
||||
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
|
||||
.map_err(|e| format!("DWG write: {e}"))
|
||||
.map_err(|e| format!("DWG write: {e}"))?;
|
||||
Ok((dwg, unwritten))
|
||||
}
|
||||
|
||||
/// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the
|
||||
@@ -903,7 +920,11 @@ mod native {
|
||||
}
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
|
||||
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as, dxf_to_dwg_noted};
|
||||
|
||||
/// What saving as DWG left out: how many records of each DXF type (upper case) the DWG writer
|
||||
/// can't write.
|
||||
pub type Unwritten = std::collections::BTreeMap<String, usize>;
|
||||
|
||||
// Test-only: the stage ("post_process" or "convert") whose guard the current test checks. The
|
||||
// stage then panics on entry, so a test through the public API shows whether it is guarded.
|
||||
@@ -950,6 +971,10 @@ pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), St
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
dxf_to_dwg_as(dxf, version)
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, Unwritten), String> {
|
||||
dxf_to_dwg_noted(dxf, version)
|
||||
}
|
||||
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
@@ -971,6 +996,10 @@ pub fn dxf_to_dwg(_dxf: &[u8]) -> Result<Vec<u8>, String> {
|
||||
pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result<Vec<u8>, String> {
|
||||
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
#[cfg(no_dwg_bridge)]
|
||||
pub fn dxf_to_dwg_noted(_dxf: &[u8], _version: &str) -> Result<(Vec<u8>, Unwritten), String> {
|
||||
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -1302,4 +1331,47 @@ mod tests {
|
||||
assert_eq!(read.dwg_tail_byte, 0, "{version}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Saving says what the DWG writer left out, per type: entities of types acadrust's DXF
|
||||
/// reader doesn't model (another program's custom entity) can't be written; a drawing the DWG
|
||||
/// keeps whole reports nothing.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn saving_lists_the_types_the_dwg_writer_leaves_out() {
|
||||
let line = " 0\nLINE\n 8\n0\n100\nAcDbEntity\n100\nAcDbLine\n 10\n0.0\n 20\n0.0\n 30\n0.0\n 11\n10.0\n 21\n0.0\n 31\n0.0\n";
|
||||
let widget = " 0\nACME_WIDGET\n 8\n0\n100\nAcDbEntity\n100\nAcmeWidget\n 10\n1.0\n 20\n2.0\n 30\n0.0\n";
|
||||
let dxf = |body: &str| {
|
||||
format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{body} 0\nENDSEC\n 0\nEOF\n")
|
||||
};
|
||||
for version in ["AC1015", "AC1018", "AC1032"] {
|
||||
let (dwg, unwritten) = dxf_to_dwg_noted(dxf(&format!("{line}{widget}{widget}")).as_bytes(), version).unwrap();
|
||||
assert!(is_dwg(&dwg), "{version}");
|
||||
assert_eq!(unwritten, [("ACME_WIDGET".to_string(), 2)].into_iter().collect::<Unwritten>(), "{version}");
|
||||
}
|
||||
let (_, unwritten) = dxf_to_dwg_noted(dxf(line).as_bytes(), "AC1032").unwrap();
|
||||
assert!(unwritten.is_empty(), "{unwritten:?}");
|
||||
}
|
||||
|
||||
/// An OLE2FRAME, which CADCraft keeps as its DXF groups without modelling it, is written to
|
||||
/// the DWG with its data: it isn't in the list of what the writer left out.
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn ole_frames_are_written_with_their_data() {
|
||||
let ole = " 0\nOLE2FRAME\n 8\n0\n100\nAcDbEntity\n100\nAcDbOle2Frame\n 70\n2\n 10\n1.0\n 20\n9.0\n 30\n0.0\n 11\n8.0\n 21\n2.0\n 31\n0.0\n 71\n2\n 72\n0\n 73\n1\n 90\n4\n310\nDEADBEEF\n 1\nOLE\n";
|
||||
let dxf =
|
||||
format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{ole} 0\nENDSEC\n 0\nEOF\n");
|
||||
for version in ["AC1015", "AC1018", "AC1032"] {
|
||||
let (dwg, unwritten) = dxf_to_dwg_noted(dxf.as_bytes(), version).unwrap();
|
||||
assert!(unwritten.is_empty(), "{version} {unwritten:?}");
|
||||
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
|
||||
let frames: Vec<Vec<u8>> = back
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::entities::EntityType::Ole2Frame(o) => Some(o.encoded_payload()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(frames, [vec![0xDE, 0xAD, 0xBE, 0xEF]], "{version}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,15 +82,31 @@ pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), St
|
||||
|
||||
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
encode_noted(dxf, version).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes
|
||||
/// back as notes of `TYPE<tab>count`.
|
||||
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
if u8::try_from(version.len()).is_err() {
|
||||
return Err(super::unsupported(version));
|
||||
}
|
||||
match call(&request(ENCODE, Limits::DEFAULT, version), dxf, MAX_DWG_REPLY, "writing this file") {
|
||||
Some(reply) => reply?.map(|(dwg, _)| dwg),
|
||||
None => super::dxf_to_dwg_as(dxf, version),
|
||||
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(¬es))),
|
||||
None => super::dxf_to_dwg_noted(dxf, version),
|
||||
}
|
||||
}
|
||||
|
||||
fn unwritten_from(notes: &[String]) -> super::Unwritten {
|
||||
notes.iter().filter_map(|n| n.split_once('\t')).filter_map(|(kind, n)| Some((kind.to_string(), n.parse::<usize>().ok()?))).fold(
|
||||
super::Unwritten::new(),
|
||||
|mut m, (kind, n)| {
|
||||
*m.entry(kind).or_insert(0) = n;
|
||||
m
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Make the worker abort, as the third-party code can: what the tests prove isolation with.
|
||||
#[doc(hidden)]
|
||||
pub fn crash_worker_for_test() -> Result<(), String> {
|
||||
@@ -241,7 +257,7 @@ fn handle(mut b: &[u8]) -> Reply {
|
||||
let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
|
||||
match op {
|
||||
DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }),
|
||||
ENCODE => super::dxf_to_dwg_as(b, version).map(|dwg| (dwg, Vec::new())),
|
||||
ENCODE => super::dxf_to_dwg_noted(b, version).map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect())),
|
||||
CRASH => std::process::abort(),
|
||||
_ => Err(bad()),
|
||||
}
|
||||
@@ -267,6 +283,12 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_writer_left_out_survives_the_reply() {
|
||||
let notes = vec!["ACME_WIDGET\t2".to_string(), "not a count".to_string(), "X\tmany".to_string()];
|
||||
assert_eq!(unwritten_from(¬es), [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_round_trip() {
|
||||
let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()]));
|
||||
|
||||
@@ -234,9 +234,10 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
missing("table style", "Standard", out);
|
||||
d.table_styles.insert(0, TableStyle::default());
|
||||
}
|
||||
let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();
|
||||
// Name sets built once: both tables come from the drawing, so a scan per reference is quadratic.
|
||||
let linetypes: HashSet<String> = d.linetypes.iter().map(|l| l.name.to_ascii_lowercase()).collect();
|
||||
for l in &mut d.layers {
|
||||
if !linetypes.iter().any(|n| n.eq_ignore_ascii_case(&l.linetype)) {
|
||||
if !linetypes.contains(&l.linetype.to_ascii_lowercase()) {
|
||||
out.push(issue(
|
||||
"missingLinetype",
|
||||
"tables",
|
||||
@@ -247,8 +248,8 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
l.linetype = "Continuous".into();
|
||||
}
|
||||
}
|
||||
let styles: Vec<String> = d.text_styles.iter().map(|s| s.name.clone()).collect();
|
||||
let known = |n: &str| styles.iter().any(|s| s.eq_ignore_ascii_case(n));
|
||||
let styles: HashSet<String> = d.text_styles.iter().map(|s| s.name.to_ascii_lowercase()).collect();
|
||||
let known = |n: &str| styles.contains(&n.to_ascii_lowercase());
|
||||
for s in &mut d.dim_styles {
|
||||
if !known(&s.text_style) {
|
||||
out.push(issue(
|
||||
|
||||
@@ -405,10 +405,11 @@ mod native {
|
||||
pub fn prune_locks(dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let names: Vec<String> = entries.flatten().take(10_000).map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
// The instances that own autosave files, parsed once rather than once per lock file.
|
||||
let with_files: std::collections::HashSet<&str> = names.iter().filter_map(|f| parse_file_name(f)).map(|(_, i, _)| i).collect();
|
||||
for n in &names {
|
||||
let Some(instance) = parse_lock_name(n) else { continue };
|
||||
let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance));
|
||||
if !has_files && !owner_alive(dir, instance) {
|
||||
if !with_files.contains(instance) && !owner_alive(dir, instance) {
|
||||
let _ = std::fs::remove_file(dir.join(n));
|
||||
}
|
||||
}
|
||||
@@ -435,7 +436,22 @@ mod native {
|
||||
}
|
||||
// Create a fresh temporary file exclusively and rename it into place, so a planted
|
||||
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
|
||||
crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string())
|
||||
// The file holds unsaved work: owner-only from its creation, whatever the umask.
|
||||
crate::atomic_write::write_atomic_with(Path::new(path), private_mode(), |f| f.write_all(&bytes)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Owner read/write only (`None` where file modes don't exist).
|
||||
fn private_mode() -> Option<std::fs::Permissions> {
|
||||
#[cfg(unix)]
|
||||
return Some(std::os::unix::fs::PermissionsExt::from_mode(0o600));
|
||||
#[cfg(not(unix))]
|
||||
None
|
||||
}
|
||||
|
||||
/// Write this app's lock file at `path` without following a link planted there: the file is
|
||||
/// replaced (exclusive temporary file, then rename), never written through (CWE-59).
|
||||
fn write_lock(path: &Path, id: u32) -> std::io::Result<()> {
|
||||
crate::atomic_write::write_atomic_with(path, private_mode(), |f| f.write_all(id.to_string().as_bytes()))
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
@@ -586,7 +602,7 @@ mod native {
|
||||
for (held, file) in &self.locks {
|
||||
let refreshed = match file {
|
||||
Some(file) => file.set_modified(SystemTime::now()),
|
||||
None => std::fs::write(held, id.to_string()),
|
||||
None => write_lock(held, id),
|
||||
};
|
||||
if held == path {
|
||||
current = Some(refreshed);
|
||||
@@ -595,10 +611,22 @@ mod native {
|
||||
if let Some(refreshed) = current {
|
||||
return refreshed;
|
||||
}
|
||||
let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create(true).truncate(false);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
// A symlink planted at the lock path must not be opened (and truncated) through,
|
||||
// nor a FIFO wait for a reader.
|
||||
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK).mode(0o600);
|
||||
}
|
||||
let mut file = options.open(path)?;
|
||||
if !file.metadata()?.is_file() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the lock file is not a plain file"));
|
||||
}
|
||||
if file.try_lock().is_err() {
|
||||
self.locks.push((path.to_path_buf(), None));
|
||||
return std::fs::write(path, id.to_string());
|
||||
return write_lock(path, id);
|
||||
}
|
||||
file.set_len(0)?;
|
||||
file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?;
|
||||
@@ -627,6 +655,30 @@ mod native {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A symlink planted at the lock path is neither opened through nor written through.
|
||||
#[test]
|
||||
fn lock_writes_do_not_follow_a_planted_link() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-lock-link-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let victim = dir.join("victim.txt");
|
||||
std::fs::write(&victim, b"precious").unwrap();
|
||||
let link = dir.join("1-a.lock");
|
||||
std::os::unix::fs::symlink(&victim, &link).unwrap();
|
||||
let mut a = Autosaver::new("1-a");
|
||||
assert!(a.refresh_lock(&link).is_err());
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
|
||||
write_lock(&link, 42).unwrap();
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
|
||||
assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
|
||||
assert_eq!(std::fs::read(&link).unwrap(), b"42");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -18,10 +18,12 @@ mod gripcmds;
|
||||
mod griphot;
|
||||
pub(crate) mod groups;
|
||||
pub(crate) mod hatch;
|
||||
mod image;
|
||||
mod inquiry;
|
||||
mod layer;
|
||||
mod layout;
|
||||
mod lisp;
|
||||
mod merge;
|
||||
mod mleader_content;
|
||||
mod mleader_edit;
|
||||
mod modify;
|
||||
@@ -199,6 +201,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(layer::specs());
|
||||
v.extend(layout::specs());
|
||||
v.extend(exchange::specs());
|
||||
v.extend(image::specs());
|
||||
v.extend(props::specs());
|
||||
v.extend(inquiry::specs());
|
||||
v.extend(settings::specs());
|
||||
|
||||
@@ -157,7 +157,7 @@ fn write_audit_log(_: &Session, _: &crate::audit::Report) {}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_path(cmd: &str, path: &str) -> Result<Vec<u8>> {
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), file::MAX_DRAWING_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
std::fs::read(path).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
+171
-15
@@ -43,6 +43,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.alias(&["i", "-insert", "ddinsert"])
|
||||
.params("{name (\"*NAME\": exploded), file?: DXF/DWG path (defines or redefines block `name`, default the file name, from its model space; base point $INSBASE), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y] (block coordinates), attribs?: {TAG: value}, explode?: bool}")
|
||||
.interactive(|_| Ok(Box::new(InsertM::default()))),
|
||||
CommandSpec::new("minsert", "Multiple Insert", run_minsert)
|
||||
.params("{name, file?: DXF/DWG path (as INSERT), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y], attribs?: {TAG: value}, rows?: 1..32767 (default 1), columns?: 1..32767 (default 1), rowSpacing?, columnSpacing? (along the reference's own axes, not scaled with it)}")
|
||||
.interactive(|_| Ok(Box::new(InsertM { minsert: true, ..InsertM::default() }))),
|
||||
CommandSpec::new("attdef", "Define Attributes...", run_attdef)
|
||||
.menu(&["Draw", "Block", "Define Attributes..."])
|
||||
.alias(&["att", "-attdef"])
|
||||
@@ -233,6 +236,33 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option
|
||||
ins
|
||||
}
|
||||
|
||||
/// MINSERT's rectangular array of a block reference: rows and columns, and the distances between
|
||||
/// them along the reference's rotated axes (not scaled with it). One row and one column is a plain
|
||||
/// reference.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Grid {
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
row_spacing: f64,
|
||||
col_spacing: f64,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
const ONE: Grid = Grid { rows: 1, cols: 1, row_spacing: 0.0, col_spacing: 0.0 };
|
||||
/// The most rows or columns MINSERT takes, as AutoCAD 2024 asks for them.
|
||||
const MAX: u32 = 32767;
|
||||
|
||||
/// A distance is kept only along an axis with more than one copy: AutoCAD 2024 asks for no
|
||||
/// other and stores 0 for it (one row of three columns: 44 = 20, 45 = 0).
|
||||
fn new(rows: u32, cols: u32, row_spacing: f64, col_spacing: f64) -> Grid {
|
||||
Grid { rows, cols, row_spacing: if rows > 1 { row_spacing } else { 0.0 }, col_spacing: if cols > 1 { col_spacing } else { 0.0 } }
|
||||
}
|
||||
|
||||
fn apply(self, ins: &mut Insert) {
|
||||
(ins.rows, ins.cols, ins.row_spacing, ins.col_spacing) = (self.rows, self.cols, self.row_spacing, self.col_spacing);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`insert`] with separate X/Y scale factors (Z as X) and an optional base point (see
|
||||
/// [`insert_entity`]).
|
||||
pub(crate) fn insert_with(
|
||||
@@ -244,10 +274,12 @@ pub(crate) fn insert_with(
|
||||
base: Option<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
) -> Result<Handle> {
|
||||
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values)
|
||||
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values, Grid::ONE)
|
||||
}
|
||||
|
||||
/// [`insert_with`] with a Z scale factor of its own.
|
||||
/// [`insert_with`] with a Z scale factor of its own, and MINSERT's grid. A grid has the one set
|
||||
/// of attributes (AutoCAD 2024 asks for their values once).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_scaled(
|
||||
s: &mut Session,
|
||||
name: &str,
|
||||
@@ -256,9 +288,11 @@ fn insert_scaled(
|
||||
rotation: f64,
|
||||
base: Option<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
grid: Grid,
|
||||
) -> Result<Handle> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let mut ins = insert_entity(&blk, at, scale, rotation, base);
|
||||
grid.apply(&mut ins);
|
||||
let m = ins.transform(blk.base.xy());
|
||||
for e in blk.entities.iter() {
|
||||
if let EntityKind::AttDef(ad) = &e.kind {
|
||||
@@ -310,24 +344,52 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
insert_json(s, p, "insert", Grid::ONE)
|
||||
}
|
||||
|
||||
/// MINSERT's JSON form: INSERT's parameters and the grid.
|
||||
fn run_minsert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let count = |key: &str| -> Result<u32> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(1),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|n| n.fract() == 0.0 && *n >= 1.0 && *n <= f64::from(Grid::MAX))
|
||||
.map(|n| n as u32)
|
||||
.ok_or_else(|| bad("minsert", format!("`{key}` must be an integer between 1 and {}", Grid::MAX))),
|
||||
}
|
||||
};
|
||||
// A distance given must be a number: `"15"` is refused, not taken as 0.
|
||||
let spacing = |key: &str| -> Result<f64> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(0.0),
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("minsert", format!("`{key}` must be a number"))),
|
||||
}
|
||||
};
|
||||
let grid = Grid::new(count("rows")?, count("columns")?, spacing("rowSpacing")?, spacing("columnSpacing")?);
|
||||
insert_json(s, p, "minsert", grid)
|
||||
}
|
||||
|
||||
/// INSERT's and MINSERT's JSON form.
|
||||
fn insert_json(s: &mut Session, p: &Value, cmd: &str, grid: Grid) -> Result<Value> {
|
||||
let p = &insert_file::json_params(s, p)?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("insert", "`name` is required"))?.to_string();
|
||||
let at = point_req("insert", p, "at")?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
|
||||
let at = point_req(cmd, p, "at")?;
|
||||
// `scale`: one factor, or [x, y] for different X and Y scale factors.
|
||||
let scale = match p.get("scale") {
|
||||
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad("insert", "`scale` must be a number or [x, y]"))?,
|
||||
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad(cmd, "`scale` must be a number or [x, y]"))?,
|
||||
_ => {
|
||||
let f = f64_or(p, "scale", 1.0);
|
||||
Vec2::new(f, f)
|
||||
}
|
||||
};
|
||||
if scale.x == 0.0 || scale.y == 0.0 {
|
||||
return Err(bad("insert", "scale cannot be 0"));
|
||||
return Err(bad(cmd, "scale cannot be 0"));
|
||||
}
|
||||
let rot = f64_or(p, "rotation", 0.0).to_radians();
|
||||
let vals = p.get("attribs").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let h = insert_with(s, &name, at, scale, rot, point_param(p, "basePoint"), &vals)?;
|
||||
if bool_or(p, "explode", false) {
|
||||
let h = insert_scaled(s, &name, at, Vec3::new(scale.x, scale.y, scale.x), rot, point_param(p, "basePoint"), &vals, grid)?;
|
||||
if cmd == "insert" && bool_or(p, "explode", false) {
|
||||
// The EXPLODE command body, not `execute`: INSERT records the one undo step.
|
||||
let explode = find_command("explode").ok_or_else(|| EngineError::UnknownCommand("explode".into()))?;
|
||||
return (explode.run)(s, &json!({ "handles": [h.hex()] }));
|
||||
@@ -560,6 +622,15 @@ enum InsStage {
|
||||
/// XYZ: the Z scale factor, after X and Y.
|
||||
ZScale,
|
||||
Rotation,
|
||||
/// MINSERT: the number of rows, then of columns.
|
||||
Rows,
|
||||
Cols,
|
||||
/// MINSERT: the distance between rows, or a unit cell's first corner.
|
||||
RowSpacing,
|
||||
/// MINSERT: the unit cell's opposite corner (its width between columns, its height between
|
||||
/// rows).
|
||||
Cell(Vec2),
|
||||
ColSpacing,
|
||||
Attribs,
|
||||
/// A Verify attribute asked again.
|
||||
Verify,
|
||||
@@ -592,6 +663,12 @@ struct InsertM {
|
||||
explode: bool,
|
||||
/// The file waiting for the Redefine answer.
|
||||
file: Option<insert_file::FileRef>,
|
||||
/// MINSERT: after the rotation, the grid's rows and columns and the distances between them.
|
||||
minsert: bool,
|
||||
rows: Option<u32>,
|
||||
cols: Option<u32>,
|
||||
row_spacing: Option<f64>,
|
||||
col_spacing: Option<f64>,
|
||||
}
|
||||
|
||||
impl InsertM {
|
||||
@@ -601,6 +678,8 @@ impl InsertM {
|
||||
if self.explode { InsStage::PresetScale } else { InsStage::XScale }
|
||||
} else if self.rotation.is_none() {
|
||||
InsStage::Rotation
|
||||
} else if let Some(g) = self.grid_stage() {
|
||||
g
|
||||
} else {
|
||||
InsStage::Attribs
|
||||
};
|
||||
@@ -619,7 +698,7 @@ impl InsertM {
|
||||
&& self.values.len() >= self.attdefs.len()
|
||||
&& let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
|
||||
{
|
||||
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values)?;
|
||||
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values, self.grid())?;
|
||||
if self.explode {
|
||||
insert_file::explode_insert(s, h)?;
|
||||
}
|
||||
@@ -628,6 +707,30 @@ impl InsertM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
/// MINSERT's next grid prompt, if any is left (as AutoCAD 2024 asks them): the rows, the
|
||||
/// columns, then the distance between rows when there is more than one row and between
|
||||
/// columns when there is more than one column. A unit cell gives both distances.
|
||||
fn grid_stage(&self) -> Option<InsStage> {
|
||||
if !self.minsert {
|
||||
return None;
|
||||
}
|
||||
let (Some(rows), Some(cols)) = (self.rows, self.cols) else {
|
||||
return Some(if self.rows.is_none() { InsStage::Rows } else { InsStage::Cols });
|
||||
};
|
||||
if rows > 1 && self.row_spacing.is_none() {
|
||||
Some(InsStage::RowSpacing)
|
||||
} else if cols > 1 && self.col_spacing.is_none() {
|
||||
Some(InsStage::ColSpacing)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid answered so far (one copy for INSERT).
|
||||
fn grid(&self) -> Grid {
|
||||
Grid::new(self.rows.unwrap_or(1), self.cols.unwrap_or(1), self.row_spacing.unwrap_or(0.0), self.col_spacing.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// The Verify attributes asked for.
|
||||
fn verifying(&self) -> Vec<&(String, String, String, bool)> {
|
||||
self.attdefs.iter().filter(|a| a.3).collect()
|
||||
@@ -698,13 +801,18 @@ impl InsertM {
|
||||
}
|
||||
}
|
||||
|
||||
/// A distance typed at MINSERT's grid prompts: any finite number, negative or 0 too.
|
||||
fn distance(t: &str) -> Result<f64> {
|
||||
number(t).filter(|v| v.is_finite()).ok_or_else(|| EngineError::Other("Requires a distance.".into()))
|
||||
}
|
||||
|
||||
fn nonzero(t: &str) -> Result<f64> {
|
||||
number(t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into()))
|
||||
}
|
||||
|
||||
impl Interactive for InsertM {
|
||||
fn name(&self) -> &'static str {
|
||||
"INSERT"
|
||||
if self.minsert { "MINSERT" } else { "INSERT" }
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let at = self.at.unwrap_or_default();
|
||||
@@ -731,9 +839,16 @@ impl Interactive for InsertM {
|
||||
InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
|
||||
InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"),
|
||||
InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at),
|
||||
InsStage::Rows => Prompt::new("Enter number of rows (---)", Accept::NUMBER).default("1"),
|
||||
InsStage::Cols => Prompt::new("Enter number of columns (|||)", Accept::NUMBER).default("1"),
|
||||
InsStage::RowSpacing => Prompt::new("Enter distance between rows or specify unit cell (---)", Accept::POINT_OR_NUMBER),
|
||||
InsStage::Cell(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
|
||||
InsStage::ColSpacing => Prompt::new("Specify distance between columns (|||)", Accept::NUMBER),
|
||||
InsStage::Attribs => {
|
||||
let (_, prompt, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default)
|
||||
let p = Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT);
|
||||
// No default shown when there is none ("Value:", AutoCAD 2024), not "<>".
|
||||
if default.is_empty() { p } else { p.default(default) }
|
||||
}
|
||||
InsStage::Verify => {
|
||||
let (tag, prompt) = self.verifying().get(self.verified).map(|a| (a.0.clone(), a.1.clone())).unwrap_or_default();
|
||||
@@ -755,9 +870,9 @@ impl Interactive for InsertM {
|
||||
s.echo(format!("Defined blocks: {}", names.join(", ")));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
// `*name`: insert exploded.
|
||||
self.explode = n.starts_with('*');
|
||||
let n = n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string();
|
||||
// `*name`: insert exploded (INSERT only: a grid has no exploded form).
|
||||
self.explode = !self.minsert && n.starts_with('*');
|
||||
let n = if self.explode { n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string() } else { n };
|
||||
match insert_file::file_ref(s, &n) {
|
||||
Some(f) if f.ask_redefine(s) => {
|
||||
self.file = Some(f);
|
||||
@@ -897,6 +1012,45 @@ impl Interactive for InsertM {
|
||||
});
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::Rows | InsStage::Cols => {
|
||||
let n = match i {
|
||||
Input::Text(t) => t
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|v| v.fract() == 0.0 && *v >= 1.0 && *v <= f64::from(Grid::MAX))
|
||||
.map(|v| v as u32)
|
||||
.ok_or_else(|| EngineError::Other(format!("Requires an integer between 1 and {}.", Grid::MAX)))?,
|
||||
Input::Enter => 1,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if self.stage == InsStage::Rows {
|
||||
self.rows = Some(n);
|
||||
} else {
|
||||
self.cols = Some(n);
|
||||
}
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::RowSpacing => match i {
|
||||
Input::Point(a) => self.stage = InsStage::Cell(a),
|
||||
Input::Text(t) => {
|
||||
self.row_spacing = Some(distance(&t)?);
|
||||
return self.advance(s);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
InsStage::Cell(a) => {
|
||||
if let Input::Point(b) = i {
|
||||
(self.col_spacing, self.row_spacing) = (Some(b.x - a.x), Some(b.y - a.y));
|
||||
return self.advance(s);
|
||||
}
|
||||
}
|
||||
InsStage::ColSpacing => {
|
||||
if let Input::Text(t) = i {
|
||||
self.col_spacing = Some(distance(&t)?);
|
||||
return self.advance(s);
|
||||
}
|
||||
}
|
||||
InsStage::Attribs => {
|
||||
let (tag, _, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
let v = match i {
|
||||
@@ -937,7 +1091,9 @@ impl Interactive for InsertM {
|
||||
_ => Vec2::new(1.0, 1.0),
|
||||
});
|
||||
let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 });
|
||||
vec![EntityKind::Insert(insert_entity(b, at, self.scale3(scale), rot, self.base))]
|
||||
let mut ins = insert_entity(b, at, self.scale3(scale), rot, self.base);
|
||||
self.grid().apply(&mut ins);
|
||||
vec![EntityKind::Insert(ins)]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! (names this drawing already has keep this drawing's definitions). The file is read through the
|
||||
//! host's io hooks like OPEN, so the engine stays I/O-agnostic.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, Handle};
|
||||
@@ -85,8 +85,7 @@ fn read_drawing(path: &str) -> Result<Drawing> {
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
|
||||
.map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
std::fs::read(_path).map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("insert", "paths are not available on the web"))
|
||||
@@ -103,7 +102,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
// Redefining keeps the existing definition's name (block names are case-insensitive).
|
||||
let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
|
||||
merge_tables(&mut d, src);
|
||||
let renames = block_names(&d, src, &name);
|
||||
let renames = crate::cmd::merge::block_renames(&d, src, Some(&name));
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = src.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -137,13 +136,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
|
||||
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
|
||||
/// exists keeps this drawing's definition).
|
||||
fn merge_tables(d: &mut Drawing, src: &Drawing) {
|
||||
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
merge(&mut d.layers, &src.layers, |l| &l.name);
|
||||
merge(&mut d.linetypes, &src.linetypes, |l| &l.name);
|
||||
merge(&mut d.text_styles, &src.text_styles, |t| &t.name);
|
||||
@@ -153,35 +146,6 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) {
|
||||
merge(&mut d.mline_styles, &src.mline_styles, |t| &t.name);
|
||||
}
|
||||
|
||||
/// 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 (or the block being defined) keeps this drawing's definition (not copied).
|
||||
fn block_names(d: &Drawing, src: &Drawing, defining: &str) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
taken.insert(defining.to_ascii_uppercase());
|
||||
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 to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
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, src: &Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
|
||||
@@ -62,11 +62,20 @@ pub(crate) fn after_open(s: &mut Session, i: usize) {
|
||||
st.saved = st.doc.clone();
|
||||
let ext = st.doc.extents(&cadcraft_doc::Space::Model);
|
||||
if !ext.is_empty() {
|
||||
st.set_view_quiet(crate::View { center: ext.center(), height: (ext.height().max(ext.width() * px.1 / px.0.max(1.0)) * 1.1).max(1e-6) });
|
||||
// Fit in the frame of the saved twist, which open_drawing has restored.
|
||||
let twist = st.view().twist;
|
||||
let (center, w, h) = crate::View::twisted_box(ext, twist);
|
||||
st.set_view_quiet(crate::View { center, height: (h.max(w * px.1 / px.0.max(1.0)) * 1.1).max(1e-6), twist });
|
||||
}
|
||||
echo_all(s, &out);
|
||||
}
|
||||
|
||||
/// The file a reference saved as `path` in a drawing at `host` is read from, if there is one
|
||||
/// (xrefs and raster images look in the same places).
|
||||
pub(crate) fn find_file(path: &str, host: Option<&str>) -> Option<std::path::PathBuf> {
|
||||
load::find(path, host.map(std::path::Path::new), false).ok()
|
||||
}
|
||||
|
||||
/// The open drawing's path (xref paths are relative to its folder).
|
||||
fn host(s: &Session) -> Option<String> {
|
||||
s.state().ok().and_then(|st| st.path.clone())
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! loaded further up (a circular reference) is not loaded again. Every failure leaves the xref's
|
||||
//! block empty with a status saying why; nothing here stops the host drawing from opening.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -339,14 +340,15 @@ fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &
|
||||
let (status, detail) = match fetch(&path, host, chosen, cx, depth) {
|
||||
Ok((x, file)) => match import(d, &key, &x, cx) {
|
||||
Ok(()) => (XrefStatus::Loaded, file.to_string_lossy().into_owned()),
|
||||
Err(e) => (XrefStatus::Unresolved, e),
|
||||
Err(e) => {
|
||||
// Whatever made it in before the failure goes again. A fetch that failed imported
|
||||
// nothing, so it doesn't pay for a second scan of the block table.
|
||||
clear(d, &key);
|
||||
(XrefStatus::Unresolved, e)
|
||||
}
|
||||
},
|
||||
Err(e) => e,
|
||||
};
|
||||
if status != XrefStatus::Loaded {
|
||||
// Whatever made it in before the failure goes again.
|
||||
clear(d, &key);
|
||||
}
|
||||
set_status(d, &key, status);
|
||||
(status, detail)
|
||||
}
|
||||
@@ -390,9 +392,15 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
return Err(format!("more than {MAX_XREF_OBJECTS} objects"));
|
||||
}
|
||||
let dep = |sym: Sym, n: &str| (!shared(sym, n)).then(|| format!("{key}|{n}"));
|
||||
// The host's names per table, looked up in a set built once: both drawings are hostile input,
|
||||
// so a scan per imported record would be quadratic.
|
||||
fn names<T>(all: &[T], name: fn(&T) -> &str) -> HashSet<String> {
|
||||
all.iter().map(|t| name(t).to_ascii_uppercase()).collect()
|
||||
}
|
||||
let mut have = names(&d.layers, |l| &l.name);
|
||||
for l in &x.layers {
|
||||
if let Some(n) = dep(Sym::Layer, &l.name)
|
||||
&& d.layer(&n).is_none()
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
let mut l = l.clone();
|
||||
l.name = n;
|
||||
@@ -400,39 +408,44 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
|
||||
d.layers.push(l);
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.linetypes, |t| &t.name);
|
||||
for t in &x.linetypes {
|
||||
if let Some(n) = dep(Sym::Linetype, &t.name)
|
||||
&& d.linetype(&n).is_none()
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
d.linetypes.push(cadcraft_doc::Linetype { name: n, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.text_styles, |t| &t.name);
|
||||
for t in &x.text_styles {
|
||||
if let Some(n) = dep(Sym::Style, &t.name)
|
||||
&& d.text_style(&n).is_none()
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
d.text_styles.push(cadcraft_doc::TextStyle { name: n, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.dim_styles, |t| &t.name);
|
||||
for t in &x.dim_styles {
|
||||
if let Some(n) = dep(Sym::DimStyle, &t.name)
|
||||
&& d.dim_style(&n).is_none()
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
|
||||
d.dim_styles.push(cadcraft_doc::DimStyle { name: n, text_style, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.mleader_styles, |t| &t.name);
|
||||
for t in &x.mleader_styles {
|
||||
if let Some(n) = dep(Sym::MLeaderStyle, &t.name)
|
||||
&& !d.mleader_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
|
||||
d.mleader_styles.push(cadcraft_doc::MLeaderStyle { name: n, text_style, ..t.clone() });
|
||||
}
|
||||
}
|
||||
let mut have = names(&d.table_styles, |t| &t.name);
|
||||
for t in &x.table_styles {
|
||||
if let Some(n) = dep(Sym::TableStyle, &t.name)
|
||||
&& !d.table_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
|
||||
&& have.insert(n.to_ascii_uppercase())
|
||||
{
|
||||
d.table_styles.push(cadcraft_doc::TableStyle { name: n, ..t.clone() });
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! The clipboard can also travel as DXF text ([`system_text`], [`load_system_text`]), so a front
|
||||
//! end can put it on the system clipboard and paste it into another CADCraft window.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, HVal, Handle};
|
||||
@@ -279,20 +279,14 @@ fn defs_for(src: &Drawing, ents: &[Entity]) -> Drawing {
|
||||
/// Add `defs`' table records and blocks that `d` doesn't have. Returns the blocks copied, from
|
||||
/// their name in `defs` to their name in `d`.
|
||||
fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
fn add<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
|
||||
for t in theirs {
|
||||
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
||||
ours.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::cmd::merge::merge_named as add;
|
||||
add(&mut d.layers, &defs.layers, |l| &l.name);
|
||||
add(&mut d.linetypes, &defs.linetypes, |l| &l.name);
|
||||
add(&mut d.text_styles, &defs.text_styles, |t| &t.name);
|
||||
add(&mut d.dim_styles, &defs.dim_styles, |t| &t.name);
|
||||
add(&mut d.mleader_styles, &defs.mleader_styles, |t| &t.name);
|
||||
add(&mut d.table_styles, &defs.table_styles, |t| &t.name);
|
||||
let renames = block_names(d, defs);
|
||||
let renames = crate::cmd::merge::block_renames(d, defs, None);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = defs.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -308,34 +302,6 @@ fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
renames
|
||||
}
|
||||
|
||||
/// Which blocks of `defs` to copy, and under which name: a new name keeps its own, a generated
|
||||
/// (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named block `d`
|
||||
/// already has keeps `d`'s definition (not copied).
|
||||
fn block_names(d: &Drawing, defs: &Drawing) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| up(k)).collect();
|
||||
for (name, b) in &defs.blocks {
|
||||
let upper = up(name);
|
||||
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
||||
continue;
|
||||
}
|
||||
let to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
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(&up(n))) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(up(&to));
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of a clipboard object for `d`: a fresh handle, block references renamed, and links to
|
||||
/// objects of the source drawing (associative dimension points) dropped.
|
||||
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! 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::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_color::Rgb;
|
||||
@@ -55,11 +55,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.alias(&["printpdf", "pdfb"])
|
||||
.params("{path?: \"DIR/NAME^NAME2.pdf\" (default: the drawing's file; a `^` splits the name into one book name per row of frames; else returns base64 `data` per book), names?: [book names], frameHeight?: mm (default 297), unit?: mm per drawing unit (default 1)} → {books: [{name, pages, bytes, path? | data}], skipped}: closed polylines of the frame height in model space are sheet frames; each row (same lower edge, bottom to top) is one PDF, its frames left to right one page each on the paper (ISO A-series, either orientation) they match")
|
||||
.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()))
|
||||
CommandSpec::new("attach", "Attach", |s, p| match str_param(p, "path").map(extension) {
|
||||
Some(e) if super::image::is_image_ext(&e) => super::image::run_imageattach(s, p),
|
||||
_ => Err(EngineError::Other("ATTACH is not available yet for this file: only raster images (PNG, JPEG, BMP, TIFF; see IMAGEATTACH) can be attached, PDF/DWF/DGN underlays can't be shown yet".into())),
|
||||
})
|
||||
.params("not available yet")
|
||||
.noundo(),
|
||||
.params("{path: image file, …} as IMAGEATTACH; other file types are not available yet"),
|
||||
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()))
|
||||
})
|
||||
@@ -166,7 +166,7 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
|
||||
let space = s.space();
|
||||
let d = s.doc_mut()?;
|
||||
let (layers, styles) = merge_tables(d, src);
|
||||
let renames = block_names(d, src);
|
||||
let renames = crate::cmd::merge::block_renames(d, src, None);
|
||||
for (from, to) in &renames {
|
||||
let Some(b) = src.blocks.get(from) else { continue };
|
||||
let mut nb = (**b).clone();
|
||||
@@ -204,8 +204,7 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
|
||||
.map_err(|e| bad("import", format!("{_path}: {e}")))
|
||||
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`"))
|
||||
@@ -214,16 +213,7 @@ fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
/// 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
|
||||
}
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
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)
|
||||
@@ -233,35 +223,6 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
|
||||
(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 {
|
||||
|
||||
@@ -24,7 +24,7 @@ pub use template::{qnew_template, set_qnew_template};
|
||||
/// endless device must be rejected before it exhausts memory (CWE-400), so native opens read
|
||||
/// through [`crate::capped_read`] rather than buffering to EOF.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) const MAX_DRAWING_BYTES: usize = 512 << 20;
|
||||
const MAX_DRAWING_BYTES: usize = 512 << 20;
|
||||
/// Largest `.scr` script accepted from a local path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_SCRIPT_BYTES: usize = 64 << 20;
|
||||
@@ -70,6 +70,19 @@ pub fn entity_codec() -> Option<&'static EntityCodec> {
|
||||
/// COMPARE (drawing comparison).
|
||||
pub mod compare;
|
||||
|
||||
/// Decode a raster image file (PNG, JPEG, BMP, TIFF) to pixels: installed by the app with the
|
||||
/// image reader, a lower layer.
|
||||
pub type ImageHook = fn(&[u8]) -> std::result::Result<cadcraft_doc::Raster, String>;
|
||||
|
||||
static IMAGE: OnceLock<ImageHook> = OnceLock::new();
|
||||
|
||||
pub fn set_image_decoder(h: ImageHook) {
|
||||
let _ = IMAGE.set(h);
|
||||
}
|
||||
pub fn image_decoder() -> Option<ImageHook> {
|
||||
IMAGE.get().copied()
|
||||
}
|
||||
|
||||
/// Hand finished file bytes to the user where there is no file system (the browser: a download).
|
||||
/// Arguments: file name, MIME type, bytes. `Ok` only once the delivery was actually started.
|
||||
pub type DeliverHook = fn(&str, &str, &[u8]) -> std::result::Result<(), String>;
|
||||
@@ -311,6 +324,7 @@ pub fn finish_open(s: &mut Session, f: ReadFile) -> Result<Value> {
|
||||
let ReadFile { drawing, binary_dxf, name, path } = f;
|
||||
let i = add_read(s, drawing, binary_dxf, &name, path);
|
||||
super::blocks::xref::after_open(s, i);
|
||||
super::image::after_open(s, i);
|
||||
Ok(opened(s, i, true))
|
||||
}
|
||||
|
||||
@@ -320,6 +334,12 @@ fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile
|
||||
// A file that isn't a drawing (an imported SVG) gives the drawing no file name: QSAVE then
|
||||
// asks where to save instead of exporting over the original.
|
||||
let path = path.filter(|p| is_drawing_file(p));
|
||||
// The SHX fonts its text styles name are looked for beside the drawing too.
|
||||
if let Some(dir) = path.as_deref().and_then(|p| std::path::Path::new(p).parent()) {
|
||||
// A bare file name is in the current folder.
|
||||
let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir };
|
||||
cadcraft_fonts::shx::add_drawing_dir(&std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf()));
|
||||
}
|
||||
Ok(ReadFile { drawing, binary_dxf: cadcraft_dxf::is_binary(bytes), name: name.to_string(), path })
|
||||
}
|
||||
|
||||
@@ -363,6 +383,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;
|
||||
let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;
|
||||
super::blocks::xref::after_open(s, i);
|
||||
super::image::after_open(s, i);
|
||||
return Ok(opened(s, i, true));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -373,6 +394,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").unwrap_or("Drawing.dxf");
|
||||
let i = open_bytes(s, &bytes, name, None)?;
|
||||
super::blocks::xref::after_open(s, i);
|
||||
super::image::after_open(s, i);
|
||||
Ok(opened(s, i, false))
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ fn other_drawing(s: &Session, p: &Value) -> Result<(Arc<Drawing>, String)> {
|
||||
fn read_path(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), super::MAX_DRAWING_BYTES).map_err(|e| bad("compare", format!("{_path}: {e}")))
|
||||
std::fs::read(_path).map_err(|e| bad("compare", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("compare", "paths are not available on the web: open the other drawing and compare with it by name (`document`)"))
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::color::{Color, Rgb};
|
||||
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, MAX_BLOCK_DEPTH, TableStyle};
|
||||
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, Layer, MAX_BLOCK_DEPTH, TableStyle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::super::{CommandSpec, bad};
|
||||
@@ -197,6 +197,7 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
|
||||
d.skipped_records.clear();
|
||||
d.handseed = d.handseed.max(other.handseed);
|
||||
|
||||
use crate::cmd::merge::merge_named as merge;
|
||||
merge(&mut d.layers, &other.layers, |t| &t.name);
|
||||
merge(&mut d.linetypes, &other.linetypes, |t| &t.name);
|
||||
merge(&mut d.text_styles, &other.text_styles, |t| &t.name);
|
||||
@@ -235,22 +236,17 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
|
||||
Snapshot { drawing: d, current, compared, counts: (here.len(), there.len()), truncated: diff.truncated, blocks_left_out, uncolored }
|
||||
}
|
||||
|
||||
/// Add the entries of `from` whose names `to` lacks (compared without letter case).
|
||||
fn merge<T: Clone>(to: &mut Vec<T>, from: &[T], name: impl Fn(&T) -> &String) {
|
||||
for t in from {
|
||||
if !to.iter().any(|x| name(x).eq_ignore_ascii_case(name(t))) {
|
||||
to.push(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add copies of `objs` to model space with new handles, their block references renamed by
|
||||
/// `renames` and their files rebased on `folder`, each painted in its comparison color, and
|
||||
/// group them as `name` (or `name_2`… when that group name is taken). Returns the group's name.
|
||||
fn add_group(d: &mut Drawing, name: &str, objs: &[(&Entity, Color)], renames: &Renames, folder: Option<&Path>, tinted: &mut Tinted) -> String {
|
||||
let mut members = Vec::new();
|
||||
// The layers this drawing has, looked up once per object rather than scanned.
|
||||
let mut layers: HashSet<String> = d.layers.iter().map(|l| l.name.to_ascii_uppercase()).collect();
|
||||
for (e, color) in objs {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
if layers.insert(e.common.layer.to_ascii_uppercase()) {
|
||||
d.layers.push(Layer::new(&e.common.layer));
|
||||
}
|
||||
let h = d.new_handle();
|
||||
let mut copy = Entity { handle: h, ..(*e).clone() };
|
||||
detach(&mut copy.kind);
|
||||
|
||||
@@ -59,7 +59,8 @@ pub(super) fn run_new(s: &mut Session, p: &Value) -> Result<Option<Value>> {
|
||||
&& hi.y > lo.y
|
||||
{
|
||||
let center = cadcraft_geom::Vec2::new((lo.x + hi.x) / 2.0, (lo.y + hi.y) / 2.0);
|
||||
st.set_view_quiet(crate::View { center, height: (hi.y - lo.y).max(1e-6) });
|
||||
let twist = st.view().twist;
|
||||
st.set_view_quiet(crate::View { center, height: (hi.y - lo.y).max(1e-6), twist });
|
||||
}
|
||||
Ok(Some(json!({ "index": i, "title": title, "template": path })))
|
||||
}
|
||||
@@ -73,7 +74,7 @@ fn read_template(path: &str) -> Result<Drawing> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_bytes(path: &str) -> Result<Vec<u8>> {
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), super::MAX_DRAWING_BYTES).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
std::fs::read(path).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Raster images (IMAGEATTACH, IMAGE). An IMAGE entity names its file; the pixels are decoded
|
||||
//! when the drawing opens or the image is attached and kept with the open drawing
|
||||
//! ([`Drawing::rasters`]), never in the file. An image whose file can't be found or read stays in
|
||||
//! the drawing as a crossed-out frame.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Image, Raster};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::file::base64_decode;
|
||||
use super::{CommandSpec, bad, f64_or, point_param, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Most image files loaded per drawing.
|
||||
const MAX_IMAGE_FILES: usize = 1000;
|
||||
/// Largest image file read (bytes).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_IMAGE_BYTES: u64 = 256 << 20;
|
||||
/// Most decoded pixel bytes kept per drawing; images past it stay unloaded.
|
||||
const MAX_LOADED_BYTES: u64 = 2 << 30;
|
||||
/// Most image files decoded at the same time when a drawing opens.
|
||||
const MAX_LOADERS: usize = 4;
|
||||
/// Longest image path taken from a caller.
|
||||
const MAX_PATH: usize = 4096;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("imageattach", "Raster Image Reference...", run_imageattach).alias(&["iat"]).params(
|
||||
"{path: PNG/JPEG/BMP/TIFF file (saved as given; a relative path is relative to this drawing's folder), data?: base64 file contents \
|
||||
(instead of reading `path`), at?: [x,y] lower-left corner (default 0,0), scale?: number (default 1: the image is one drawing unit \
|
||||
wide), width?: width in drawing units (instead of `scale`), rotation? (degrees), name? (default: the file name)} → {name, handle, \
|
||||
path, pixels: [w, h], size: [w, h]}",
|
||||
),
|
||||
CommandSpec::new("image", "Raster Images", run_image)
|
||||
.alias(&["im"])
|
||||
.params("{} → {images: [{name, path, foundAt, status: Loaded|Not Found|Not loaded, references, pixels?: [w, h]}]}")
|
||||
.noundo(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether `ext` (lower case, no dot) is an image file type IMAGEATTACH reads.
|
||||
pub(crate) fn is_image_ext(ext: &str) -> bool {
|
||||
matches!(ext, "png" | "jpg" | "jpeg" | "bmp" | "tif" | "tiff")
|
||||
}
|
||||
|
||||
/// Every IMAGE entity of `d`: in model space, on the layouts and in the blocks.
|
||||
fn images(d: &Drawing) -> impl Iterator<Item = &Image> {
|
||||
let model = std::iter::once(&d.model);
|
||||
let layouts = d.layouts.iter().map(|l| &l.entities);
|
||||
let blocks = d.blocks.values().map(|b| &b.entities);
|
||||
model.chain(layouts).chain(blocks).flat_map(|store| store.iter()).filter_map(|e| match &e.kind {
|
||||
EntityKind::Image(i) => Some(i),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The image files `d` names, each once, in the order they are first used.
|
||||
fn paths(d: &Drawing) -> Vec<String> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut paths = Vec::new();
|
||||
for i in images(d) {
|
||||
if !i.path.trim().is_empty() && seen.insert(i.path.as_str()) {
|
||||
paths.push(i.path.clone());
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
/// The contents of the image file saved as `path` in a drawing at `host`.
|
||||
fn read_file(path: &str, host: Option<&str>) -> std::result::Result<Vec<u8>, String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let file = super::blocks::xref::find_file(path, host).ok_or("file not found")?;
|
||||
let len = std::fs::metadata(&file).map_err(|e| e.to_string())?.len();
|
||||
if len > MAX_IMAGE_BYTES {
|
||||
return Err(format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20));
|
||||
}
|
||||
// Read no more than the limit: the file can grow or change after the check above.
|
||||
let limit = usize::try_from(MAX_IMAGE_BYTES).unwrap_or(usize::MAX);
|
||||
crate::capped_read::read_capped(&file, limit).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::InvalidData { format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20) } else { e.to_string() }
|
||||
})
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (path, host);
|
||||
Err("files are not available on the web".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode an image file. A decoder that panics on a damaged file fails this image only: the
|
||||
/// drawing it belongs to must still open.
|
||||
fn decode(bytes: &[u8]) -> std::result::Result<Raster, String> {
|
||||
let hook = super::file::image_decoder().ok_or("image files can't be read in this build")?;
|
||||
match std::panic::catch_unwind(|| hook(bytes)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err("the file is damaged and can't be read".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes_of(r: &Raster) -> u64 {
|
||||
u64::try_from(r.rgba().len()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Load the images of drawing `i`, just opened. Those that can't be loaded are reported and
|
||||
/// stay crossed-out frames; opening never fails over an image.
|
||||
pub(crate) fn after_open(s: &mut Session, i: usize) {
|
||||
let Some(st) = s.docs.get_mut(i) else { return };
|
||||
let paths = paths(&st.doc);
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut rasters = st.doc.rasters.clone();
|
||||
let notes = load_missing(&paths, st.path.as_deref(), &mut rasters);
|
||||
if !rasters.is_empty() {
|
||||
// Loading pixels is not a change to the drawing. A drawing counts as unchanged while
|
||||
// it is the very one that was saved, so the saved one follows.
|
||||
let unchanged = Arc::ptr_eq(&st.doc, &st.saved);
|
||||
Arc::make_mut(&mut st.doc).rasters = rasters;
|
||||
if unchanged {
|
||||
st.saved = st.doc.clone();
|
||||
}
|
||||
}
|
||||
for n in notes {
|
||||
s.echo(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read and decode into `rasters` the files of `paths` that are not in it yet, for a drawing at
|
||||
/// `host`. Returns one message per file that could not be loaded.
|
||||
fn load_missing(paths: &[String], host: Option<&str>, rasters: &mut cadcraft_doc::Rasters) -> Vec<String> {
|
||||
let mut notes = Vec::new();
|
||||
let mut missing: Vec<&str> = paths.iter().map(String::as_str).filter(|p| rasters.get(p).is_none()).collect();
|
||||
if missing.len() > MAX_IMAGE_FILES {
|
||||
missing.truncate(MAX_IMAGE_FILES);
|
||||
notes.push(format!("Images: only the first {MAX_IMAGE_FILES} image files are loaded"));
|
||||
}
|
||||
let decoded = decode_files(&missing, host);
|
||||
// Kept in the order the drawing uses them, up to the memory limit.
|
||||
let mut kept_bytes = 0u64;
|
||||
for (path, result) in missing.iter().zip(decoded) {
|
||||
match result {
|
||||
Ok(r) => {
|
||||
kept_bytes = kept_bytes.saturating_add(bytes_of(&r));
|
||||
if kept_bytes > MAX_LOADED_BYTES {
|
||||
notes.push(over_limit(path));
|
||||
break;
|
||||
}
|
||||
rasters.insert(path, Arc::new(r));
|
||||
}
|
||||
Err(e) => notes.push(format!("Image {path}: {e}")),
|
||||
}
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
fn over_limit(path: &str) -> String {
|
||||
format!("Image {path}: not loaded (more than {} MB of images)", MAX_LOADED_BYTES >> 20)
|
||||
}
|
||||
|
||||
/// Read and decode the image files `paths` of a drawing at `host`, a few at a time (decoding is
|
||||
/// what opening a drawing with large images waits for). The results are in the order of `paths`.
|
||||
fn decode_files(paths: &[&str], host: Option<&str>) -> Vec<std::result::Result<Raster, String>> {
|
||||
// Decoded bytes so far, shared by the loaders: past the limit no further file is started.
|
||||
let decoded_bytes = AtomicU64::new(0);
|
||||
let load = |path: &str| -> std::result::Result<Raster, String> {
|
||||
if decoded_bytes.load(Ordering::Relaxed) > MAX_LOADED_BYTES {
|
||||
return Err(format!("not loaded (more than {} MB of images)", MAX_LOADED_BYTES >> 20));
|
||||
}
|
||||
let r = read_file(path, host).and_then(|bytes| decode(&bytes))?;
|
||||
decoded_bytes.fetch_add(bytes_of(&r), Ordering::Relaxed);
|
||||
Ok(r)
|
||||
};
|
||||
let mut results: Vec<Option<std::result::Result<Raster, String>>> = paths.iter().map(|_| None).collect();
|
||||
let loaders = loader_count(paths.len());
|
||||
if loaders > 1 {
|
||||
// Each loader takes the next file nobody has started.
|
||||
let next = AtomicUsize::new(0);
|
||||
let work = || {
|
||||
let mut done = Vec::new();
|
||||
loop {
|
||||
let index = next.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(path) = paths.get(index) else { break };
|
||||
done.push((index, load(path)));
|
||||
}
|
||||
done
|
||||
};
|
||||
let finished: Vec<_> = std::thread::scope(|scope| {
|
||||
let started: Vec<_> = (0..loaders).filter_map(|_| std::thread::Builder::new().spawn_scoped(scope, work).ok()).collect();
|
||||
started.into_iter().filter_map(|loader| loader.join().ok()).flatten().collect()
|
||||
});
|
||||
for (index, result) in finished {
|
||||
if let Some(slot) = results.get_mut(index) {
|
||||
*slot = Some(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
// One loader, or files a loader that could not start or finish left behind.
|
||||
results.into_iter().zip(paths).map(|(result, path)| result.unwrap_or_else(|| load(path))).collect()
|
||||
}
|
||||
|
||||
/// How many files are decoded at once: one per processor, at most [`MAX_LOADERS`] (each large
|
||||
/// image needs several hundred megabytes while it is decoded) and no more than there are files.
|
||||
fn loader_count(files: usize) -> usize {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let processors = std::thread::available_parallelism().map(std::num::NonZero::get).unwrap_or(1);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let processors = 1;
|
||||
processors.min(MAX_LOADERS).min(files)
|
||||
}
|
||||
|
||||
/// The file name without folders and extension (either separator).
|
||||
fn stem(path: &str) -> String {
|
||||
let file = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
match file.rsplit_once('.') {
|
||||
Some((stem, _)) if !stem.is_empty() => stem.to_string(),
|
||||
_ => file.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The definition name for a new image of `path`: the name that file already has in `d`, else
|
||||
/// `wanted`, numbered (`wanted_2`, `wanted_3`, …) when another file has taken it.
|
||||
fn def_name(d: &Drawing, path: &str, wanted: &str) -> String {
|
||||
if let Some(i) = images(d).find(|i| i.path == path && !i.name.is_empty()) {
|
||||
return i.name.clone();
|
||||
}
|
||||
// The names other files have, compared without regard to case.
|
||||
let taken: BTreeSet<String> = images(d).filter(|i| i.path != path).map(|i| i.name.to_ascii_lowercase()).collect();
|
||||
let free = |name: &str| !taken.contains(&name.to_ascii_lowercase());
|
||||
if free(wanted) {
|
||||
return wanted.to_string();
|
||||
}
|
||||
// One of these is free: there are more candidates than names taken.
|
||||
let candidates = 2..taken.len().saturating_add(3);
|
||||
candidates.map(|n| format!("{wanted}_{n}")).find(|name| free(name)).unwrap_or_else(|| wanted.to_string())
|
||||
}
|
||||
|
||||
const ID: &str = "imageattach";
|
||||
|
||||
pub(crate) fn run_imageattach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(ID, "`path` is required"))?.to_string();
|
||||
if path.len() > MAX_PATH {
|
||||
return Err(bad(ID, "`path` is too long"));
|
||||
}
|
||||
let place = placement(p)?;
|
||||
let raster = pixels(s, p, &path)?;
|
||||
let (w, h) = (f64::from(raster.width()), f64::from(raster.height()));
|
||||
// One pixel in drawing units, along the image's bottom edge (`u`) and its left edge (`v`).
|
||||
let pixel = place.width / w;
|
||||
let u = Vec2::from_angle(place.rotation) * pixel;
|
||||
let v = u.perp();
|
||||
if !(pixel.is_normal() && (pixel * h).is_normal() && u.x.is_finite() && u.y.is_finite()) {
|
||||
return Err(bad(ID, "the image would be too small or too large to draw"));
|
||||
}
|
||||
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| stem(&path));
|
||||
let name = def_name(s.doc()?, &path, &wanted);
|
||||
let image = Image {
|
||||
insert: place.at.to3(0.0),
|
||||
u: u.to3(0.0),
|
||||
v: v.to3(0.0),
|
||||
size: Vec2::new(w, h),
|
||||
path: path.clone(),
|
||||
name: name.clone(),
|
||||
..Image::default()
|
||||
};
|
||||
s.doc_mut()?.rasters.insert(&path, raster);
|
||||
let handle = s.add_entity(EntityKind::Image(image))?;
|
||||
Ok(json!({ "name": name, "handle": handle.hex(), "path": path, "pixels": [w, h], "size": [place.width, pixel * h] }))
|
||||
}
|
||||
|
||||
/// Where and how large IMAGEATTACH puts the image.
|
||||
struct Placement {
|
||||
/// The lower-left corner.
|
||||
at: Vec2,
|
||||
/// Radians, counter-clockwise.
|
||||
rotation: f64,
|
||||
/// The image's width in drawing units.
|
||||
width: f64,
|
||||
}
|
||||
|
||||
/// `at`, `rotation` and the width (`width`, else `scale`: an image is one unit wide at scale 1).
|
||||
fn placement(p: &Value) -> Result<Placement> {
|
||||
let at = point_param(p, "at").unwrap_or(Vec2::ZERO);
|
||||
if !(at.x.is_finite() && at.y.is_finite()) {
|
||||
return Err(bad(ID, "`at` must be a point"));
|
||||
}
|
||||
let width = match p.get("width") {
|
||||
Some(v) => v.as_f64().filter(|w| w.is_finite()).ok_or_else(|| bad(ID, "`width` must be a number"))?,
|
||||
None => f64_or(p, "scale", 1.0),
|
||||
};
|
||||
if !(width.is_finite() && width > 0.0) {
|
||||
return Err(bad(ID, "`scale` and `width` must be greater than zero"));
|
||||
}
|
||||
Ok(Placement { at, rotation: f64_or(p, "rotation", 0.0).to_radians(), width })
|
||||
}
|
||||
|
||||
/// The pixels of the image at `path`: decoded from `data` when the caller hands the file over,
|
||||
/// else the ones already loaded for that path, else read from the file.
|
||||
fn pixels(s: &Session, p: &Value, path: &str) -> Result<Arc<Raster>> {
|
||||
let bytes = match p.get("data") {
|
||||
Some(data) => {
|
||||
let text = data.as_str().ok_or_else(|| bad(ID, "`data` must be base64 text"))?;
|
||||
base64_decode(text).ok_or_else(|| bad(ID, "`data` is not valid base64"))?
|
||||
}
|
||||
None => {
|
||||
if let Some(loaded) = s.doc()?.rasters.get(path) {
|
||||
return Ok(loaded.clone());
|
||||
}
|
||||
let host = s.state()?.path.clone();
|
||||
read_file(path, host.as_deref()).map_err(|e| bad(ID, format!("{path}: {e}")))?
|
||||
}
|
||||
};
|
||||
Ok(Arc::new(decode(&bytes).map_err(|e| bad(ID, format!("{path}: {e}")))?))
|
||||
}
|
||||
|
||||
fn run_image(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let host = s.state()?.path.clone();
|
||||
let d = s.doc()?;
|
||||
// Per file: its definition name and how many images show it.
|
||||
let mut uses: BTreeMap<&str, (&str, usize)> = BTreeMap::new();
|
||||
for i in images(d) {
|
||||
uses.entry(i.path.as_str()).or_insert((i.name.as_str(), 0)).1 += 1;
|
||||
}
|
||||
let rows: Vec<Value> = paths(d)
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let (name, references) = uses.get(path.as_str()).copied().unwrap_or_default();
|
||||
let mut row = describe(d, path, host.as_deref());
|
||||
row["name"] = json!(name);
|
||||
row["references"] = json!(references);
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "images": rows }))
|
||||
}
|
||||
|
||||
/// The state of image file `path` of drawing `d` (saved at `host`): where it is and whether its
|
||||
/// pixels are loaded.
|
||||
fn describe(d: &Drawing, path: &str, host: Option<&str>) -> Value {
|
||||
let loaded = d.rasters.get(path);
|
||||
let found_at = super::blocks::xref::find_file(path, host).map(|f| f.to_string_lossy().into_owned());
|
||||
let status = match (loaded, &found_at) {
|
||||
(Some(_), _) => "Loaded",
|
||||
(None, None) => "Not Found",
|
||||
// The file is there but could not be read as an image.
|
||||
(None, Some(_)) => "Not loaded",
|
||||
};
|
||||
json!({ "path": path, "foundAt": found_at, "status": status, "pixels": loaded.map(|r| json!([r.width(), r.height()])) })
|
||||
}
|
||||
@@ -408,12 +408,24 @@ fn run_state_restore(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("layerstate.restore", "`name` is required"))?;
|
||||
let d = s.doc_mut()?;
|
||||
let st = d.layer_states.iter().find(|st| st.name == name).cloned().ok_or_else(|| bad("layerstate.restore", "no such layer state"))?;
|
||||
for saved in st.layers {
|
||||
if let Some(l) = d.layer_mut(&saved.name) {
|
||||
*l = saved;
|
||||
restore_layers(d, st.layers);
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Put the saved layers back over the drawing's layers of the same name (without regard to case);
|
||||
/// saved layers the drawing no longer has are skipped. Both lists can come from a file, so the
|
||||
/// drawing's layers are looked up in an index rather than scanned per saved layer.
|
||||
pub(crate) fn restore_layers(d: &mut cadcraft_doc::Drawing, saved: Vec<Layer>) {
|
||||
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::with_capacity(d.layers.len());
|
||||
for (i, l) in d.layers.iter().enumerate() {
|
||||
// The first of names that differ only in case, as `Drawing::layer_mut` finds it.
|
||||
index.entry(l.name.to_ascii_lowercase()).or_insert(i);
|
||||
}
|
||||
for layer in saved {
|
||||
if let Some(l) = index.get(&layer.name.to_ascii_lowercase()).and_then(|&i| d.layers.get_mut(i)) {
|
||||
*l = layer;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn run_state_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -461,6 +473,22 @@ mod tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Restoring many saved layers, most of them missing, takes one pass and matches without case.
|
||||
#[test]
|
||||
fn restore_layers_matches_by_name_in_linear_time() {
|
||||
let mut d = cadcraft_doc::Drawing::new_imperial();
|
||||
d.layers = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("L{i}"))).collect();
|
||||
let mut saved: Vec<cadcraft_doc::Layer> = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("missing{i}"))).collect();
|
||||
let mut back = cadcraft_doc::Layer::new("l7");
|
||||
back.frozen = true;
|
||||
saved.push(back);
|
||||
let started = std::time::Instant::now();
|
||||
super::restore_layers(&mut d, saved);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
assert!(d.layer("L7").is_some_and(|l| l.frozen));
|
||||
assert_eq!(d.layers.len(), 20_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_states_save_list_restore_delete() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -524,6 +524,7 @@ fn viewport_json(h: Handle, v: &Viewport) -> Value {
|
||||
"height": v.height,
|
||||
"viewCenter": [v.view_center.x, v.view_center.y],
|
||||
"viewHeight": v.view_height,
|
||||
"twist": v.view_twist().to_degrees(),
|
||||
"scale": if v.view_height > 0.0 { v.height / v.view_height } else { 0.0 },
|
||||
"locked": v.locked,
|
||||
"on": v.on,
|
||||
|
||||
@@ -303,9 +303,11 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Some(_path)) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let bytes = crate::capped_read::read_capped(std::path::Path::new(_path), cadcraft_doc::MAX_TABLE_FILE)
|
||||
.map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
String::from_utf8(bytes).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
|
||||
let meta = std::fs::metadata(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
if meta.len() > cadcraft_doc::MAX_TABLE_FILE as u64 {
|
||||
return Err(bad(cmd, format!("{_path}: the plot style table file is too large")));
|
||||
}
|
||||
std::fs::read_to_string(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad(cmd, "files can't be read here: pass `text`"));
|
||||
|
||||
@@ -283,7 +283,7 @@ fn mspace_edits_model_through_viewport() {
|
||||
s.cmdline("circle 10,10 3").unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), before + 1);
|
||||
// Zooming changes the viewport's model view, not the sheet.
|
||||
s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0 });
|
||||
s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0, twist: v.twist });
|
||||
let vp2 = viewports(&s, "L").into_iter().find(|(x, _)| *x == h).unwrap().1;
|
||||
assert!((vp2.view_height - vp.view_height / 2.0).abs() < 1e-9);
|
||||
assert_eq!(s.state().unwrap().paper_view(), pv);
|
||||
|
||||
@@ -329,7 +329,7 @@ pub(super) fn run_vpclip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let k = cur.view_height / cur.height.max(1e-12);
|
||||
let mut v = cur;
|
||||
let c = b.center();
|
||||
let view_center = v.view_center + (c - v.center.xy()) * k;
|
||||
let view_center = v.paper_to_model(c);
|
||||
let view_height = b.height() * k;
|
||||
if view_center.is_finite() && view_height.is_finite() && view_height > 0.0 {
|
||||
v.view_center = view_center;
|
||||
|
||||
@@ -109,7 +109,7 @@ fn parse_color(t: &str) -> Result<Color> {
|
||||
fn viewport_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
let st = s.state().ok()?;
|
||||
let at = match st.active_viewport() {
|
||||
Some((_, vp)) => vp.center.xy() + (p - vp.view_center) * (vp.height / vp.view_height),
|
||||
Some((_, vp)) => vp.model_to_paper(p),
|
||||
None => p,
|
||||
};
|
||||
super::viewports::viewport_at(s, at)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Bringing another drawing's symbol tables and blocks into this one (INSERT of a file, IMPORT,
|
||||
//! paste, COMPARE export).
|
||||
//!
|
||||
//! Both drawings are hostile input, so the work is linear in their sizes: names are looked up in a
|
||||
//! set built once per table, and a generated block name continues from the last one allocated for
|
||||
//! its prefix instead of probing every taken number again (CWE-407).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
use cadcraft_doc::Drawing;
|
||||
|
||||
/// Add the entries of `theirs` whose names `ours` lacks (compared without letter case; a name that
|
||||
/// exists keeps `ours`' definition). Returns how many were added.
|
||||
pub(crate) fn merge_named<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
|
||||
let mut have: HashSet<String> = ours.iter().map(|o| name(o).to_ascii_uppercase()).collect();
|
||||
let mut added = 0;
|
||||
for t in theirs {
|
||||
if have.insert(name(t).to_ascii_uppercase()) {
|
||||
ours.push(t.clone());
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// Which blocks of `src` to copy into `d`, and under which name: a new name keeps its own, a
|
||||
/// generated (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named
|
||||
/// block `d` already has (or `defining`, the block being defined from `src`) keeps `d`'s
|
||||
/// definition (not copied).
|
||||
pub(crate) fn block_renames(d: &Drawing, src: &Drawing, defining: Option<&str>) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
taken.extend(defining.map(str::to_ascii_uppercase));
|
||||
// The next number to try per generated prefix: numbers below it are taken.
|
||||
let mut next: HashMap<String, usize> = HashMap::new();
|
||||
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 to = if !taken.contains(&upper) {
|
||||
name.clone()
|
||||
} else if b.anonymous || name.starts_with('*') {
|
||||
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
let cursor = next.entry(prefix.to_ascii_uppercase()).or_insert(1);
|
||||
match (*cursor..=1_000_000).map(|i| (i, format!("{prefix}{i}"))).find(|(_, n)| !taken.contains(&n.to_ascii_uppercase())) {
|
||||
Some((i, n)) => {
|
||||
*cursor = i + 1;
|
||||
n
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
taken.insert(to.to_ascii_uppercase());
|
||||
out.insert(name.clone(), to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Layer};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merge_keeps_ours_and_adds_the_rest_without_regard_to_case() {
|
||||
let mut ours = vec![Layer::new("Walls")];
|
||||
let theirs = vec![Layer::new("WALLS"), Layer::new("Doors"), Layer::new("doors")];
|
||||
assert_eq!(merge_named(&mut ours, &theirs, |l| &l.name), 1);
|
||||
assert_eq!(ours.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Walls", "Doors"]);
|
||||
}
|
||||
|
||||
/// Many colliding anonymous blocks get consecutive names in one pass, not a rescan each.
|
||||
#[test]
|
||||
fn anonymous_collisions_take_consecutive_numbers() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut src = Drawing::new_imperial();
|
||||
for i in 1..=5000 {
|
||||
let mut b = Block::new(&format!("*U{i}"));
|
||||
b.anonymous = true;
|
||||
d.blocks.insert(b.name.clone(), Arc::new(b.clone()));
|
||||
src.blocks.insert(b.name.clone(), Arc::new(b));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let renames = block_renames(&d, &src, None);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
assert_eq!(renames.len(), 5000);
|
||||
assert_eq!(renames.get("*U1").map(String::as_str), Some("*U5001"));
|
||||
let distinct: HashSet<&String> = renames.values().collect();
|
||||
assert_eq!(distinct.len(), 5000);
|
||||
// Every new name is the next free number: nothing below 5001 is free, nothing above 10000 is needed.
|
||||
assert!(renames.values().all(|n| n.strip_prefix("*U").and_then(|i| i.parse::<u32>().ok()).is_some_and(|i| (5001..=10000).contains(&i))));
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,10 @@ fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::
|
||||
if let Some(items) = crate::cmd::array::explode_array(d, e) {
|
||||
return Some(items);
|
||||
}
|
||||
// A MINSERT grid stays whole, as in AutoCAD 2024 (see `explode`).
|
||||
if minserted(ins) {
|
||||
return None;
|
||||
}
|
||||
let blk = d.block(&ins.block)?;
|
||||
let m = ins.transform(blk.base.xy());
|
||||
let stretched = m.similarity_scale().is_none();
|
||||
@@ -223,9 +227,24 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
}
|
||||
|
||||
pub(super) fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
|
||||
// AutoCAD 2024 refuses a MINSERT grid: "The object is minserted." (Locked ones are left
|
||||
// without a word, as `explode_with` leaves them.)
|
||||
let d = s.doc()?;
|
||||
let grids = hs
|
||||
.iter()
|
||||
.filter(|h| !crate::cmd::curves::is_locked(s, **h) && d.entity(**h).is_some_and(|e| matches!(&e.kind, EntityKind::Insert(i) if minserted(i))))
|
||||
.count();
|
||||
for _ in 0..grids {
|
||||
s.echo("The object is minserted.");
|
||||
}
|
||||
explode_with(s, hs, explode_kind)
|
||||
}
|
||||
|
||||
/// A block reference with more than one row or column (MINSERT).
|
||||
fn minserted(i: &cadcraft_doc::Insert) -> bool {
|
||||
i.rows > 1 || i.cols > 1
|
||||
}
|
||||
|
||||
/// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives
|
||||
/// none for are left alone. EXPLODE, and BURST with its own pieces for block references.
|
||||
pub(crate) fn explode_with(
|
||||
|
||||
@@ -182,8 +182,7 @@ fn chspace(s: &mut Session, hs: &[Handle], to: Option<&str>, vp: Option<Handle>)
|
||||
};
|
||||
let m = match viewport_of(s, &layout, vp) {
|
||||
Some(v) => {
|
||||
let k = v.view_height / v.height;
|
||||
let to_paper = Mat3::translate(v.center.xy()).then_before(Mat3::scale(1.0 / k, 1.0 / k)).then_before(Mat3::translate(-v.view_center));
|
||||
let to_paper = v.model_to_paper_matrix();
|
||||
if target == Space::Model { to_paper.inverse().unwrap_or(Mat3::IDENTITY) } else { to_paper }
|
||||
}
|
||||
None => Mat3::IDENTITY,
|
||||
|
||||
@@ -411,6 +411,7 @@ fn chspace_json_and_command_line() {
|
||||
layer_colors: Vec::new(),
|
||||
clip: None,
|
||||
on: true,
|
||||
twist: 0.0,
|
||||
};
|
||||
s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Default::default(), EntityKind::Viewport(vp)).unwrap();
|
||||
let r = s.execute("chspace", &json!({"handles": [l]})).unwrap();
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Largest linked file read (bytes).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_LINK_BYTES: usize = 8 << 20;
|
||||
const MAX_LINK_BYTES: u64 = 8 << 20;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -238,9 +238,11 @@ fn read_link(id: &str, file: &std::path::Path) -> Result<String> {
|
||||
let path = file.display();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// The limit is enforced on the bytes read, not on reported metadata, so a device or FIFO
|
||||
// that misreports its length can't be buffered to EOF.
|
||||
let bytes = crate::capped_read::read_capped(file, MAX_LINK_BYTES).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
let meta = std::fs::metadata(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
if meta.len() > MAX_LINK_BYTES {
|
||||
return Err(bad(id, format!("{path}: larger than {} MB", MAX_LINK_BYTES >> 20)));
|
||||
}
|
||||
let bytes = std::fs::read(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned())
|
||||
}
|
||||
@@ -368,16 +370,6 @@ fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A linked path naming an endless device is refused instead of buffered to EOF.
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn link_to_endless_device_is_rejected() {
|
||||
let dev = std::path::Path::new("/dev/zero");
|
||||
if dev.exists() {
|
||||
assert!(read_link("datalink", dev).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_parsing() {
|
||||
assert_eq!(parse_csv("a,b\r\n\"c,d\",\"e\"\"f\"\n", ','), vec![vec!["a", "b"], vec!["c,d", "e\"f"]]);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! The CAL / QuickCalc evaluator: numbers and points (`[x,y]`, `[d<a]`), arithmetic and vector
|
||||
//! arithmetic, numeric and geometric functions; unit conversion; values picked from the drawing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -67,6 +69,9 @@ struct Parser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
vars: &'a Map<String, Value>,
|
||||
/// `vars` by ASCII-lower-cased name (the first of names that differ only in case), so a
|
||||
/// case-insensitive lookup is not a scan of the map per reference.
|
||||
folded: HashMap<String, &'a Value>,
|
||||
depth: usize,
|
||||
unary_depth: usize,
|
||||
}
|
||||
@@ -230,7 +235,7 @@ impl Parser<'_> {
|
||||
match name.as_str() {
|
||||
"pi" => Ok(Val::Num(std::f64::consts::PI)),
|
||||
"e" => Ok(Val::Num(std::f64::consts::E)),
|
||||
_ => var(self.vars, &name).ok_or_else(|| format!("unknown name `{name}`")),
|
||||
_ => var(self.vars, &self.folded, &name).ok_or_else(|| format!("unknown name `{name}`")),
|
||||
}
|
||||
}
|
||||
_ => Err("expected a number".into()),
|
||||
@@ -239,8 +244,8 @@ impl Parser<'_> {
|
||||
}
|
||||
|
||||
/// A variable: a number, or a point as `[x, y]`. Names are case-insensitive.
|
||||
fn var(vars: &Map<String, Value>, name: &str) -> Option<Val> {
|
||||
let v = vars.get(name).or_else(|| vars.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))?;
|
||||
fn var(vars: &Map<String, Value>, folded: &HashMap<String, &Value>, name: &str) -> Option<Val> {
|
||||
let v = vars.get(name).or_else(|| folded.get(&name.to_ascii_lowercase()).copied())?;
|
||||
if let Some(n) = v.as_f64() {
|
||||
return Some(Val::Num(n));
|
||||
}
|
||||
@@ -411,7 +416,11 @@ pub fn eval_val(expr: &str, vars: &Map<String, Value>) -> Result<Val, String> {
|
||||
if expr.len() > MAX_LEN {
|
||||
return Err("expression too long".into());
|
||||
}
|
||||
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0, unary_depth: 0 };
|
||||
let mut folded = HashMap::with_capacity(vars.len());
|
||||
for (k, v) in vars {
|
||||
folded.entry(k.to_ascii_lowercase()).or_insert(v);
|
||||
}
|
||||
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, folded, depth: 0, unary_depth: 0 };
|
||||
let v = p.expr()?;
|
||||
if p.peek().is_some() {
|
||||
return Err(format!("unexpected `{}`", expr.get(p.i..).unwrap_or("")));
|
||||
@@ -587,6 +596,21 @@ mod tests {
|
||||
eval_val(s, &Map::new())
|
||||
}
|
||||
|
||||
/// Variables are found without regard to case; an exact match wins over a case variant.
|
||||
/// (Names are read lower-cased, so `X` finds `x` too.)
|
||||
#[test]
|
||||
fn variables_ignore_case_and_prefer_exact_names() {
|
||||
let mut vars = Map::new();
|
||||
for i in 0..10_000 {
|
||||
vars.insert(format!("v{i}"), json!(i));
|
||||
}
|
||||
vars.insert("X".into(), json!(1));
|
||||
vars.insert("x".into(), json!(2));
|
||||
assert!(matches!(eval_val("V9999 + 1", &vars), Ok(Val::Num(n)) if n == 10_000.0));
|
||||
assert!(matches!(eval_val("x", &vars), Ok(Val::Num(n)) if n == 2.0));
|
||||
assert!(eval_val("nope", &vars).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_and_vectors() {
|
||||
assert_eq!(ev("[1,2]+[3,4]*2"), Ok(Val::Pt(Vec2::new(7.0, 10.0))));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! View menu: ZOOM (with Realtime and Dynamic), PAN, REGEN, REDRAW, named views (VIEW), layout
|
||||
//! switching.
|
||||
//! View menu: ZOOM (with Realtime and Dynamic), PAN, REGEN, REDRAW, named views (VIEW), the view
|
||||
//! twist (DVIEW TWist), layout switching.
|
||||
|
||||
use cadcraft_doc::{Space, entity_bounds};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
@@ -10,6 +10,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, f64_req, ok, point_param, point_r
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View};
|
||||
|
||||
mod dview;
|
||||
mod named_views;
|
||||
mod vports;
|
||||
|
||||
@@ -95,10 +96,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.alias(&["r"])
|
||||
.noundo()
|
||||
.transparent(),
|
||||
CommandSpec::new("view.set", "Set View", run_view_set).params("{center: [x,y], height}").noundo(),
|
||||
dview::spec(),
|
||||
CommandSpec::new("view.set", "Set View", run_view_set)
|
||||
.params("{center: [x,y], height, twist?: degrees (default: keep the current twist; in a layout viewport it is the viewport's twist, an undoable edit; not in Paper space)}")
|
||||
.noundo(),
|
||||
CommandSpec::new("view.get", "Get View", |s, _| {
|
||||
let v = s.state()?.view();
|
||||
Ok(json!({"center": [v.center.x, v.center.y], "height": v.height, "viewportPx": [s.viewport_px.0, s.viewport_px.1]}))
|
||||
Ok(json!({"center": [v.center.x, v.center.y], "height": v.height, "twist": v.twist.to_degrees(), "viewportPx": [s.viewport_px.0, s.viewport_px.1]}))
|
||||
})
|
||||
.noundo(),
|
||||
CommandSpec::new("layout.set", "Switch Layout", run_layout_set).params("{name: \"Model\" | layout name}").noundo(),
|
||||
@@ -120,8 +124,11 @@ fn fit(s: &mut Session, b: Bounds2) -> Result<()> {
|
||||
let (w, h) = s.viewport_px;
|
||||
let st = s.state_mut()?;
|
||||
let aspect = st.zoom_aspect(w / h.max(1.0));
|
||||
let height = b.height().max(b.width() / aspect.max(1e-6)).max(1e-9);
|
||||
st.set_zoom_frame(View { center: b.center(), height });
|
||||
// A twisted view fits the box the bounds make on screen.
|
||||
let twist = st.zoom_frame().twist;
|
||||
let (center, bw, bh) = View::twisted_box(b, twist);
|
||||
let height = bh.max(bw / aspect.max(1e-6)).max(1e-9);
|
||||
st.set_zoom_frame(View { center, height, twist });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -150,18 +157,18 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = if mode == "in" { 2.0 } else { 0.5 };
|
||||
let st = s.state_mut()?;
|
||||
let v = st.zoom_frame();
|
||||
st.set_zoom_frame(View { center: v.center, height: v.height / f });
|
||||
st.set_zoom_frame(View { height: v.height / f, ..v });
|
||||
}
|
||||
"center" | "c" | "dynamic" | "d" => {
|
||||
let c = point_req("zoom", p, "center")?;
|
||||
let cur = s.state()?.zoom_frame().height;
|
||||
let h = if p.get("factor").is_some() { factor_height(s, p, cur)? } else { f64_or(p, "height", cur) };
|
||||
s.state_mut()?.set_zoom_frame(View { center: c, height: h.max(1e-9) });
|
||||
let cur = s.state()?.zoom_frame();
|
||||
let h = if p.get("factor").is_some() { factor_height(s, p, cur.height)? } else { f64_or(p, "height", cur.height) };
|
||||
s.state_mut()?.set_zoom_frame(View { center: c, height: h.max(1e-9), ..cur });
|
||||
}
|
||||
"scale" | "s" => {
|
||||
let v = s.state()?.zoom_frame();
|
||||
let height = factor_height(s, p, v.height)?;
|
||||
s.state_mut()?.set_zoom_frame(View { center: v.center, height });
|
||||
s.state_mut()?.set_zoom_frame(View { height, ..v });
|
||||
}
|
||||
"realtime" => {
|
||||
let v = s.state()?.zoom_frame();
|
||||
@@ -169,7 +176,7 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if f <= 0.0 {
|
||||
return Err(bad("zoom", "factor must be positive"));
|
||||
}
|
||||
s.state_mut()?.set_zoom_frame(View { center: v.center, height: (v.height / f).clamp(1e-9, 1e12) });
|
||||
s.state_mut()?.set_zoom_frame(View { height: (v.height / f).clamp(1e-9, 1e12), ..v });
|
||||
}
|
||||
"object" | "o" => {
|
||||
let hs = targets(s, p)?;
|
||||
@@ -242,7 +249,8 @@ fn pan_frac(s: &mut Session, f: Vec2) -> Result<Value> {
|
||||
let st = s.state_mut()?;
|
||||
let v = st.view();
|
||||
let ww = v.height * w / h.max(1.0);
|
||||
st.set_view(View { center: v.center + Vec2::new(f.x * ww, f.y * v.height), height: v.height });
|
||||
// Screen directions: in a twisted view the drawing is turned by the twist.
|
||||
st.set_view(View { center: v.center + Vec2::new(f.x * ww, f.y * v.height).rotate(-v.twist), ..v });
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -258,7 +266,7 @@ fn run_pan(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = point_req("pan", p, "to")?;
|
||||
v.center - (b - a)
|
||||
};
|
||||
st.set_view(View { center: c, height: v.height });
|
||||
st.set_view(View { center: c, ..v });
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -268,9 +276,33 @@ fn run_view_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if h <= 0.0 {
|
||||
return Err(bad("view.set", "height must be positive"));
|
||||
}
|
||||
if !s.state_mut()?.set_view(View { center: c, height: h }) {
|
||||
let twist = match p.get("twist") {
|
||||
Some(t) => t.as_f64().filter(|t| t.is_finite()).ok_or_else(|| bad("view.set", "twist must be a number of degrees"))?.to_radians(),
|
||||
None => s.state()?.view().twist,
|
||||
};
|
||||
// As DVIEW TWist: the Model tab's view or the active layout viewport turns; a layout sheet
|
||||
// (paper space) is never twisted.
|
||||
let target = match dview::target(s) {
|
||||
Ok(t) => Some(t),
|
||||
Err(_) if twist == 0.0 => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let before = (s.state()?.doc.clone(), s.state()?.selection.clone());
|
||||
let twist = match target {
|
||||
Some(t @ dview::Target::Viewport(_)) => {
|
||||
dview::set_twist(s, t, twist)?;
|
||||
s.state()?.view().twist
|
||||
}
|
||||
Some(dview::Target::Model) => cadcraft_geom::norm_angle(twist),
|
||||
None => 0.0,
|
||||
};
|
||||
if !s.state_mut()?.set_view(View { center: c, height: h, twist }) {
|
||||
s.state_mut()?.doc = before.0;
|
||||
return Err(bad("view.set", "the view is too large to show. Nothing was changed"));
|
||||
}
|
||||
if let Some(dview::Target::Viewport(h)) = target {
|
||||
dview::record_viewport_edit(s, before, h)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -294,7 +326,7 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let page = st.doc.layout(n).map(|l| l.page.clone()).unwrap_or_default();
|
||||
let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) };
|
||||
let unit = if st.doc.header.i64("MEASUREMENT", 0) == 1 { 1.0 } else { 1.0 / 25.4 };
|
||||
st.set_view_quiet(View { center: Vec2::new(w * unit / 2.0, h * unit / 2.0), height: h * unit * 1.15 });
|
||||
st.set_view_quiet(View { center: Vec2::new(w * unit / 2.0, h * unit / 2.0), height: h * unit * 1.15, twist: 0.0 });
|
||||
}
|
||||
s.touch();
|
||||
ok()
|
||||
@@ -441,7 +473,7 @@ impl Interactive for ZoomM {
|
||||
if st.view_history.last() == Some(&b.screen) {
|
||||
st.view_history.pop();
|
||||
}
|
||||
st.set_zoom_frame(View { center: b.center, height: b.height });
|
||||
st.set_zoom_frame(View { center: b.center, height: b.height, twist: b.frame.twist });
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(ZoomStage::Dynamic(mut b), Input::Point(p)) => {
|
||||
@@ -565,7 +597,7 @@ impl Session {
|
||||
let f = 2f64.powf((-dy / half).clamp(-16.0, 16.0));
|
||||
let st = self.state_mut()?;
|
||||
let v = st.view();
|
||||
st.set_view_quiet(View { center: v.center, height: (v.height / f).clamp(1e-9, 1e12) });
|
||||
st.set_view_quiet(View { height: (v.height / f).clamp(1e-9, 1e12), ..v });
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
//! DVIEW: the dynamic view, on CADCraft's 2D plan views.
|
||||
//!
|
||||
//! It starts with "Select objects or <use DVIEWBLOCK>:": the objects
|
||||
//! picked are the preview set, and Enter with nothing picked previews the DVIEWBLOCK block (the
|
||||
//! drawing's own block of that name, else a built-in sample house). While DVIEW runs only the
|
||||
//! preview set is drawn ([`Interactive::display_only`]); the whole drawing is regenerated when it
|
||||
//! ends. Then it repeats "Enter option [CAmera/TArget/Distance/POints/PAn/Zoom/TWist/CLip/Hide/
|
||||
//! Off/Undo]:" until Enter. TWist, PAn, Zoom, Off and Undo work; the options that need a 3D
|
||||
//! camera say they are not available yet and stay at the option prompt.
|
||||
//!
|
||||
//! DVIEW works on the Model tab's view, or on the active (double-clicked) layout viewport; on a
|
||||
//! layout with no active viewport it is not available. A Model tab twist is a view change like
|
||||
//! ZOOM (view history, not undo). A layout viewport's twist belongs to the viewport entity, so a
|
||||
//! DVIEW that changed it is one undo step.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Entity, EntityKind, Handle, Insert, Line, Space};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::super::{CommandSpec, bad, f64_req};
|
||||
use crate::{Accept, DisplayOnly, EngineError, Input, Interactive, Prompt, Result, Session, Snapshot, Step, View};
|
||||
|
||||
/// What DVIEW (and a view twist asked for anywhere) says on a layout with no active viewport.
|
||||
pub(super) const PAPER_SPACE: &str = "This command is not available in Paper space.";
|
||||
|
||||
/// The option prompt's keywords, in prompt order.
|
||||
const OPTIONS: &[&str] = &["CAmera", "TArget", "Distance", "POints", "PAn", "Zoom", "TWist", "CLip", "Hide", "Off", "Undo"];
|
||||
|
||||
/// The block DVIEW previews when nothing is selected.
|
||||
const DVIEWBLOCK: &str = "DVIEWBLOCK";
|
||||
|
||||
pub(super) fn spec() -> CommandSpec {
|
||||
CommandSpec::new("dview", "DView", run_dview)
|
||||
.alias(&["dv"])
|
||||
.params("{twist: degrees counterclockwise} (DVIEW TWist: the Model tab's view twist, or the active layout viewport's, an undoable edit; not in Paper space)")
|
||||
.noundo()
|
||||
.interactive(|s| {
|
||||
// Refused as it starts on a layout with no active viewport.
|
||||
target(s)?;
|
||||
Ok(Box::new(DviewM::default()))
|
||||
})
|
||||
}
|
||||
|
||||
/// The view DVIEW changes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum Target {
|
||||
/// The Model tab's view.
|
||||
Model,
|
||||
/// The active (MSPACE) layout viewport: its own view twist.
|
||||
Viewport(Handle),
|
||||
}
|
||||
|
||||
/// The view DVIEW changes: the active layout viewport, else the Model tab's view. A layout with
|
||||
/// no active viewport has none.
|
||||
pub(super) fn target(s: &Session) -> Result<Target> {
|
||||
let st = s.state()?;
|
||||
if let Some((h, _)) = st.active_viewport() {
|
||||
return Ok(Target::Viewport(h));
|
||||
}
|
||||
if st.space == Space::Model {
|
||||
return Ok(Target::Model);
|
||||
}
|
||||
Err(EngineError::Other(PAPER_SPACE.into()))
|
||||
}
|
||||
|
||||
/// A twist kept as files hold it: [0, 2π).
|
||||
fn normal(twist: f64) -> f64 {
|
||||
cadcraft_geom::norm_angle(twist)
|
||||
}
|
||||
|
||||
/// The active viewport's twist, if it is `h`.
|
||||
fn viewport_twist(s: &Session, h: Handle) -> Option<f64> {
|
||||
s.state().ok()?.active_viewport().filter(|(vh, _)| *vh == h).map(|(_, vp)| vp.view_twist())
|
||||
}
|
||||
|
||||
/// Turn `target`'s view to `twist` radians. The Model tab's twist is a view change (view
|
||||
/// history, like ZOOM); a viewport's is a change to the viewport entity, which the caller
|
||||
/// records as an undo step ([`record_viewport_edit`]).
|
||||
pub(super) fn set_twist(s: &mut Session, target: Target, twist: f64) -> Result<()> {
|
||||
if !twist.is_finite() {
|
||||
return Err(bad("dview", "the twist must be a finite angle"));
|
||||
}
|
||||
let twist = normal(twist);
|
||||
match target {
|
||||
Target::Model => {
|
||||
let st = s.state_mut()?;
|
||||
let v = st.view();
|
||||
st.set_view(View { twist, ..v });
|
||||
}
|
||||
Target::Viewport(h) => {
|
||||
if viewport_twist(s, h) == Some(twist) {
|
||||
return Ok(());
|
||||
}
|
||||
// A locked viewport keeps its view, the twist with it.
|
||||
if s.state()?.active_viewport().is_some_and(|(vh, vp)| vh == h && vp.locked) {
|
||||
return Err(EngineError::Other("The viewport is locked.".into()));
|
||||
}
|
||||
let space = s.state()?.space.clone();
|
||||
let d = s.doc_mut()?;
|
||||
if let Some(store) = d.space_mut(&space) {
|
||||
store.modify(h, |e| {
|
||||
if let EntityKind::Viewport(vp) = &mut e.kind {
|
||||
vp.twist = twist;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a layout viewport's twist change as one undo step, `before` being the drawing (and
|
||||
/// selection) before it. Nothing when the twist did not change, and nothing inside a LISP routine
|
||||
/// (its own single step holds it).
|
||||
pub(super) fn record_viewport_edit(s: &mut Session, before: (Arc<cadcraft_doc::Drawing>, Vec<Handle>), h: Handle) -> Result<()> {
|
||||
let old = before.0.entity(h).and_then(|e| match &e.kind {
|
||||
EntityKind::Viewport(vp) => Some(vp.view_twist()),
|
||||
_ => None,
|
||||
});
|
||||
if old == viewport_twist(s, h) || s.lisp_groups_undo() {
|
||||
return Ok(());
|
||||
}
|
||||
let st = s.state_mut()?;
|
||||
if !Arc::ptr_eq(&before.0, &st.doc) {
|
||||
st.push_undo(Snapshot { label: "DView".into(), doc: before.0, selection: before.1 });
|
||||
st.revision += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The drawing and selection now, for [`record_viewport_edit`].
|
||||
fn snapshot(s: &Session) -> Result<(Arc<cadcraft_doc::Drawing>, Vec<Handle>)> {
|
||||
let st = s.state()?;
|
||||
Ok((st.doc.clone(), st.selection.clone()))
|
||||
}
|
||||
|
||||
fn run_dview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = target(s)?;
|
||||
let deg = f64_req("dview", p, "twist")?;
|
||||
let before = snapshot(s)?;
|
||||
set_twist(s, t, deg.to_radians())?;
|
||||
if let Target::Viewport(h) = t {
|
||||
record_viewport_edit(s, before, h)?;
|
||||
}
|
||||
let v = s.state()?.view();
|
||||
Ok(json!({ "twist": v.twist.to_degrees() }))
|
||||
}
|
||||
|
||||
/// The built-in DVIEWBLOCK: a house front in the unit square, origin at the lower-left corner
|
||||
/// (the size and origin a DVIEWBLOCK of one's own is expected to have). CADCraft's own drawing.
|
||||
fn sample_house() -> Vec<(Vec2, Vec2)> {
|
||||
let p = Vec2::new;
|
||||
let mut lines = Vec::new();
|
||||
let mut poly = |pts: &[Vec2], closed: bool| {
|
||||
for w in pts.windows(2) {
|
||||
lines.push((w[0], w[1]));
|
||||
}
|
||||
if closed && let (Some(a), Some(b)) = (pts.last(), pts.first()) {
|
||||
lines.push((*a, *b));
|
||||
}
|
||||
};
|
||||
// Walls.
|
||||
poly(&[p(0.1, 0.0), p(0.9, 0.0), p(0.9, 0.6), p(0.1, 0.6)], true);
|
||||
// Roof with eaves, and a chimney on its right slope.
|
||||
poly(&[p(0.0, 0.55), p(0.5, 1.0), p(1.0, 0.55)], false);
|
||||
poly(&[p(0.7, 0.82), p(0.7, 0.95), p(0.8, 0.95), p(0.8, 0.73)], false);
|
||||
// Door.
|
||||
poly(&[p(0.42, 0.0), p(0.42, 0.35), p(0.58, 0.35), p(0.58, 0.0)], false);
|
||||
// Two windows with their bars.
|
||||
for x in [0.17, 0.67] {
|
||||
poly(&[p(x, 0.25), p(x + 0.16, 0.25), p(x + 0.16, 0.45), p(x, 0.45)], true);
|
||||
poly(&[p(x + 0.08, 0.25), p(x + 0.08, 0.45)], false);
|
||||
poly(&[p(x, 0.35), p(x + 0.16, 0.35)], false);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Select,
|
||||
Option,
|
||||
Twist,
|
||||
PanBase,
|
||||
PanSecond(Vec2),
|
||||
Zoom,
|
||||
}
|
||||
|
||||
/// DVIEW, interactive.
|
||||
#[derive(Default)]
|
||||
struct DviewM {
|
||||
stage: Stage,
|
||||
sel: SelectPhase,
|
||||
/// The preview set; empty with `sample` set: the DVIEWBLOCK.
|
||||
preview: Vec<Handle>,
|
||||
/// Where the DVIEWBLOCK preview stands: its lower-left corner and size (drawing units), fixed
|
||||
/// as the options start so that PAn and Zoom move the view, not the house.
|
||||
sample: Option<(Vec2, f64)>,
|
||||
/// The views before each DVIEW action, for Undo.
|
||||
undo: Vec<View>,
|
||||
/// What DVIEW works on, and the drawing as it started (a viewport's twist change is one
|
||||
/// undo step).
|
||||
target: Option<Target>,
|
||||
before: Option<(Arc<cadcraft_doc::Drawing>, Vec<Handle>)>,
|
||||
}
|
||||
|
||||
impl DviewM {
|
||||
fn target(&self, s: &Session) -> Result<Target> {
|
||||
match self.target {
|
||||
Some(t) => Ok(t),
|
||||
None => target(s),
|
||||
}
|
||||
}
|
||||
/// Start the options with this preview set (empty: the DVIEWBLOCK).
|
||||
fn options(&mut self, s: &mut Session, picked: Vec<Handle>) -> Result<Step> {
|
||||
// Objects picked at the prompt don't stay selected.
|
||||
s.set_selection(Vec::new());
|
||||
if picked.is_empty() {
|
||||
let v = s.state()?.view();
|
||||
let size = v.height * 0.5;
|
||||
self.sample = Some((v.center - Vec2::new(size, size) * 0.5, size));
|
||||
}
|
||||
self.preview = picked;
|
||||
self.stage = Stage::Option;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
/// Show view `v` (centre, height and twist) on the target.
|
||||
fn show(&self, s: &mut Session, v: View) -> Result<()> {
|
||||
let t = self.target(s)?;
|
||||
let before = s.state()?.doc.clone();
|
||||
let twist = normal(v.twist);
|
||||
if let Target::Viewport(_) = t {
|
||||
// The twist is the viewport's; the view inside it then moves as asked.
|
||||
set_twist(s, t, twist)?;
|
||||
}
|
||||
if !s.state_mut()?.set_view(View { twist, ..v }) {
|
||||
s.state_mut()?.doc = before;
|
||||
return Err(EngineError::Other("The view is too large to show.".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Apply a view as one DVIEW action (Undo takes it back).
|
||||
fn change(&mut self, s: &mut Session, f: impl FnOnce(View) -> View) -> Result<()> {
|
||||
let v = s.state()?.view();
|
||||
self.show(s, f(v))?;
|
||||
self.undo.push(v);
|
||||
Ok(())
|
||||
}
|
||||
/// The command ends: a viewport twist change becomes one undo step, and the whole drawing is
|
||||
/// regenerated.
|
||||
fn end(&mut self, s: &mut Session) -> Result<()> {
|
||||
if let (Some(Target::Viewport(h)), Some(before)) = (self.target, self.before.take()) {
|
||||
record_viewport_edit(s, before, h)?;
|
||||
}
|
||||
s.touch();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for DviewM {
|
||||
fn name(&self) -> &'static str {
|
||||
"DVIEW"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.target = Some(target(s)?);
|
||||
self.before = Some(snapshot(s)?);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
Stage::Select if self.sel.removing => self.sel.prompt(),
|
||||
Stage::Select => Prompt::new("Select objects or", Accept::SELECT).default("use DVIEWBLOCK"),
|
||||
Stage::Option => Prompt::new("Enter option", Accept::TEXT).kw(OPTIONS),
|
||||
Stage::Twist => {
|
||||
let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0));
|
||||
let cur = s.state().map(|st| st.view().twist).unwrap_or(0.0);
|
||||
// Offered as it would be typed: clockwise is positive when ANGDIR is 1.
|
||||
let cur = if s.angle_settings().clockwise { -cur } else { cur };
|
||||
let center = s.state().map(|st| st.view().center).ok();
|
||||
Prompt::new("Specify view twist angle", Accept::POINT_OR_NUMBER).default(crate::units::format_angle(cur, au, ap)).base_opt(center)
|
||||
}
|
||||
Stage::PanBase => Prompt::new("Specify displacement base point", Accept::POINT),
|
||||
Stage::PanSecond(b) => Prompt::new("Specify second point", Accept::POINT).base(b),
|
||||
Stage::Zoom => Prompt::new("Specify zoom scale factor", Accept::NUMBER).default("1"),
|
||||
}
|
||||
}
|
||||
fn cancelled(&mut self, s: &mut Session) {
|
||||
if let Err(e) = self.end(s) {
|
||||
s.echo(e.to_string());
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.stage {
|
||||
Stage::Select => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => self.options(s, Vec::new()),
|
||||
SelOutcome::Done(hs) => self.options(s, hs),
|
||||
},
|
||||
Stage::Option => {
|
||||
let word = match &i {
|
||||
Input::Enter => {
|
||||
self.end(s)?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Input::Keyword(t) | Input::Text(t) => self.prompt(s).match_keyword(t),
|
||||
_ => None,
|
||||
};
|
||||
match word.as_deref() {
|
||||
Some("TWist") => self.stage = Stage::Twist,
|
||||
Some("PAn") => self.stage = Stage::PanBase,
|
||||
Some("Zoom") => self.stage = Stage::Zoom,
|
||||
// Perspective is never on in a 2D view: there is nothing to turn off.
|
||||
Some("Off") => {}
|
||||
Some("Undo") => {
|
||||
if let Some(v) = self.undo.pop() {
|
||||
self.show(s, v)?;
|
||||
}
|
||||
}
|
||||
// CAmera, TArget, Distance, POints, CLip and Hide need a 3D view.
|
||||
Some(other) => s.echo(format!("DVIEW {other} is not available yet.")),
|
||||
None => return Err(EngineError::Other("Invalid option keyword.".into())),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::Twist => {
|
||||
let twist = match &i {
|
||||
Input::Enter => None,
|
||||
Input::Point(p) => {
|
||||
let c = s.state()?.view().center;
|
||||
let d = *p - c;
|
||||
if d.len() < 1e-12 {
|
||||
return Err(EngineError::Other("Requires a valid angle.".into()));
|
||||
}
|
||||
Some(d.y.atan2(d.x))
|
||||
}
|
||||
Input::Text(t) | Input::Keyword(t) if t.trim().is_empty() => None,
|
||||
Input::Text(t) | Input::Keyword(t) => {
|
||||
Some(s.angle_settings().rotation(t).ok_or_else(|| EngineError::Other("Requires a valid angle.".into()))?)
|
||||
}
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if let Some(a) = twist {
|
||||
self.change(s, |v| View { twist: normal(a), ..v })?;
|
||||
}
|
||||
self.stage = Stage::Option;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::PanBase => match i {
|
||||
Input::Point(p) => {
|
||||
self.stage = Stage::PanSecond(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => {
|
||||
self.stage = Stage::Option;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => Err(EngineError::Other("Invalid point.".into())),
|
||||
},
|
||||
Stage::PanSecond(b) => match i {
|
||||
Input::Point(p) => {
|
||||
self.change(s, |v| View { center: v.center - (p - b), ..v })?;
|
||||
self.stage = Stage::Option;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => {
|
||||
self.stage = Stage::Option;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => Err(EngineError::Other("Invalid point.".into())),
|
||||
},
|
||||
Stage::Zoom => {
|
||||
let f = match &i {
|
||||
Input::Enter => None,
|
||||
Input::Text(t) | Input::Keyword(t) if t.trim().is_empty() => None,
|
||||
Input::Text(t) | Input::Keyword(t) => match number(t) {
|
||||
Some(f) if f.is_finite() && f > 0.0 => Some(f),
|
||||
_ => return Err(EngineError::Other("Requires a positive number.".into())),
|
||||
},
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if let Some(f) = f.filter(|f| *f != 1.0) {
|
||||
self.change(s, |v| View { height: v.height / f, ..v })?;
|
||||
}
|
||||
self.stage = Stage::Option;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn display_only(&self, s: &Session) -> Option<DisplayOnly> {
|
||||
if self.stage == Stage::Select {
|
||||
return None;
|
||||
}
|
||||
let mut extra = Vec::new();
|
||||
if let Some((corner, size)) = self.sample {
|
||||
let common = s.current_common();
|
||||
let d = s.doc().ok()?;
|
||||
if d.block(DVIEWBLOCK).is_some() {
|
||||
// The drawing's own DVIEWBLOCK, defined in a 1 by 1 unit area.
|
||||
let ins = Insert {
|
||||
block: DVIEWBLOCK.into(),
|
||||
insert: corner.to3(0.0),
|
||||
scale: Vec3::new(size, size, size),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
extra.push(Entity { handle: Handle(0), common, kind: EntityKind::Insert(ins) });
|
||||
} else {
|
||||
extra.extend(sample_house().into_iter().map(|(a, b)| Entity {
|
||||
handle: Handle(0),
|
||||
common: common.clone(),
|
||||
kind: EntityKind::Line(Line { a: (corner + a * size).to3(0.0), b: (corner + b * size).to3(0.0) }),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Some(DisplayOnly { handles: self.preview.clone(), extra })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_sample_house_fits_the_unit_square() {
|
||||
let lines = sample_house();
|
||||
assert!(lines.len() > 10);
|
||||
for (a, b) in lines {
|
||||
for q in [a, b] {
|
||||
assert!((0.0..=1.0).contains(&q.x) && (0.0..=1.0).contains(&q.y), "{q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,15 +110,25 @@ fn save(s: &mut Session, name: &str, window: Option<Bounds2>, snapshot: bool) ->
|
||||
(Space::Paper(l), false) => Some(l.clone()),
|
||||
_ => None,
|
||||
};
|
||||
// The view this saves: the viewport's model view inside an MSPACE viewport, else the screen.
|
||||
let frame = if in_viewport { st.zoom_frame() } else { st.view() };
|
||||
// The view twist goes with the view (paper space has none).
|
||||
let twist = if layout.is_some() { 0.0 } else { frame.twist };
|
||||
let (center, height, width) = match window {
|
||||
Some(b) => {
|
||||
if !(b.width() > 1e-12 && b.height() > 1e-12 && b.min.is_finite() && b.max.is_finite()) {
|
||||
return Err(EngineError::Other("The window has no area.".into()));
|
||||
}
|
||||
(b.center(), b.height(), b.width())
|
||||
// In a twisted view the window's box on screen is larger than its world box.
|
||||
let (center, width, height) = crate::View::twisted_box(b, twist);
|
||||
// Turning corners near the number limit can overflow: save no view that can't be restored.
|
||||
if !(center.is_finite() && width.is_finite() && height.is_finite() && width > 1e-12 && height > 1e-12) {
|
||||
return Err(EngineError::Other("The window is too large for a twisted view.".into()));
|
||||
}
|
||||
(center, height, width)
|
||||
}
|
||||
None => {
|
||||
let f = if in_viewport { st.zoom_frame() } else { st.view() };
|
||||
let f = frame;
|
||||
let aspect = if in_viewport { st.zoom_aspect(w / h.max(1.0)) } else { w / h.max(1.0) };
|
||||
(f.center, f.height, f.height * aspect)
|
||||
}
|
||||
@@ -139,7 +149,7 @@ fn save(s: &mut Session, name: &str, window: Option<Bounds2>, snapshot: bool) ->
|
||||
}
|
||||
d.views.retain(|v| !v.name.eq_ignore_ascii_case(old));
|
||||
}
|
||||
d.views.push(NamedView { name: name.to_string(), center, height, width, layer_state, twist: 0.0, layout });
|
||||
d.views.push(NamedView { name: name.to_string(), center, height, width, layer_state, twist, layout });
|
||||
let msg = if replaced.is_some() { format!("View \"{name}\" replaced.") } else { format!("View \"{name}\" saved.") };
|
||||
Ok(json!({ "name": name, "center": [center.x, center.y], "height": height, "width": width, "message": msg }))
|
||||
}
|
||||
@@ -168,7 +178,7 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {
|
||||
let st = s.state_mut()?;
|
||||
st.mspace = None;
|
||||
let height = v.height.max(v.width / (w / h.max(1.0)).max(1e-9));
|
||||
st.set_view(View { center: v.center, height });
|
||||
st.set_view(View { center: v.center, height, twist: 0.0 });
|
||||
}
|
||||
None => {
|
||||
// A model space view: into the active MSPACE viewport, else on the Model tab.
|
||||
@@ -177,21 +187,18 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {
|
||||
}
|
||||
let (w, h) = s.viewport_px;
|
||||
let st = s.state_mut()?;
|
||||
// In an unlocked MSPACE viewport the saved twist turns the viewport (an edit of the
|
||||
// viewport, recorded as the command's undo step).
|
||||
let aspect = st.zoom_aspect(w / h.max(1.0)).max(1e-9);
|
||||
st.set_zoom_frame(View { center: v.center, height: v.height.max(v.width / aspect) });
|
||||
let twist = cadcraft_geom::norm_angle(v.twist);
|
||||
st.set_zoom_frame(View { center: v.center, height: v.height.max(v.width / aspect), twist });
|
||||
}
|
||||
}
|
||||
let mut msg = format!("View \"{}\" restored.", v.name);
|
||||
if let Some(state) = &v.layer_state {
|
||||
let d = s.doc_mut()?;
|
||||
match d.layer_states.iter().find(|l| l.name.eq_ignore_ascii_case(state)).cloned() {
|
||||
Some(saved) => {
|
||||
for layer in saved.layers {
|
||||
if let Some(l) = d.layer_mut(&layer.name) {
|
||||
*l = layer;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(saved) => super::super::layer::restore_layers(d, saved.layers),
|
||||
None => msg.push_str(&format!(" Its layer snapshot \"{state}\" is missing.")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ fn rect(t: &VportTile) -> Rect {
|
||||
}
|
||||
|
||||
fn view_of(t: &VportTile) -> View {
|
||||
View { center: t.center, height: t.height }
|
||||
View { center: t.center, height: t.height, twist: if t.twist.is_finite() { t.twist } else { 0.0 } }
|
||||
}
|
||||
|
||||
fn active_config(d: &Drawing) -> Option<&VportConfig> {
|
||||
@@ -117,7 +117,7 @@ fn live(st: &DocState, px: (f64, f64)) -> (Vec<VportTile>, usize) {
|
||||
let now = st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v).unwrap_or_default();
|
||||
let Some(cfg) = active_config(&st.doc) else {
|
||||
let aspect = px.0 / px.1.max(1.0);
|
||||
return (vec![VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: now.center, height: now.height, aspect }], 0);
|
||||
return (vec![VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: now.center, height: now.height, aspect, twist: now.twist }], 0);
|
||||
};
|
||||
let mut tiles = cfg.tiles.clone();
|
||||
let synced =
|
||||
@@ -125,11 +125,11 @@ fn live(st: &DocState, px: (f64, f64)) -> (Vec<VportTile>, usize) {
|
||||
let active = if synced { st.tiles.active.min(tiles.len() - 1) } else { 0 };
|
||||
if synced {
|
||||
for (t, v) in tiles.iter_mut().zip(&st.tiles.views) {
|
||||
(t.center, t.height) = (v.center, v.height);
|
||||
(t.center, t.height, t.twist) = (v.center, v.height, v.twist);
|
||||
}
|
||||
}
|
||||
if let Some(t) = tiles.get_mut(active) {
|
||||
(t.center, t.height) = (now.center, now.height);
|
||||
(t.center, t.height, t.twist) = (now.center, now.height, now.twist);
|
||||
}
|
||||
// Screen aspects from the current tile's size in pixels.
|
||||
if let Some(cur) = tiles.get(active).copied() {
|
||||
@@ -171,7 +171,7 @@ fn summary(s: &Session) -> Result<Value> {
|
||||
let rows: Vec<Value> = tiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| json!({ "index": i + 1, "min": [t.min.x, t.min.y], "max": [t.max.x, t.max.y], "center": [t.center.x, t.center.y], "height": t.height, "current": i == active }))
|
||||
.map(|(i, t)| json!({ "index": i + 1, "min": [t.min.x, t.min.y], "max": [t.max.x, t.max.y], "center": [t.center.x, t.center.y], "height": t.height, "twist": t.twist.to_degrees(), "current": i == active }))
|
||||
.collect();
|
||||
Ok(json!({ "tiles": rows }))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::view::{ModelTiles, View, usable_view};
|
||||
use super::{Arc, Drawing, Handle, Space, blockedit, undoctl};
|
||||
use super::{Arc, Drawing, Handle, Space, Vec2, blockedit, undoctl};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Snapshot {
|
||||
@@ -80,6 +80,7 @@ impl DocState {
|
||||
pub(crate) fn swap_in(&mut self, snap: Snapshot) -> Snapshot {
|
||||
let cur = Snapshot { label: snap.label, doc: std::mem::replace(&mut self.doc, snap.doc), selection: std::mem::take(&mut self.selection) };
|
||||
self.follow_layout(&cur.doc);
|
||||
self.reconcile_untiled_twist();
|
||||
self.revision += 1;
|
||||
cur
|
||||
}
|
||||
@@ -135,7 +136,8 @@ impl DocState {
|
||||
match self.active_viewport() {
|
||||
Some((_, vp)) => {
|
||||
let k = vp.view_height / vp.height;
|
||||
View { center: vp.view_center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
|
||||
// The viewport's twist turns the model view inside it.
|
||||
View { center: vp.paper_to_model(pv.center), height: pv.height * k, twist: vp.view_twist() }
|
||||
}
|
||||
None => pv,
|
||||
}
|
||||
@@ -150,16 +152,18 @@ impl DocState {
|
||||
if vp.locked {
|
||||
// A locked viewport keeps its scale: pan/zoom the sheet instead.
|
||||
let k0 = vp.view_height / vp.height;
|
||||
let pc = vp.center.xy() + (v.center - vp.view_center) / k0;
|
||||
let paper = View { center: pc, height: v.height / k0 };
|
||||
let pc = vp.model_to_paper(v.center);
|
||||
let paper = View { center: pc, height: v.height / k0, twist: pv.twist };
|
||||
if !usable_view(&paper) {
|
||||
return false;
|
||||
}
|
||||
self.put_paper_view(paper);
|
||||
} else {
|
||||
let center = v.center - (pv.center - vp.center.xy()) * k;
|
||||
// `v`'s twist is the one the viewport shows (a non-finite one keeps its own).
|
||||
let twist = if v.twist.is_finite() { v.twist } else { vp.view_twist() };
|
||||
let center = v.center - ((pv.center - vp.center.xy()) * k).rotate(-twist);
|
||||
let height = vp.height * k;
|
||||
if !usable_view(&View { center, height }) {
|
||||
if !usable_view(&View { center, height, twist }) {
|
||||
return false;
|
||||
}
|
||||
let doc = Arc::make_mut(&mut self.doc);
|
||||
@@ -168,6 +172,7 @@ impl DocState {
|
||||
if let cadcraft_doc::EntityKind::Viewport(x) = &mut e.kind {
|
||||
x.view_center = center;
|
||||
x.view_height = height;
|
||||
x.twist = twist;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -182,6 +187,36 @@ impl DocState {
|
||||
Some((_, slot)) => *slot = v,
|
||||
None => self.views.push((self.space.clone(), v)),
|
||||
}
|
||||
if self.space == Space::Model {
|
||||
self.keep_untiled_twist(v);
|
||||
}
|
||||
}
|
||||
/// An untiled Model tab keeps its view twist in the drawing as a lone `*Active` VPORT tile
|
||||
/// (written as that record's group 51 and read back), so it survives save and reopen. Only a
|
||||
/// change of twist touches the drawing; an untwisted view keeps no record.
|
||||
/// After undo or redo has put back another drawing: its untiled-twist record follows the
|
||||
/// Model tab's live view again (views are not part of the drawing's undo).
|
||||
pub(crate) fn reconcile_untiled_twist(&mut self) {
|
||||
if let Some(v) = self.views.iter().find(|(s, _)| *s == Space::Model).map(|(_, v)| *v) {
|
||||
self.keep_untiled_twist(v);
|
||||
}
|
||||
}
|
||||
fn keep_untiled_twist(&mut self, v: View) {
|
||||
use cadcraft_doc::{VportConfig, VportTile};
|
||||
let twist = cadcraft_geom::norm_angle(v.twist);
|
||||
let active = self.doc.vports.iter().find(|c| c.is_active());
|
||||
if active.is_some_and(|c| c.tiles.len() > 1) {
|
||||
return;
|
||||
}
|
||||
if active.and_then(|c| c.tiles.first()).map_or(0.0, |t| t.twist) == twist {
|
||||
return;
|
||||
}
|
||||
let d = Arc::make_mut(&mut self.doc);
|
||||
d.vports.retain(|c| !c.is_active());
|
||||
if twist != 0.0 {
|
||||
let tile = VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: v.center, height: v.height, aspect: 1.0, twist };
|
||||
d.vports.insert(0, VportConfig { name: VportConfig::ACTIVE.into(), tiles: vec![tile] });
|
||||
}
|
||||
}
|
||||
/// Set the view, recording view history. Returns false, changing nothing, when the view (or,
|
||||
/// in MSPACE, the viewport view it maps to) isn't finite with a positive height.
|
||||
@@ -210,7 +245,7 @@ impl DocState {
|
||||
/// The view ZOOM starts from: the model view of the active unlocked viewport, else [`Self::view`].
|
||||
pub fn zoom_frame(&self) -> View {
|
||||
match self.zoom_viewport() {
|
||||
Some(v) => View { center: v.view_center, height: v.view_height },
|
||||
Some(v) => View { center: v.view_center, height: v.view_height, twist: v.view_twist() },
|
||||
None => self.view(),
|
||||
}
|
||||
}
|
||||
@@ -225,7 +260,9 @@ impl DocState {
|
||||
Some(vp) => {
|
||||
let pv = self.paper_view();
|
||||
let k = f.height / vp.height;
|
||||
View { center: f.center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
|
||||
// The frame's twist turns the viewport (a non-finite one keeps the viewport's).
|
||||
let twist = if f.twist.is_finite() { f.twist } else { vp.view_twist() };
|
||||
View { center: f.center + ((pv.center - vp.center.xy()) * k).rotate(-twist), height: pv.height * k, twist }
|
||||
}
|
||||
None => f,
|
||||
};
|
||||
@@ -233,7 +270,7 @@ impl DocState {
|
||||
}
|
||||
/// Set without recording view history (realtime pan/zoom frames).
|
||||
pub fn set_view_quiet(&mut self, v: View) {
|
||||
if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) {
|
||||
if !usable_view(&v) {
|
||||
return;
|
||||
}
|
||||
self.store_view(v);
|
||||
|
||||
@@ -66,6 +66,14 @@ impl Interactive for Guarded {
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
catch_unwind(AssertUnwindSafe(|| self.inner.begin(s))).unwrap_or_else(|p| Err(self.internal(&*p)))
|
||||
}
|
||||
fn cancelled(&mut self, s: &mut Session) {
|
||||
if self.broken.get().is_none()
|
||||
&& let Err(p) = catch_unwind(AssertUnwindSafe(|| self.inner.cancelled(s)))
|
||||
{
|
||||
let e = self.internal(&*p);
|
||||
s.echo(e.to_string());
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
|
||||
if self.broken.get().is_some() {
|
||||
return Vec::new();
|
||||
@@ -102,6 +110,15 @@ impl Interactive for Guarded {
|
||||
None
|
||||
})
|
||||
}
|
||||
fn display_only(&self, s: &Session) -> Option<crate::prompt::DisplayOnly> {
|
||||
if self.broken.get().is_some() {
|
||||
return None;
|
||||
}
|
||||
catch_unwind(AssertUnwindSafe(|| self.inner.display_only(s))).unwrap_or_else(|_| {
|
||||
self.broken.set(Some("panic in preview"));
|
||||
None
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -49,7 +49,7 @@ pub use cadcraft_geom as geom;
|
||||
pub use cadcraft_render as render;
|
||||
pub use cmd::hatch::{HatchPicks, PatternChoice, PickMode, pattern_choices};
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step};
|
||||
pub use prompt::{Accept, DisplayOnly, Drag, Input, Interactive, Picker, Prompt, Step};
|
||||
|
||||
mod doc_state;
|
||||
mod error;
|
||||
|
||||
@@ -31,7 +31,7 @@ impl Accept {
|
||||
/// [Truecolor/COlorbook] <BYLAYER>:". A prompt asking for something else besides its keywords has
|
||||
/// it: "Enter block name or [?]:", "Specify insertion base point or [Basepoint/…]:". Read from
|
||||
/// AutoCAD's command line over measured runs, where no prompt was written both ways.
|
||||
const NO_OR: [&str; 27] = [
|
||||
const NO_OR: [&str; 28] = [
|
||||
"Bring to front",
|
||||
"Calculate rectangle dimensions based on",
|
||||
"Choose mode for selected object conversion",
|
||||
@@ -47,6 +47,7 @@ const NO_OR: [&str; 27] = [
|
||||
"Enter leader format option",
|
||||
"Enter line spacing type",
|
||||
"Enter object ordering option",
|
||||
"Enter option",
|
||||
"Enter polyline linetype generation option",
|
||||
"Enter property to change",
|
||||
"Enter the type of array",
|
||||
@@ -266,6 +267,9 @@ pub trait Interactive: Send {
|
||||
fn begin(&mut self, _s: &mut Session) -> Result<Step> {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
/// Called when the command is cancelled from outside (Esc, or another command starting),
|
||||
/// before it finishes: what it records on every exit path.
|
||||
fn cancelled(&mut self, _s: &mut Session) {}
|
||||
/// Rubber-band geometry for the cursor position.
|
||||
fn preview(&self, _s: &Session, _cursor: Vec2) -> Vec<EntityKind> {
|
||||
Vec::new()
|
||||
@@ -287,6 +291,22 @@ pub trait Interactive: Send {
|
||||
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
|
||||
None
|
||||
}
|
||||
/// What the drawing area shows in place of the drawing while the command runs (DVIEW draws
|
||||
/// only its preview set); `None` shows the drawing.
|
||||
fn display_only(&self, _s: &Session) -> Option<DisplayOnly> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`Interactive::display_only`]: the only things drawn in the current view while a command
|
||||
/// runs. The whole drawing is drawn again when it ends.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct DisplayOnly {
|
||||
/// Objects of the drawing, drawn as they are.
|
||||
pub handles: Vec<Handle>,
|
||||
/// Geometry that is not in the drawing (DVIEW's sample house, or an insert of the drawing's
|
||||
/// DVIEWBLOCK block), with its own properties.
|
||||
pub extra: Vec<cadcraft_doc::Entity>,
|
||||
}
|
||||
|
||||
/// See [`Interactive::drag`].
|
||||
|
||||
@@ -6,7 +6,7 @@ impl Session {
|
||||
self.untitled_counter += 1;
|
||||
let d = if metric { Drawing::new_metric() } else { Drawing::new_imperial() };
|
||||
let mut st = DocState::new(d, &format!("Drawing{}", self.untitled_counter), None);
|
||||
st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0 } } else { View::default() });
|
||||
st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0, twist: 0.0 } } else { View::default() });
|
||||
self.end_running();
|
||||
self.docs.push(st);
|
||||
self.active = self.docs.len() - 1;
|
||||
@@ -18,11 +18,13 @@ impl Session {
|
||||
pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option<String>) -> usize {
|
||||
let mut st = DocState::new(d, title, path);
|
||||
let ext = st.doc.extents(&Space::Model);
|
||||
if !ext.is_empty() {
|
||||
st.set_view_quiet(View {
|
||||
center: ext.center(),
|
||||
height: (ext.height().max(ext.width() * self.viewport_px.1 / self.viewport_px.0.max(1.0)) * 1.1).max(1e-6),
|
||||
});
|
||||
// The current tile (the first), or the lone untiled view, keeps its saved twist.
|
||||
let twist = cadcraft_geom::norm_angle(st.doc.vports.iter().find(|c| c.is_active()).and_then(|c| c.tiles.first()).map_or(0.0, |t| t.twist));
|
||||
if ext.is_empty() {
|
||||
st.set_view_quiet(View { twist, ..st.view() });
|
||||
} else {
|
||||
let (center, w, h) = View::twisted_box(ext, twist);
|
||||
st.set_view_quiet(View { center, height: (h.max(w * self.viewport_px.1 / self.viewport_px.0.max(1.0)) * 1.1).max(1e-6), twist });
|
||||
}
|
||||
self.end_running();
|
||||
self.docs.push(st);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::DisplayOnly;
|
||||
|
||||
use super::{
|
||||
Arc, Bounds2, Drag, Drawing, EngineError, Entity, Handle, Input, PendingWindow, Prompt, Result, Running, Session, Snapshot, Step, Value, Vec2,
|
||||
assoc, cmd, find_command, guard, pointmod, select, snap, sysvars,
|
||||
@@ -199,7 +201,7 @@ impl Session {
|
||||
Input::Point(p) => {
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
if let Some(pw) = self.pending_window.take() {
|
||||
let crossing = p.x < pw.corner.x;
|
||||
let crossing = self.window_is_crossing(pw.corner, p);
|
||||
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
|
||||
return Ok(Some(Input::Pick(cmd::groups::expand(self, hs))));
|
||||
}
|
||||
@@ -279,7 +281,10 @@ impl Session {
|
||||
let base = if matches!(input, Some(Input::Point(_))) { self.current_prompt().and_then(|p| p.base) } else { None };
|
||||
let Some(mut run) = self.running.take() else { return Ok(()) };
|
||||
let step = match input {
|
||||
Some(Input::Cancel) => Ok(Step::Cancel),
|
||||
Some(Input::Cancel) => {
|
||||
run.machine.cancelled(self);
|
||||
Ok(Step::Cancel)
|
||||
}
|
||||
Some(i) => {
|
||||
if let Input::Point(p) = &i {
|
||||
self.last_point = *p;
|
||||
@@ -438,7 +443,8 @@ impl Session {
|
||||
}
|
||||
if self.running.is_some() {
|
||||
if self.suspended.is_some() {
|
||||
if let Some(nested) = self.running.take() {
|
||||
if let Some(mut nested) = self.running.take() {
|
||||
nested.machine.cancelled(self);
|
||||
self.finish(nested, true);
|
||||
}
|
||||
self.running = self.suspended.take();
|
||||
@@ -523,6 +529,13 @@ impl Session {
|
||||
out
|
||||
}
|
||||
|
||||
/// What the drawing area shows in place of the drawing while a command runs (DVIEW's preview
|
||||
/// set, see [`Interactive::display_only`]), also while a transparent command interrupts it.
|
||||
/// `None`: the drawing.
|
||||
pub fn display_only(&self) -> Option<DisplayOnly> {
|
||||
[&self.running, &self.suspended].into_iter().flatten().find_map(|r| r.machine.display_only(self))
|
||||
}
|
||||
|
||||
/// The active command's preview as a transform of existing objects (see
|
||||
/// [`Interactive::drag`]), with its extra rubber-band geometry as entities.
|
||||
pub fn drag(&self, cursor: Vec2) -> Option<(Drag, Vec<Entity>)> {
|
||||
|
||||
@@ -16,6 +16,13 @@ impl Session {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether a selection window from `corner` to `p` is a crossing window: it was dragged
|
||||
/// leftwards on screen (in a twisted view, not along the drawing's X axis).
|
||||
pub fn window_is_crossing(&self, corner: Vec2, p: Vec2) -> bool {
|
||||
let twist = self.state().map_or(0.0, |st| st.view().twist);
|
||||
(p - corner).rotate(twist).x < 0.0
|
||||
}
|
||||
|
||||
/// A click with no command running: pick/toggle objects or start a selection window.
|
||||
///
|
||||
/// PICKADD on (the default): picks add to the selection and Shift removes. PICKADD off: each pick
|
||||
@@ -24,7 +31,7 @@ impl Session {
|
||||
let space = self.space();
|
||||
let pickadd = self.settings.pickadd;
|
||||
if let Some(pw) = self.pending_window.take() {
|
||||
let crossing = p.x < pw.corner.x;
|
||||
let crossing = self.window_is_crossing(pw.corner, p);
|
||||
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
|
||||
let hs = cmd::groups::expand(self, hs);
|
||||
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
|
||||
|
||||
@@ -23,8 +23,10 @@ impl Session {
|
||||
let ext = if ext.is_empty() { self.zoom_limits()? } else { ext };
|
||||
let st = self.state_mut()?;
|
||||
let aspect = st.zoom_aspect(w / h.max(1.0));
|
||||
let height = ext.height().max(ext.width() / aspect.max(1e-6)).max(1e-6) * 1.05;
|
||||
st.set_zoom_frame(View { center: ext.center(), height });
|
||||
let twist = st.zoom_frame().twist;
|
||||
let (center, bw, bh) = View::twisted_box(ext, twist);
|
||||
let height = bh.max(bw / aspect.max(1e-6)).max(1e-6) * 1.05;
|
||||
st.set_zoom_frame(View { center, height, twist });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -38,7 +40,7 @@ impl Session {
|
||||
let h = (v.height / factor).clamp(1e-9, 1e12);
|
||||
let k = h / v.height;
|
||||
let c = about + (v.center - about) * k;
|
||||
st.set_view_quiet(View { center: c, height: h });
|
||||
st.set_view_quiet(View { center: c, height: h, twist: v.twist });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ impl<'a> Projection<'a> {
|
||||
}
|
||||
let half = Vec2::new(viewport.width / 2.0, viewport.height / 2.0);
|
||||
let paper_clip = Bounds2::new(center - half, center + half);
|
||||
let model_clip = Bounds2::new(viewport.view_center - half / scale, viewport.view_center + half / scale);
|
||||
// The model box the viewport shows (turned, with a view twist).
|
||||
let model_clip = Bounds2::from_points(paper_clip.corners().map(|p| viewport.paper_to_model(p)));
|
||||
if ![paper_clip.min, paper_clip.max, model_clip.min, model_clip.max].into_iter().all(Vec2::is_finite) {
|
||||
return None;
|
||||
}
|
||||
@@ -48,11 +49,11 @@ impl<'a> Projection<'a> {
|
||||
}
|
||||
|
||||
pub fn to_model(&self, point: Vec2) -> Vec2 {
|
||||
self.viewport.view_center + (point - self.viewport.center.xy()) / self.scale
|
||||
self.viewport.paper_to_model(point)
|
||||
}
|
||||
|
||||
fn to_paper(&self, point: Vec2) -> Vec2 {
|
||||
self.viewport.center.xy() + (point - self.viewport.view_center) * self.scale
|
||||
self.viewport.model_to_paper(point)
|
||||
}
|
||||
|
||||
pub fn hit(&self, mut hit: SnapHit) -> Option<SnapHit> {
|
||||
@@ -76,7 +77,9 @@ impl<'a> Projection<'a> {
|
||||
if !(center.is_finite() && radius.is_finite() && radius > 0.0) {
|
||||
return None;
|
||||
}
|
||||
SnapCurve::Arc { center, radius, start, end }
|
||||
// The arc turns on the sheet with the viewport's twist.
|
||||
let twist = self.viewport.view_twist();
|
||||
SnapCurve::Arc { center, radius, start: start + twist, end: end + twist }
|
||||
}
|
||||
SnapCurve::Line { a, b } => {
|
||||
let (a, b) = (self.to_paper(a), self.to_paper(b));
|
||||
|
||||
@@ -184,6 +184,8 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
json!([v.center.x, v.center.y, 0.0])
|
||||
}
|
||||
"VIEWSIZE" => json!(s.state().ok()?.view().height),
|
||||
// The view twist (DVIEW TWist), in degrees counterclockwise.
|
||||
"VIEWTWIST" => json!(s.state().ok()?.view().twist.to_degrees()),
|
||||
"EXTMIN" | "EXTMAX" => {
|
||||
let d = s.doc().ok()?;
|
||||
let e = d.extents(&s.space());
|
||||
@@ -260,6 +262,7 @@ const READ_ONLY: &[&str] = &[
|
||||
"DISTANCE",
|
||||
"VIEWCTR",
|
||||
"VIEWSIZE",
|
||||
"VIEWTWIST",
|
||||
"EXTMIN",
|
||||
"EXTMAX",
|
||||
"UNDOCTL",
|
||||
|
||||
@@ -185,7 +185,7 @@ fn move_with_pickfirst_and_window_selection() {
|
||||
s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
|
||||
s.execute("line", &json!({"points": [[5, 5], [6, 5]]})).unwrap();
|
||||
s.viewport_px = (1000.0, 1000.0);
|
||||
s.state_mut().unwrap().set_view(View { center: Vec2::new(3.0, 3.0), height: 10.0 });
|
||||
s.state_mut().unwrap().set_view(View { center: Vec2::new(3.0, 3.0), height: 10.0, twist: 0.0 });
|
||||
// Window selection from left to right picks only the first line.
|
||||
s.cmdline("move").unwrap();
|
||||
s.input(Input::Point(Vec2::new(-1.0, -1.0))).unwrap();
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
use cadcraft_geom::Bounds2;
|
||||
|
||||
use super::{Deserialize, Serialize, Vec2};
|
||||
|
||||
/// A 2D view of a space: world centre and visible height in drawing units.
|
||||
/// A 2D view of a space: world centre and visible height in drawing units, and the view twist
|
||||
/// (VIEWTWIST, DVIEW TWist): how far the view is turned on screen, in radians counterclockwise
|
||||
/// (the drawing appears turned by `twist`; 0 is the plain plan view).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct View {
|
||||
pub center: Vec2,
|
||||
pub height: f64,
|
||||
#[serde(default)]
|
||||
pub twist: f64,
|
||||
}
|
||||
|
||||
impl Default for View {
|
||||
fn default() -> Self {
|
||||
View { center: Vec2::new(6.0, 4.5), height: 10.0 }
|
||||
View { center: Vec2::new(6.0, 4.5), height: 10.0, twist: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// Box `b` (world) as a view turned by `twist` shows it: the world centre of the box it
|
||||
/// makes on screen, and that box's width and height (`b`'s own when there is no twist).
|
||||
pub fn twisted_box(b: Bounds2, twist: f64) -> (Vec2, f64, f64) {
|
||||
if twist == 0.0 || !twist.is_finite() {
|
||||
return (b.center(), b.width(), b.height());
|
||||
}
|
||||
let r = Bounds2::from_points(b.corners().map(|p| p.rotate(twist)));
|
||||
(r.center().rotate(-twist), r.width(), r.height())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn usable_view(v: &View) -> bool {
|
||||
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12
|
||||
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12 && v.twist.is_finite()
|
||||
}
|
||||
|
||||
/// Tiled model viewports (VPORTS): the session side of the drawing's `*Active` configuration.
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
//! DVIEW starts with "Select objects or <use DVIEWBLOCK>:", then the option prompt;
|
||||
//! TWist, PAn, Zoom, Off and Undo on a 2D view; the 3D options not available yet. It works on the
|
||||
//! Model tab and inside an active layout viewport (whose twist is an undoable edit of the
|
||||
//! viewport), and not on a layout sheet.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle, Viewport};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Input, Session, View};
|
||||
use serde_json::json;
|
||||
|
||||
const SELECT: &str = "Select objects or <use DVIEWBLOCK>:";
|
||||
const OPTION: &str = "Enter option [CAmera/TArget/Distance/POints/PAn/Zoom/TWist/CLip/Hide/Off/Undo]:";
|
||||
const PAPER: &str = "This command is not available in Paper space.";
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.viewport_px = (1200.0, 800.0);
|
||||
s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap();
|
||||
s.execute("view.set", &json!({ "center": [10, 10], "height": 20 })).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn prompt(s: &Session) -> String {
|
||||
s.current_prompt().map(|p| p.display()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn view(s: &Session) -> View {
|
||||
s.state().unwrap().view()
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
fn twist_deg(s: &Session) -> f64 {
|
||||
view(s).twist.to_degrees()
|
||||
}
|
||||
|
||||
fn logged(s: &Session, m: &str) -> bool {
|
||||
s.log.iter().any(|l| l == m)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_prompt_step_on_the_model_tab() {
|
||||
let mut s = session();
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
s.cmdline("dview").unwrap();
|
||||
assert_eq!(prompt(&s), SELECT);
|
||||
// The whole drawing shows while objects are picked.
|
||||
assert!(s.display_only().is_none());
|
||||
// Enter with nothing selected: the DVIEWBLOCK sample (a house of lines) is the preview set.
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(prompt(&s), OPTION);
|
||||
let only = s.display_only().unwrap();
|
||||
assert!(only.handles.is_empty());
|
||||
assert!(only.extra.len() > 10 && only.extra.iter().all(|e| matches!(e.kind, EntityKind::Line(_))));
|
||||
twist_pan_and_zoom(&mut s);
|
||||
// The sample house stays where it was put: the view moves over it.
|
||||
assert_eq!(s.display_only().unwrap(), only);
|
||||
off_and_unavailable_options(&mut s);
|
||||
undo_steps(&mut s);
|
||||
|
||||
// An unknown option is refused and asked again.
|
||||
assert!(s.cmdline("bogus").is_ok());
|
||||
assert_eq!(prompt(&s), OPTION);
|
||||
|
||||
// Enter ends DVIEW; the whole drawing shows again, and a Model tab twist is no undo step.
|
||||
s.cmdline("tw").unwrap();
|
||||
s.cmdline("45").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert!(s.display_only().is_none());
|
||||
assert!(near(twist_deg(&s), 45.0));
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo);
|
||||
}
|
||||
|
||||
/// DVIEW's TWist, PAn and Zoom steps from the option prompt of a fresh Model tab session.
|
||||
fn twist_pan_and_zoom(s: &mut Session) {
|
||||
// TWist: the current twist is the default; a typed angle turns the view.
|
||||
s.cmdline("tw").unwrap();
|
||||
assert_eq!(prompt(s), "Specify view twist angle <0>:");
|
||||
s.cmdline("30").unwrap();
|
||||
assert!(near(twist_deg(s), 30.0));
|
||||
assert_eq!(prompt(s), OPTION);
|
||||
s.cmdline("TWist").unwrap();
|
||||
assert_eq!(prompt(s), "Specify view twist angle <30>:");
|
||||
// Enter keeps it.
|
||||
s.cmdline("").unwrap();
|
||||
assert!(near(twist_deg(s), 30.0));
|
||||
|
||||
// PAn: base point, second point.
|
||||
s.cmdline("pa").unwrap();
|
||||
assert_eq!(prompt(s), "Specify displacement base point:");
|
||||
s.cmdline("0,0").unwrap();
|
||||
assert_eq!(prompt(s), "Specify second point:");
|
||||
s.cmdline("4,0").unwrap();
|
||||
assert_eq!(view(s).center, Vec2::new(6.0, 10.0));
|
||||
assert_eq!(prompt(s), OPTION);
|
||||
|
||||
// Zoom: a scale factor of the current view.
|
||||
s.cmdline("z").unwrap();
|
||||
assert_eq!(prompt(s), "Specify zoom scale factor <1>:");
|
||||
s.cmdline("2").unwrap();
|
||||
assert!(near(view(s).height, 10.0));
|
||||
}
|
||||
|
||||
/// DVIEW's Off option and the options that need a 3D camera.
|
||||
fn off_and_unavailable_options(s: &mut Session) {
|
||||
// Off: perspective is never on in a 2D view, so nothing changes.
|
||||
let before = view(s);
|
||||
s.cmdline("o").unwrap();
|
||||
assert_eq!(prompt(s), OPTION);
|
||||
assert_eq!(view(s), before);
|
||||
|
||||
// The options that need a 3D camera are not available yet and stay at the option prompt.
|
||||
for (typed, name) in [("ca", "CAmera"), ("ta", "TArget"), ("d", "Distance"), ("po", "POints"), ("cl", "CLip"), ("h", "Hide")] {
|
||||
s.cmdline(typed).unwrap();
|
||||
assert!(logged(s, &format!("DVIEW {name} is not available yet.")), "{name}: {:?}", s.log);
|
||||
assert_eq!(prompt(s), OPTION);
|
||||
}
|
||||
assert!(!s.log.iter().any(|l| l.contains("Only the view twist")));
|
||||
}
|
||||
|
||||
/// DVIEW's Undo after [`twist_pan_and_zoom`].
|
||||
fn undo_steps(s: &mut Session) {
|
||||
// Undo takes back one DVIEW action at a time: zoom, pan, twist.
|
||||
s.cmdline("u").unwrap();
|
||||
assert!(near(view(s).height, 20.0));
|
||||
s.cmdline("u").unwrap();
|
||||
assert_eq!(view(s).center, Vec2::new(10.0, 10.0));
|
||||
assert!(near(twist_deg(s), 30.0));
|
||||
s.cmdline("u").unwrap();
|
||||
assert_eq!(view(s).twist, 0.0);
|
||||
// Nothing left to undo: still at the option prompt.
|
||||
s.cmdline("u").unwrap();
|
||||
assert_eq!(prompt(s), OPTION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_objects_are_the_preview_set() {
|
||||
let mut s = session();
|
||||
let line = s.doc().unwrap().model.iter().next().map(|e| e.handle).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap();
|
||||
s.cmdline("dview").unwrap();
|
||||
s.input(Input::Pick(vec![line])).unwrap();
|
||||
assert_eq!(prompt(&s), SELECT);
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(prompt(&s), OPTION);
|
||||
let only = s.display_only().unwrap();
|
||||
assert_eq!(only.handles, vec![line]);
|
||||
assert!(only.extra.is_empty(), "no sample house with a selection");
|
||||
// Picked at the prompt, the objects don't stay selected.
|
||||
assert!(s.selection().is_empty());
|
||||
// Escape ends it too, and the drawing shows again.
|
||||
s.input(Input::Cancel).unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert!(s.display_only().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dviewblock_of_the_drawings_own_is_previewed() {
|
||||
let mut s = session();
|
||||
s.execute("circle", &json!({ "center": [0.5, 0.5], "radius": 0.5 })).unwrap();
|
||||
let c = s.doc().unwrap().model.iter().last().map(|e| e.handle).unwrap();
|
||||
s.execute("block", &json!({ "name": "DVIEWBLOCK", "base": [0, 0], "handles": [c.hex()], "keep": "delete" })).unwrap();
|
||||
s.cmdline("dview").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
let only = s.display_only().unwrap();
|
||||
assert_eq!(only.extra.len(), 1);
|
||||
match &only.extra[0].kind {
|
||||
EntityKind::Insert(i) => {
|
||||
assert_eq!(i.block, "DVIEWBLOCK");
|
||||
// Scaled from its 1 by 1 unit area to half the view's height.
|
||||
assert!(near(i.scale.x, 10.0) && near(i.scale.y, 10.0));
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
s.cmdline("").unwrap();
|
||||
}
|
||||
|
||||
/// A layout with one viewport, `(session, viewport handle)`.
|
||||
fn layout_session() -> (Session, Handle) {
|
||||
let mut s = session();
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
let r = s.execute("mview", &json!({ "p1": [20, 20], "p2": [220, 120] })).unwrap();
|
||||
let h = Handle::parse_hex(r["viewports"][0].as_str().unwrap()).unwrap();
|
||||
(s, h)
|
||||
}
|
||||
|
||||
fn vp(s: &Session, h: Handle) -> Viewport {
|
||||
match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::Viewport(v) => v.clone(),
|
||||
_ => panic!("not a viewport"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_a_layout_sheet_it_is_not_available() {
|
||||
let (mut s, _) = layout_session();
|
||||
assert_eq!(s.execute("dview", &json!({ "twist": 10 })).unwrap_err().to_string(), PAPER);
|
||||
assert_eq!(s.cmdline("dview").unwrap_err().to_string(), PAPER);
|
||||
assert!(s.current_prompt().is_none());
|
||||
// view.set can't turn the sheet either; an untwisted view is fine.
|
||||
assert_eq!(s.execute("view.set", &json!({ "center": [0, 0], "height": 5, "twist": 15 })).unwrap_err().to_string(), PAPER);
|
||||
s.execute("view.set", &json!({ "center": [0, 0], "height": 5 })).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_an_active_viewport_it_twists_that_viewport_as_one_undo_step() {
|
||||
let (mut s, h) = layout_session();
|
||||
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
let pv = s.state().unwrap().paper_view();
|
||||
s.cmdline("dview").unwrap();
|
||||
assert_eq!(prompt(&s), SELECT);
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("tw").unwrap();
|
||||
s.cmdline("30").unwrap();
|
||||
// The viewport entity carries the twist, and the model view inside it is turned by it.
|
||||
assert!(near(vp(&s, h).twist.to_degrees(), 30.0));
|
||||
assert!(near(twist_deg(&s), 30.0));
|
||||
// The sheet itself is not turned.
|
||||
assert_eq!(s.state().unwrap().paper_view(), pv);
|
||||
s.cmdline("tw").unwrap();
|
||||
s.cmdline("50").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert!(near(vp(&s, h).twist.to_degrees(), 50.0));
|
||||
// One undo step for the whole DVIEW, which turns the viewport back.
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert_eq!(vp(&s, h).twist, 0.0);
|
||||
|
||||
// The JSON form and view.set twist the viewport the same way, each one undo step.
|
||||
s.execute("dview", &json!({ "twist": -90 })).unwrap();
|
||||
assert!(near(vp(&s, h).twist.to_degrees(), 270.0));
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
|
||||
let v = view(&s);
|
||||
s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height, "twist": 15 })).unwrap();
|
||||
assert!(near(vp(&s, h).twist.to_degrees(), 15.0));
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo + 2);
|
||||
// Setting a view without a twist keeps the viewport's.
|
||||
s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height })).unwrap();
|
||||
assert!(near(vp(&s, h).twist.to_degrees(), 15.0));
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo + 2);
|
||||
|
||||
// Back on the sheet, DVIEW is not available again.
|
||||
s.execute("pspace", &json!({})).unwrap();
|
||||
assert_eq!(s.cmdline("dview").unwrap_err().to_string(), PAPER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_viewport_keeps_its_twist() {
|
||||
let (mut s, h) = layout_session();
|
||||
s.doc_mut()
|
||||
.unwrap()
|
||||
.modify_entity(h, |e| {
|
||||
if let EntityKind::Viewport(v) = &mut e.kind {
|
||||
v.locked = true;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
assert!(s.execute("dview", &json!({ "twist": 30 })).is_err());
|
||||
let v = view(&s);
|
||||
assert!(s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height, "twist": 30 })).is_err());
|
||||
assert_eq!(vp(&s, h).twist, 0.0);
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo);
|
||||
// A view with the twist it already has is fine.
|
||||
s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height })).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_inside_an_active_viewport_keeps_the_twist_as_one_undo_step() {
|
||||
let (mut s, h) = layout_session();
|
||||
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
for l in ["dview", "", "tw", "30"] {
|
||||
s.cmdline(l).unwrap();
|
||||
}
|
||||
s.input(Input::Cancel).unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
assert!(near(vp(&s, h).twist.to_degrees(), 30.0));
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert_eq!(vp(&s, h).twist, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_view_inside_a_twisted_viewport_maps_the_screen_like_the_sheet() {
|
||||
let (mut s, h) = layout_session();
|
||||
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
|
||||
s.execute("dview", &json!({ "twist": 30 })).unwrap();
|
||||
let st = s.state().unwrap();
|
||||
let (vpe, pv, v) = (vp(&s, h), st.paper_view(), st.view());
|
||||
// The model view's centre is the model point at the sheet's view centre.
|
||||
assert!((vpe.model_to_paper(v.center) - pv.center).len() < 1e-9);
|
||||
assert!(near(v.twist, vpe.twist));
|
||||
// Panning the view inside the viewport moves its model view, keeping the twist.
|
||||
let moved = View { center: v.center + Vec2::new(5.0, -2.0), ..v };
|
||||
s.state_mut().unwrap().set_view(moved);
|
||||
let after = view(&s);
|
||||
assert!((after.center - moved.center).len() < 1e-9, "{after:?}");
|
||||
assert!(near(after.twist, vpe.twist));
|
||||
assert_eq!(s.state().unwrap().paper_view(), pv);
|
||||
// ZOOM Extents fits the turned box of the drawing in the viewport.
|
||||
s.execute("zoom", &json!({ "mode": "extents" })).unwrap();
|
||||
assert!(near(view(&s).twist, vpe.twist));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_viewport_twist_survives_a_dxf_round_trip() {
|
||||
let (mut s, h) = layout_session();
|
||||
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
|
||||
s.execute("dview", &json!({ "twist": 30 })).unwrap();
|
||||
let dxf = cadcraft_io::write_dxf(s.doc().unwrap());
|
||||
let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
|
||||
let twists: Vec<f64> = back
|
||||
.layouts
|
||||
.iter()
|
||||
.flat_map(|l| l.entities.iter())
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Viewport(v) if v.id != 1 => Some(v.twist.to_degrees()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(twists.len(), 1);
|
||||
assert!((twists[0] - 30.0).abs() < 1e-9, "{twists:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_view_saved_in_a_twisted_viewport_restores_its_twist_as_one_undo_step() {
|
||||
let (mut s, h) = layout_session();
|
||||
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
|
||||
s.execute("dview", &json!({ "twist": 30 })).unwrap();
|
||||
s.execute("view", &json!({ "option": "save", "name": "Turned" })).unwrap();
|
||||
let saved = s.doc().unwrap().views.iter().find(|v| v.name == "Turned").map(|v| v.twist.to_degrees()).unwrap();
|
||||
assert!(near(saved, 30.0), "saved twist {saved}");
|
||||
let center = vp(&s, h).view_center;
|
||||
s.execute("dview", &json!({ "twist": 0 })).unwrap();
|
||||
s.execute("view.set", &json!({ "center": [80, 40], "height": 3 })).unwrap();
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
s.execute("view", &json!({ "option": "restore", "name": "Turned" })).unwrap();
|
||||
assert!(near(vp(&s, h).twist.to_degrees(), 30.0), "viewport twist {}", vp(&s, h).twist.to_degrees());
|
||||
assert!(near(twist_deg(&s), 30.0));
|
||||
assert!((vp(&s, h).view_center - center).len() < 1e-6, "{:?} vs {center:?}", vp(&s, h).view_center);
|
||||
// The viewport's twist changed: one undo step, which turns it back.
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert_eq!(vp(&s, h).twist, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_a_drawing_change_keeps_the_untiled_twist_saved() {
|
||||
let mut s = session();
|
||||
s.execute("line", &json!({ "points": [[0, 10], [5, 10]] })).unwrap();
|
||||
// A Model tab twist is a view change, not an undo step.
|
||||
s.execute("dview", &json!({ "twist": 30 })).unwrap();
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(near(twist_deg(&s), 30.0));
|
||||
let saved = |s: &Session| s.doc().unwrap().vports.iter().find(|c| c.is_active()).and_then(|c| c.tiles.first()).map(|t| t.twist.to_degrees());
|
||||
assert!(saved(&s).is_some_and(|t| near(t, 30.0)), "{:?}", saved(&s));
|
||||
s.execute("redo", &json!({})).unwrap();
|
||||
assert!(saved(&s).is_some_and(|t| near(t, 30.0)), "{:?}", saved(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leftward_drag_on_a_turned_screen_is_a_crossing_window() {
|
||||
let mut s = session();
|
||||
let r = s.execute("line", &json!({ "points": [[200, 0], [260, 0]] })).unwrap();
|
||||
let line = Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap();
|
||||
s.execute("dview", &json!({ "twist": 180 })).unwrap();
|
||||
// Turned half way round, a drag towards +X in the drawing runs leftwards on screen.
|
||||
assert!(s.window_is_crossing(Vec2::new(210.0, -5.0), Vec2::new(230.0, 5.0)));
|
||||
assert!(!s.window_is_crossing(Vec2::new(230.0, -5.0), Vec2::new(210.0, 5.0)));
|
||||
// A crossing window takes the line it only partly covers.
|
||||
s.idle_click(Vec2::new(210.0, -5.0), false).unwrap();
|
||||
s.idle_click(Vec2::new(230.0, 5.0), false).unwrap();
|
||||
assert_eq!(s.selection(), vec![line]);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! Raster images (#956): IMAGEATTACH from a file and from handed-over bytes, the pixels loaded
|
||||
//! again when the drawing is reopened, a missing file left as a frame, and bad input refused.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, base64_encode, set_image_decoder, set_io};
|
||||
use cadcraft_engine::doc::{Drawing, EntityKind, Image, Raster};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn hooks() {
|
||||
set_io(IoHooks {
|
||||
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
|
||||
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
|
||||
plot: None,
|
||||
});
|
||||
set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string()));
|
||||
}
|
||||
|
||||
/// A fresh folder for one test.
|
||||
fn folder(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-image-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A PNG file of 4 x 2 pixels.
|
||||
fn png() -> Vec<u8> {
|
||||
let px: Vec<u8> = (0..8u8).flat_map(|i| [i * 30, 255 - i * 30, 7, 255]).collect();
|
||||
cadcraft_io::raster::encode_png(&Raster::new(4, 2, px).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn images(s: &Session) -> Vec<Image> {
|
||||
let d = s.doc().unwrap();
|
||||
d.model.iter().filter_map(|e| if let EntityKind::Image(i) = &e.kind { Some(i.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, path: &Path) {
|
||||
s.execute("open", &json!({ "path": path.to_string_lossy() })).unwrap();
|
||||
}
|
||||
|
||||
fn listed(s: &mut Session) -> Value {
|
||||
s.execute("image", &json!({})).unwrap()["images"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_from_bytes_places_the_image() {
|
||||
hooks();
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("imageattach", &json!({ "path": "maps/site.png", "data": base64_encode(&png()), "at": [10, 20], "width": 100 })).unwrap();
|
||||
assert_eq!((r["name"].as_str(), r["pixels"].clone(), r["size"].clone()), (Some("site"), json!([4.0, 2.0]), json!([100.0, 50.0])));
|
||||
let i = images(&s).pop().unwrap();
|
||||
assert_eq!((i.insert.x, i.insert.y, i.u.x, i.u.y, i.v.x, i.v.y), (10.0, 20.0, 25.0, 0.0, 0.0, 25.0));
|
||||
assert_eq!((i.size.x, i.size.y, i.path.as_str(), i.name.as_str()), (4.0, 2.0, "maps/site.png", "site"));
|
||||
assert_eq!(s.doc().unwrap().rasters.get("maps/site.png").map(|r| (r.width(), r.height())), Some((4, 2)));
|
||||
let l = listed(&mut s);
|
||||
assert_eq!((l[0]["status"].as_str(), l[0]["references"].as_u64(), l[0]["pixels"].clone()), (Some("Loaded"), Some(1), json!([4, 2])));
|
||||
|
||||
// A second reference to the same file shares the definition and the pixels; another file
|
||||
// with the same file name gets its own name.
|
||||
s.execute("imageattach", &json!({ "path": "maps/site.png", "at": [200, 0], "rotation": 90, "scale": 8 })).unwrap();
|
||||
let r = s.execute("attach", &json!({ "path": "other/site.png", "data": base64_encode(&png()) })).unwrap();
|
||||
assert_eq!(r["name"], "site_2");
|
||||
let all = images(&s);
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(all[1].name, "site");
|
||||
assert!((all[1].u.x).abs() < 1e-12 && (all[1].u.y - 2.0).abs() < 1e-12 && (all[1].v.x + 2.0).abs() < 1e-12, "{:?}", all[1]);
|
||||
assert_eq!(listed(&mut s)[0]["references"], 2);
|
||||
|
||||
// Undo takes the reference away; the drawing draws with its pixels.
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert_eq!(images(&s).len(), 2);
|
||||
let list = cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default());
|
||||
assert_eq!(list.images.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_is_found_next_to_the_drawing_and_loaded_again_on_open() {
|
||||
hooks();
|
||||
let dir = folder("file");
|
||||
std::fs::write(dir.join("map.png"), png()).unwrap();
|
||||
let host = dir.join("host.dxf");
|
||||
std::fs::write(&host, cadcraft_io::write(&Drawing::new_metric(), "host.dxf").unwrap()).unwrap();
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &host);
|
||||
let r = s.execute("imageattach", &json!({ "path": "map.png" })).unwrap();
|
||||
assert_eq!(r["size"], json!([1.0, 0.5]), "one drawing unit wide by default");
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
|
||||
// Reopened: the pixels come back from the file.
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &host);
|
||||
assert_eq!(images(&s).len(), 1);
|
||||
assert!(s.doc().unwrap().rasters.get("map.png").is_some());
|
||||
assert!(!s.state().unwrap().is_dirty(), "loading images is not a change");
|
||||
let l = listed(&mut s);
|
||||
assert_eq!(l[0]["status"], "Loaded");
|
||||
assert!(l[0]["foundAt"].as_str().is_some_and(|f| f.ends_with("map.png")));
|
||||
|
||||
// The file gone: the drawing still opens, says so, and keeps the reference.
|
||||
std::fs::remove_file(dir.join("map.png")).unwrap();
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &host);
|
||||
assert_eq!(images(&s).len(), 1);
|
||||
assert!(s.doc().unwrap().rasters.get("map.png").is_none());
|
||||
assert!(s.log.iter().any(|l| l.contains("map.png") && l.contains("file not found")), "{:?}", s.log);
|
||||
assert_eq!(listed(&mut s)[0]["status"], "Not Found");
|
||||
|
||||
// A file that is not an image is reported the same way.
|
||||
std::fs::write(dir.join("map.png"), b"not an image").unwrap();
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &host);
|
||||
assert!(s.doc().unwrap().rasters.get("map.png").is_none());
|
||||
assert!(s.log.iter().any(|l| l.contains("map.png")), "{:?}", s.log);
|
||||
assert_eq!(listed(&mut s)[0]["status"], "Not loaded");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_is_refused_and_changes_nothing() {
|
||||
hooks();
|
||||
let mut s = Session::new();
|
||||
let data = base64_encode(&png());
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({ "path": " " }),
|
||||
json!({ "path": "nowhere/missing.png" }),
|
||||
json!({ "path": "a.png", "data": "***" }),
|
||||
json!({ "path": "a.png", "data": 5 }),
|
||||
json!({ "path": "a.png", "data": base64_encode(b"plain text") }),
|
||||
json!({ "path": "a.png", "data": data, "scale": 0 }),
|
||||
json!({ "path": "a.png", "data": data, "width": -3 }),
|
||||
json!({ "path": "a.png", "data": data, "width": "wide" }),
|
||||
json!({ "path": "a.png", "data": data, "width": 1e-320 }),
|
||||
json!({ "path": "x".repeat(5000), "data": data }),
|
||||
] {
|
||||
assert!(s.execute("imageattach", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(images(&s).is_empty());
|
||||
assert!(s.execute("attach", &json!({ "path": "plan.pdf" })).unwrap_err().to_string().contains("not available yet"));
|
||||
assert_eq!(listed(&mut s), json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_among_many_objects_keeps_its_pixels() {
|
||||
// More objects than one build task takes: the display list is built in parts and joined,
|
||||
// and the image must still point at its own pixels.
|
||||
hooks();
|
||||
let mut s = Session::new();
|
||||
for i in 0..700 {
|
||||
s.execute("line", &json!({ "points": [[i, 0], [i, 1]] })).unwrap();
|
||||
}
|
||||
s.execute("imageattach", &json!({ "path": "a.png", "data": base64_encode(&png()), "at": [0, 5], "width": 4 })).unwrap();
|
||||
for i in 0..700 {
|
||||
s.execute("line", &json!({ "points": [[i, 2], [i, 3]] })).unwrap();
|
||||
}
|
||||
s.execute("imageattach", &json!({ "path": "b.png", "data": base64_encode(&png()), "at": [10, 5], "width": 8 })).unwrap();
|
||||
let list = cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default());
|
||||
let widths: Vec<f64> = list
|
||||
.prims
|
||||
.iter()
|
||||
.filter_map(|p| match p.kind {
|
||||
cadcraft_engine::render::Kind::Image { index } => list.images.get(index as usize).map(|i| i.right.x * 4.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(widths, [4.0, 8.0]);
|
||||
}
|
||||
|
||||
fn drawn_images(s: &Session) -> usize {
|
||||
cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default()).images.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_commands_work_on_images_and_undo_keeps_the_pixels() {
|
||||
hooks();
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("imageattach", &json!({ "path": "a.png", "data": base64_encode(&png()), "at": [10, 0], "width": 4 })).unwrap();
|
||||
let h = r["handle"].as_str().unwrap().to_string();
|
||||
|
||||
// Move, then a quarter turn about the origin: the corner and both axes follow.
|
||||
s.execute("move", &json!({ "handles": [h], "delta": [0, 5] })).unwrap();
|
||||
s.execute("rotate", &json!({ "handles": [h], "base": [0, 0], "angle": 90 })).unwrap();
|
||||
let i = images(&s).pop().unwrap();
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
assert!(close(i.insert.x, -5.0) && close(i.insert.y, 10.0), "{i:?}");
|
||||
assert!(close(i.u.x, 0.0) && close(i.u.y, 1.0) && close(i.v.x, -1.0) && close(i.v.y, 0.0), "{i:?}");
|
||||
assert_eq!(drawn_images(&s), 1);
|
||||
|
||||
// A copy is a second reference to the same file.
|
||||
s.execute("copy", &json!({ "handles": [h], "delta": [20, 0] })).unwrap();
|
||||
assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2));
|
||||
assert_eq!(listed(&mut s)[0]["references"], 2);
|
||||
assert_eq!(listed(&mut s).as_array().map(Vec::len), Some(1));
|
||||
|
||||
// Scaled about its corner: twice the size, still the same pixels.
|
||||
s.execute("scale", &json!({ "handles": [h], "base": [-5, 10], "factor": 2 })).unwrap();
|
||||
let i = images(&s).into_iter().find(|i| close(i.insert.x, -5.0)).unwrap();
|
||||
assert!(close(i.u.y, 2.0) && close(i.size.x, 4.0), "{i:?}");
|
||||
|
||||
// Erased, then brought back by undo, with its picture; redo erases again.
|
||||
s.execute("erase", &json!({ "handles": [h] })).unwrap();
|
||||
assert_eq!((images(&s).len(), drawn_images(&s)), (1, 1));
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2));
|
||||
s.execute("redo", &json!({})).unwrap();
|
||||
assert_eq!(images(&s).len(), 1);
|
||||
|
||||
// Everything undone: no image, and nothing left to draw.
|
||||
for _ in 0..10 {
|
||||
let _ = s.execute("undo", &json!({}));
|
||||
}
|
||||
assert!(images(&s).is_empty());
|
||||
assert_eq!(drawn_images(&s), 0);
|
||||
assert_eq!(listed(&mut s), json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_alias_and_the_command_line_reach_the_command() {
|
||||
hooks();
|
||||
assert_eq!(cadcraft_engine::cmd::resolve_alias("IAT"), "imageattach");
|
||||
assert_eq!(cadcraft_engine::cmd::resolve_alias("im"), "image");
|
||||
let spec = |id: &str| cadcraft_engine::cmd::command_specs().iter().find(|c| c.id == id).unwrap();
|
||||
assert!(!spec("imageattach").params.is_empty() && !spec("image").params.is_empty());
|
||||
// Typed without a file it has nothing to attach: an error, not a crash or a stuck prompt.
|
||||
let mut s = Session::new();
|
||||
let _ = s.cmdline("imageattach");
|
||||
let _ = s.cmdline("");
|
||||
s.cancel();
|
||||
assert!(s.running.is_none() && images(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_in_blocks_and_layouts_are_loaded_on_open() {
|
||||
use cadcraft_engine::doc::geom::{Vec2, Vec3};
|
||||
use cadcraft_engine::doc::{Block, Common, Entity, Space};
|
||||
hooks();
|
||||
let dir = folder("spaces");
|
||||
std::fs::create_dir_all(dir.join("maps")).unwrap();
|
||||
std::fs::write(dir.join("maps").join("a.png"), png()).unwrap();
|
||||
std::fs::write(dir.join("b.png"), png()).unwrap();
|
||||
let image = |path: &str, name: &str| {
|
||||
EntityKind::Image(Image {
|
||||
u: Vec3::new(1.0, 0.0, 0.0),
|
||||
v: Vec3::new(0.0, 1.0, 0.0),
|
||||
size: Vec2::new(4.0, 2.0),
|
||||
path: path.into(),
|
||||
name: name.into(),
|
||||
..Image::default()
|
||||
})
|
||||
};
|
||||
let mut d = Drawing::new_metric();
|
||||
// The same file in model space, in a block and on a layout; another only on the layout,
|
||||
// saved with a folder that no longer exists (found by its name next to the drawing); and
|
||||
// one that is nowhere.
|
||||
d.add(&Space::Model, Common::default(), image("maps/a.png", "a")).unwrap();
|
||||
let mut blk = Block::new("MAP");
|
||||
let bh = d.new_handle();
|
||||
blk.entities.push(Entity::new(bh, image("maps/a.png", "a")));
|
||||
d.blocks.insert("MAP".into(), std::sync::Arc::new(blk));
|
||||
let layout = Space::Paper("Layout1".into());
|
||||
d.add(&layout, Common::default(), image("maps/a.png", "a")).unwrap();
|
||||
d.add(&layout, Common::default(), image("C:\\old\\place\\b.png", "b")).unwrap();
|
||||
d.add(&layout, Common::default(), image("gone.png", "gone")).unwrap();
|
||||
let host = dir.join("host.dxf");
|
||||
std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap();
|
||||
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &host);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.rasters.len(), 2, "each file once");
|
||||
assert!(doc.rasters.get("maps/a.png").is_some() && doc.rasters.get("C:\\old\\place\\b.png").is_some());
|
||||
assert!(!s.state().unwrap().is_dirty());
|
||||
assert_eq!(s.log.iter().filter(|l| l.starts_with("Image ")).count(), 1, "{:?}", s.log);
|
||||
let l = listed(&mut s);
|
||||
let row = |name: &str| l.as_array().unwrap().iter().find(|r| r["name"] == name).cloned().unwrap();
|
||||
assert_eq!((row("a")["status"].clone(), row("a")["references"].clone()), (json!("Loaded"), json!(3)));
|
||||
assert_eq!(row("b")["status"], "Loaded");
|
||||
assert_eq!(row("gone")["status"], "Not Found");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawing_opened_from_bytes_gets_its_pixels_handed_over() {
|
||||
// The web app's way: no file system, the drawing arrives as bytes and so do the images.
|
||||
use cadcraft_engine::doc::geom::{Vec2, Vec3};
|
||||
use cadcraft_engine::doc::{Common, Space};
|
||||
hooks();
|
||||
let mut d = Drawing::new_metric();
|
||||
let i = Image {
|
||||
u: Vec3::new(1.0, 0.0, 0.0),
|
||||
v: Vec3::new(0.0, 1.0, 0.0),
|
||||
size: Vec2::new(4.0, 2.0),
|
||||
path: "no-such-folder/web.png".into(),
|
||||
name: "web".into(),
|
||||
..Image::default()
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap();
|
||||
let dxf = cadcraft_io::write(&d, "plan.dxf").unwrap();
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("open", &json!({ "data": base64_encode(&dxf), "name": "plan.dxf" })).unwrap();
|
||||
assert_eq!(images(&s).len(), 1, "opened with its image reference");
|
||||
assert!(s.doc().unwrap().rasters.is_empty());
|
||||
assert!(s.log.iter().any(|l| l.contains("web.png")), "{:?}", s.log);
|
||||
assert_eq!(listed(&mut s)[0]["status"], "Not Found");
|
||||
assert_eq!(drawn_images(&s), 0);
|
||||
|
||||
// The same file handed over as bytes: the reference that was there draws too.
|
||||
s.execute("imageattach", &json!({ "path": "no-such-folder/web.png", "data": base64_encode(&png()), "at": [10, 0] })).unwrap();
|
||||
assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2));
|
||||
let l = listed(&mut s);
|
||||
assert_eq!((l[0]["status"].clone(), l[0]["references"].clone(), l[0]["name"].clone()), (json!("Loaded"), json!(2), json!("web")));
|
||||
|
||||
// Saved and read back: the references are kept, the pixels are not in the file.
|
||||
let bytes = cadcraft_io::write(s.doc().unwrap(), "plan.dxf").unwrap();
|
||||
let back = cadcraft_io::read(&bytes, "plan.dxf").unwrap();
|
||||
assert!(back.rasters.is_empty());
|
||||
assert_eq!(back.model.iter().filter(|e| matches!(e.kind, EntityKind::Image(_))).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_files_are_loaded_together_and_reported_in_order() {
|
||||
// More files than loaders, with missing and unreadable ones among them: each good file is
|
||||
// loaded, each bad one reported once, in the order the drawing uses them.
|
||||
use cadcraft_engine::doc::geom::{Vec2, Vec3};
|
||||
use cadcraft_engine::doc::{Common, Space};
|
||||
hooks();
|
||||
let dir = folder("many");
|
||||
let mut d = Drawing::new_metric();
|
||||
let mut expected_notes = Vec::new();
|
||||
for n in 0..60u32 {
|
||||
let name = format!("tile{n:02}.png");
|
||||
match n % 10 {
|
||||
3 => expected_notes.push(format!("Image {name}: file not found")),
|
||||
7 => {
|
||||
std::fs::write(dir.join(&name), b"not an image").unwrap();
|
||||
expected_notes.push(format!("Image {name}: "));
|
||||
}
|
||||
_ => {
|
||||
// A different width per file, so a mix-up would show.
|
||||
let px = vec![200u8; (n as usize + 1) * 4];
|
||||
std::fs::write(dir.join(&name), cadcraft_io::raster::encode_png(&Raster::new(n + 1, 1, px).unwrap()).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
let i = Image {
|
||||
insert: Vec3::new(f64::from(n) * 3.0, 0.0, 0.0),
|
||||
u: Vec3::new(1.0, 0.0, 0.0),
|
||||
v: Vec3::new(0.0, 1.0, 0.0),
|
||||
size: Vec2::new(f64::from(n + 1), 1.0),
|
||||
path: name.clone(),
|
||||
name: format!("tile{n:02}"),
|
||||
..Image::default()
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap();
|
||||
}
|
||||
let host = dir.join("host.dxf");
|
||||
std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap();
|
||||
|
||||
for _ in 0..3 {
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &host);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.rasters.len(), 48);
|
||||
for n in 0..60u32 {
|
||||
let loaded = doc.rasters.get(&format!("tile{n:02}.png")).map(|r| r.width());
|
||||
assert_eq!(loaded, if matches!(n % 10, 3 | 7) { None } else { Some(n + 1) }, "tile {n}");
|
||||
}
|
||||
let notes: Vec<&String> = s.log.iter().filter(|l| l.starts_with("Image ")).collect();
|
||||
assert_eq!(notes.len(), expected_notes.len(), "{notes:?}");
|
||||
assert!(notes.iter().zip(&expected_notes).all(|(got, want)| got.starts_with(want.as_str())), "{notes:?}");
|
||||
assert!(!s.state().unwrap().is_dirty());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! A raster image decoder that panics on a damaged file fails that image only (#956): the
|
||||
//! drawing it belongs to still opens, and IMAGEATTACH reports an error.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, base64_encode, set_image_decoder, set_io};
|
||||
use cadcraft_engine::doc::geom::{Vec2, Vec3};
|
||||
use cadcraft_engine::doc::{Common, Drawing, EntityKind, Image, Line, Space};
|
||||
use serde_json::json;
|
||||
|
||||
fn hooks() {
|
||||
set_io(IoHooks {
|
||||
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
|
||||
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
|
||||
plot: None,
|
||||
});
|
||||
set_image_decoder(|_| panic!("decoder bug"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panicking_decoder_fails_the_image_not_the_drawing() {
|
||||
hooks();
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-image-panic-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("map.png"), b"anything").unwrap();
|
||||
let mut d = Drawing::new_metric();
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(5.0, 5.0, 0.0) })).unwrap();
|
||||
let i = Image {
|
||||
u: Vec3::new(1.0, 0.0, 0.0),
|
||||
v: Vec3::new(0.0, 1.0, 0.0),
|
||||
size: Vec2::new(4.0, 2.0),
|
||||
path: "map.png".into(),
|
||||
name: "map".into(),
|
||||
..Image::default()
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap();
|
||||
let host = dir.join("host.dxf");
|
||||
std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap();
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("open", &json!({ "path": host.to_string_lossy() })).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.model.len(), 2, "the drawing opened with everything in it");
|
||||
assert!(doc.rasters.is_empty());
|
||||
assert!(s.log.iter().any(|l| l.contains("map.png") && l.contains("damaged")), "{:?}", s.log);
|
||||
|
||||
let before = s.doc().unwrap().model.len();
|
||||
let e = s.execute("imageattach", &json!({ "path": "other.png", "data": base64_encode(b"bytes") })).unwrap_err().to_string();
|
||||
assert!(e.contains("damaged"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().model.len(), before);
|
||||
// The session still works.
|
||||
s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), before + 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -135,9 +135,9 @@ fn mview_restore_puts_a_saved_configuration_on_a_layout() {
|
||||
fn mview_restore_ignores_unusable_tiles_from_files() {
|
||||
let (mut s, _) = two_tiles();
|
||||
let bad = [
|
||||
VportTile { min: v(f64::NAN, 0.0), max: v(1.0, 1.0), center: v(0.0, 0.0), height: 1.0, aspect: 1.0 },
|
||||
VportTile { min: v(0.5, 0.5), max: v(0.5, 0.9), center: v(0.0, 0.0), height: 1.0, aspect: 1.0 },
|
||||
VportTile { min: v(-3.0, -3.0), max: v(9.0, 9.0), center: v(f64::INFINITY, 0.0), height: -1.0, aspect: f64::NAN },
|
||||
VportTile { min: v(f64::NAN, 0.0), max: v(1.0, 1.0), center: v(0.0, 0.0), height: 1.0, aspect: 1.0, twist: 0.0 },
|
||||
VportTile { min: v(0.5, 0.5), max: v(0.5, 0.9), center: v(0.0, 0.0), height: 1.0, aspect: 1.0, twist: 0.0 },
|
||||
VportTile { min: v(-3.0, -3.0), max: v(9.0, 9.0), center: v(f64::INFINITY, 0.0), height: -1.0, aspect: f64::NAN, twist: 0.0 },
|
||||
];
|
||||
if let Some(c) = s.doc_mut().unwrap().vports.iter_mut().find(|c| c.name == "Two") {
|
||||
c.tiles = bad.to_vec();
|
||||
|
||||
@@ -81,6 +81,7 @@ mod malformed_handles;
|
||||
mod matchprop;
|
||||
mod measure_distance_snapping;
|
||||
mod metric_linetypes;
|
||||
mod minsert;
|
||||
mod mirror_degenerate_line;
|
||||
mod mleader_content;
|
||||
mod mleader_remember;
|
||||
|
||||
@@ -27,6 +27,7 @@ fn drawing() -> (Drawing, Space) {
|
||||
layer_colors: Vec::new(),
|
||||
clip: None,
|
||||
on: true,
|
||||
twist: 0.0,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! MINSERT, one block reference repeated in rows and columns, as AutoCAD 2024 makes and edits it
|
||||
//! (accoreconsole: the prompts, and `entget` of the reference after MINSERT, EXPLODE, MIRROR,
|
||||
//! SCALE and ROTATE).
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Insert};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn handle(v: &Value) -> String {
|
||||
v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap().to_owned()
|
||||
}
|
||||
|
||||
/// A session with block B (a line from (0, 0) to (10, 0) and a circle of radius 3 round (5, 5),
|
||||
/// base point (0, 0)), after the AutoLISP `code`.
|
||||
fn run(code: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
let a = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
let c = handle(&s.execute("circle", &json!({"center": [5, 5], "radius": 3})).unwrap());
|
||||
s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [a, c]})).unwrap();
|
||||
s.cmdline(code).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
s
|
||||
}
|
||||
|
||||
fn inserts(s: &Session) -> Vec<Insert> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => Some(i.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The grid as `entget` gives it: 70 columns, 71 rows, 44 between columns, 45 between rows (a
|
||||
/// plain reference, one row and one column, as 1/1/0/0).
|
||||
fn grid(i: &Insert) -> (u32, u32, f64, f64) {
|
||||
(i.cols, i.rows, i.col_spacing, i.row_spacing)
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grid_is_stored_as_autocad_2024_stores_it() {
|
||||
// (MINSERT's answers after the block name and insertion point, the grid AutoCAD stored.)
|
||||
let cases = [
|
||||
// One row: no distance between rows asked, and 0 stored for it.
|
||||
("1 \"\" 0 1 3 20.0", (3, 1, 20.0, 0.0)),
|
||||
("1 \"\" 0 3 1 15.0", (1, 3, 0.0, 15.0)),
|
||||
// One of each: a plain reference.
|
||||
("1 \"\" 0 1 1", (1, 1, 0.0, 0.0)),
|
||||
// A unit cell: its width between columns, its height between rows, signed.
|
||||
("1 \"\" 0 2 3 '(0.0 0.0) '(25.0 12.0)", (3, 2, 25.0, 12.0)),
|
||||
("1 \"\" 0 2 3 '(25.0 12.0) '(0.0 0.0)", (3, 2, -25.0, -12.0)),
|
||||
// Its sides along the drawing's axes, the reference rotated or not.
|
||||
("1 \"\" 30 2 3 '(0.0 0.0) '(25.0 12.0)", (3, 2, 25.0, 12.0)),
|
||||
// Typed distances are kept as typed, negative or 0 too.
|
||||
("1 \"\" 0 2 2 -15.0 -20.0", (2, 2, -20.0, -15.0)),
|
||||
("1 \"\" 0 2 2 0.0 20.0", (2, 2, 20.0, 0.0)),
|
||||
// The distances are not scaled with the reference.
|
||||
("2 \"\" 30 2 2 15.0 20.0", (2, 2, 20.0, 15.0)),
|
||||
];
|
||||
for (answers, want) in cases {
|
||||
let s = run(&format!("(command \"_.MINSERT\" \"B\" '(20.0 10.0) {answers})"));
|
||||
let [i] = inserts(&s).try_into().unwrap();
|
||||
let got = grid(&i);
|
||||
assert!(got.0 == want.0 && got.1 == want.1 && near(got.2, want.2) && near(got.3, want.3), "{answers}: {got:?}");
|
||||
assert!(near(i.insert.x, 20.0) && near(i.insert.y, 10.0));
|
||||
}
|
||||
let s = run("(command \"_.MINSERT\" \"B\" '(20.0 10.0) 2 \"\" 30 2 2 15.0 20.0)");
|
||||
let [i] = inserts(&s).try_into().unwrap();
|
||||
assert!(near(i.scale.x, 2.0) && near(i.scale.y, 2.0) && near(i.rotation, PI / 6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prompts_follow_autocad_2024() {
|
||||
let mut s = Session::new();
|
||||
let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [l]})).unwrap();
|
||||
// Each prompt, and what was written to the command line on the way to it.
|
||||
let mut asked = Vec::new();
|
||||
for input in ["minsert", "B", "20,10", "1", "", "0", "0", "2", "3", "x", "15", "20"] {
|
||||
let logged = s.log.len();
|
||||
s.cmdline(input).unwrap();
|
||||
asked.extend(s.log.iter().skip(logged).filter(|l| l.starts_with("Requires")).cloned());
|
||||
if let Some(p) = s.current_prompt() {
|
||||
asked.push(p.display());
|
||||
}
|
||||
}
|
||||
// The grid's prompts and messages are AutoCAD 2024's; the ones before are INSERT's.
|
||||
assert_eq!(
|
||||
asked,
|
||||
[
|
||||
"Enter block name or [?]:",
|
||||
"Specify insertion point or [Basepoint/Scale/X/Y/Z/Rotate]:",
|
||||
"Enter X scale factor, specify opposite corner or [Corner/XYZ] <1>:",
|
||||
"Enter Y scale factor <use X scale factor>:",
|
||||
"Specify rotation angle <0>:",
|
||||
"Enter number of rows (---) <1>:",
|
||||
"Requires an integer between 1 and 32767.",
|
||||
"Enter number of rows (---) <1>:",
|
||||
"Enter number of columns (|||) <1>:",
|
||||
"Enter distance between rows or specify unit cell (---):",
|
||||
"Requires a distance.",
|
||||
"Enter distance between rows or specify unit cell (---):",
|
||||
"Specify distance between columns (|||):",
|
||||
]
|
||||
);
|
||||
assert!(s.running.is_none());
|
||||
let [i] = inserts(&s).try_into().unwrap();
|
||||
assert_eq!(grid(&i), (3, 2, 20.0, 15.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_run_from_1_to_32767() {
|
||||
// AutoCAD 2024: "Requires an integer between 1 and 32767."
|
||||
let mut s = Session::new();
|
||||
let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [l]})).unwrap();
|
||||
for input in ["minsert", "B", "0,0", "1", "", "0", "32768", "32767", "1", "2"] {
|
||||
s.cmdline(input).unwrap();
|
||||
}
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(s.log.iter().filter(|l| l.starts_with("Requires an integer between 1 and 32767.")).count(), 1, "{:?}", s.log);
|
||||
let [i] = inserts(&s).try_into().unwrap();
|
||||
assert_eq!(grid(&i), (1, 32767, 0.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribute_values_are_asked_once_after_the_grid() {
|
||||
let mut s = Session::new();
|
||||
let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
let a = handle(&s.execute("attdef", &json!({"tag": "TAG", "prompt": "Value", "at": [0, 2], "height": 1})).unwrap());
|
||||
s.execute("-block", &json!({"name": "BA", "base": [0, 0], "handles": [l, a]})).unwrap();
|
||||
for input in ["minsert", "BA", "0,700", "1", "", "0", "5", "4", "15", "20"] {
|
||||
s.cmdline(input).unwrap();
|
||||
}
|
||||
// After the grid, with no "<>" for an empty default (AutoCAD 2024: "Value: hello").
|
||||
assert_eq!(s.current_prompt().unwrap().display(), "Value:");
|
||||
s.cmdline("hello").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let [i] = inserts(&s).try_into().unwrap();
|
||||
assert_eq!(grid(&i), (4, 5, 20.0, 15.0));
|
||||
assert_eq!(i.attribs.iter().map(|a| a.text.value.as_str()).collect::<Vec<_>>(), ["hello"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explode_leaves_a_grid_whole() {
|
||||
let s = run("(progn (command \"_.MINSERT\" \"B\" '(20.0 10.0) 1 \"\" 0 2 2 15.0 20.0) (command \"_.EXPLODE\" (entlast) \"\"))");
|
||||
let [i] = inserts(&s).try_into().unwrap();
|
||||
assert_eq!(grid(&i), (2, 2, 20.0, 15.0));
|
||||
assert_eq!(s.doc().unwrap().model.iter().count(), 1);
|
||||
assert!(s.log.iter().any(|l| l.contains("The object is minserted.")), "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_carry_the_grid_as_in_autocad_2024() {
|
||||
let made = "(command \"_.MINSERT\" \"B\" '(20.0 10.0) 1 \"\" 0 2 2 15.0 20.0)";
|
||||
// (the edit, the copy it leaves last: insertion point, X and Y scale, rotation, grid)
|
||||
let cases = [
|
||||
("(command \"_.SCALE\" (entlast) \"\" '(20.0 10.0) 2)", ([20.0, 10.0], [2.0, 2.0], 0.0, (2, 2, 40.0, 30.0))),
|
||||
("(command \"_.ROTATE\" (entlast) \"\" '(20.0 10.0) 30)", ([20.0, 10.0], [1.0, 1.0], PI / 6.0, (2, 2, 20.0, 15.0))),
|
||||
("(command \"_.MOVE\" (entlast) \"\" '(20.0 10.0) '(25.0 13.0))", ([25.0, 13.0], [1.0, 1.0], 0.0, (2, 2, 20.0, 15.0))),
|
||||
// Mirrored about the vertical through the insertion point, and about the horizontal: X
|
||||
// scale -1, and the columns run the other way.
|
||||
("(command \"_.MIRROR\" (entlast) \"\" '(20.0 10.0) '(20.0 30.0) \"_N\")", ([20.0, 10.0], [-1.0, 1.0], 0.0, (2, 2, -20.0, 15.0))),
|
||||
("(command \"_.MIRROR\" (entlast) \"\" '(20.0 10.0) '(40.0 10.0) \"_N\")", ([20.0, 10.0], [-1.0, 1.0], PI, (2, 2, -20.0, 15.0))),
|
||||
];
|
||||
for (edit, (at, scale, rot, want)) in cases {
|
||||
let s = run(&format!("(progn {made} {edit})"));
|
||||
let i = inserts(&s).pop().unwrap();
|
||||
let got = grid(&i);
|
||||
assert!(
|
||||
near(i.insert.x, at[0])
|
||||
&& near(i.insert.y, at[1])
|
||||
&& near(i.scale.x, scale[0])
|
||||
&& near(i.scale.y, scale[1])
|
||||
&& near(i.rotation, rot)
|
||||
&& got.0 == want.0
|
||||
&& got.1 == want.1
|
||||
&& near(got.2, want.2)
|
||||
&& near(got.3, want.3),
|
||||
"{edit}: {i:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_json_form_makes_the_same_grid() {
|
||||
let mut s = Session::new();
|
||||
let l = handle(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
s.execute("-block", &json!({"name": "B", "base": [0, 0], "handles": [l]})).unwrap();
|
||||
s.execute("minsert", &json!({"name": "B", "at": [20, 10], "rows": 2, "columns": 3, "rowSpacing": 15, "columnSpacing": 20})).unwrap();
|
||||
// One row: its distance is not kept.
|
||||
s.execute("minsert", &json!({"name": "B", "at": [20, 100], "rows": 1, "columns": 3, "rowSpacing": 15, "columnSpacing": 20})).unwrap();
|
||||
let got: Vec<_> = inserts(&s).iter().map(grid).collect();
|
||||
assert_eq!(got, [(3, 2, 20.0, 15.0), (3, 1, 20.0, 0.0)]);
|
||||
for bad in [
|
||||
json!({"name": "B", "at": [0, 0], "rows": 0}),
|
||||
json!({"name": "B", "at": [0, 0], "columns": 32768}),
|
||||
json!({"name": "B", "at": [0, 0], "rows": 2.5}),
|
||||
// A distance that is not a number is refused, not taken as 0.
|
||||
json!({"name": "B", "at": [0, 0], "rows": 2, "rowSpacing": "15"}),
|
||||
json!({"name": "B", "at": [0, 0], "columns": 2, "columnSpacing": true}),
|
||||
] {
|
||||
assert!(s.execute("minsert", &bad).is_err(), "{bad}");
|
||||
}
|
||||
// A whole number written as 2.0 is a count; `explode` is INSERT's and leaves the grid made.
|
||||
let r = s.execute("minsert", &json!({"name": "B", "at": [0, 200], "rows": 2.0, "rowSpacing": 15, "explode": true})).unwrap();
|
||||
assert!(r["handle"].is_string(), "{r}");
|
||||
assert_eq!(inserts(&s).last().map(grid), Some((1, 2, 0.0, 15.0)));
|
||||
}
|
||||
@@ -46,7 +46,7 @@ fn typed_save_restore_list_delete() {
|
||||
// Saving is an undo step; a view change is not.
|
||||
set_view(&mut s, 0.0, 0.0, 500.0);
|
||||
typed(&mut s, &["view", "r", "front"]);
|
||||
assert_eq!(view(&s), View { center: Vec2::new(10.0, 20.0), height: 30.0 });
|
||||
assert_eq!(view(&s), View { center: Vec2::new(10.0, 20.0), height: 30.0, twist: 0.0 });
|
||||
// Zoom Previous goes back to the view before the restore.
|
||||
s.execute("zoom.previous", &json!({})).unwrap();
|
||||
assert_eq!(view(&s).height, 500.0);
|
||||
@@ -84,6 +84,20 @@ fn window_option_and_json_forms() {
|
||||
assert!(s.execute("view", &json!({ "option": "orthographic" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_saved_in_a_twisted_view_restores_whole() {
|
||||
let mut s = session();
|
||||
s.execute("view.set", &json!({ "center": [0, 0], "height": 10, "twist": 45 })).unwrap();
|
||||
s.execute("view", &json!({ "option": "window", "name": "Turned", "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
let v = s.doc().unwrap().views.first().cloned().unwrap();
|
||||
// A 10 x 10 world square seen at 45° is 10√2 across on screen.
|
||||
assert!(close(v.width, 200f64.sqrt()) && close(v.height, 200f64.sqrt()), "{} x {}", v.width, v.height);
|
||||
// Corners near the number limit overflow when turned: an error, and the saved view stays.
|
||||
let huge = json!({ "option": "window", "name": "Turned", "p1": [1.4e308, 1.4e308], "p2": [1.5e308, 1.5e308] });
|
||||
assert!(s.execute("view", &huge).is_err());
|
||||
assert!(close(s.doc().unwrap().views.first().unwrap().width, 200f64.sqrt()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_snapshot_restores_layers_and_the_setting_turns_it_off() {
|
||||
let mut s = session();
|
||||
@@ -112,7 +126,7 @@ fn paper_space_view_switches_to_its_layout() {
|
||||
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
|
||||
s.execute("view", &json!({ "option": "restore", "name": "Sheet" })).unwrap();
|
||||
assert_eq!(s.layout_space(), Space::Paper(layout));
|
||||
assert_eq!(view(&s), View { center: Vec2::new(5.0, 4.0), height: 6.0 });
|
||||
assert_eq!(view(&s), View { center: Vec2::new(5.0, 4.0), height: 6.0, twist: 0.0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -138,7 +152,7 @@ fn zoom_realtime_drags_and_enter_ends() {
|
||||
assert_eq!(view(&s).height, 100.0);
|
||||
// The JSON form zooms by a factor about the centre.
|
||||
s.execute("zoom.realtime", &json!({ "factor": 4 })).unwrap();
|
||||
assert_eq!(view(&s), View { center: Vec2::ZERO, height: 25.0 });
|
||||
assert_eq!(view(&s), View { center: Vec2::ZERO, height: 25.0, twist: 0.0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -158,10 +172,10 @@ fn zoom_dynamic_places_and_sizes_the_view_box() {
|
||||
assert!(close(v.height, 40.0), "{v:?}");
|
||||
// Zoom Previous skips the overview.
|
||||
s.execute("zoom.previous", &json!({})).unwrap();
|
||||
assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0 });
|
||||
assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0, twist: 0.0 });
|
||||
// JSON: like Center.
|
||||
s.execute("zoom.dynamic", &json!({ "center": [1, 2], "height": 3 })).unwrap();
|
||||
assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0 });
|
||||
assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0, twist: 0.0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -46,15 +46,15 @@ fn split_activate_and_each_tile_keeps_its_view() {
|
||||
s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap();
|
||||
assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]);
|
||||
// Every new tile starts with the view that was split.
|
||||
assert!(tiles(&s).iter().all(|t| t.view == View { center: Vec2::new(10.0, 10.0), height: 20.0 }));
|
||||
assert!(tiles(&s).iter().all(|t| t.view == View { center: Vec2::new(10.0, 10.0), height: 20.0, twist: 0.0 }));
|
||||
let first = current(&s);
|
||||
let other = 1 - first;
|
||||
s.activate_tile(other).unwrap();
|
||||
assert_eq!(current(&s), other);
|
||||
s.execute("view.set", &json!({ "center": [50, 25], "height": 5 })).unwrap();
|
||||
s.execute("vports.activate", &json!({ "index": first + 1 })).unwrap();
|
||||
assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0 });
|
||||
assert_eq!(tiles(&s)[other].view, View { center: Vec2::new(50.0, 25.0), height: 5.0 });
|
||||
assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0, twist: 0.0 });
|
||||
assert_eq!(tiles(&s)[other].view, View { center: Vec2::new(50.0, 25.0), height: 5.0, twist: 0.0 });
|
||||
// Switching tiles changes no drawing data; arranging them is an undo step.
|
||||
assert_eq!(s.state().unwrap().undo.len(), 2, "line + VPORTS");
|
||||
s.undo().unwrap();
|
||||
@@ -82,7 +82,7 @@ fn typed_three_right_then_join_and_single() {
|
||||
s.click_tile(bottom).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]);
|
||||
assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0 });
|
||||
assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0, twist: 0.0 });
|
||||
// Tiles that don't make a rectangle can't be joined.
|
||||
s.execute("-vports", &json!({ "option": "3", "arrangement": "above" })).unwrap();
|
||||
let t = tiles(&s);
|
||||
@@ -93,7 +93,7 @@ fn typed_three_right_then_join_and_single() {
|
||||
s.execute("view.set", &json!({ "center": [7, 7], "height": 7 })).unwrap();
|
||||
typed(&mut s, &["-vports", "si"]);
|
||||
assert!(tiles(&s).is_empty());
|
||||
assert_eq!(view(&s), View { center: Vec2::new(7.0, 7.0), height: 7.0 });
|
||||
assert_eq!(view(&s), View { center: Vec2::new(7.0, 7.0), height: 7.0, twist: 0.0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//! FF8: the SHX fonts a drawing's text styles name are found beside the drawing it opens. The
|
||||
//! font is compiled here from shape source written for the test.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::{Drawing, TextStyle};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn opening_a_drawing_finds_the_shx_fonts_beside_it() {
|
||||
set_io(IoHooks {
|
||||
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
|
||||
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
|
||||
plot: None,
|
||||
});
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-shx-beside-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let font = cadcraft_fonts::shx::compile("*0,4,beside\n6,2,0,0\n*65,7,UCA\n1,064,040,06C,2,020,0\n").unwrap();
|
||||
std::fs::write(dir.join("cadcraft-beside-engine.shx"), font).unwrap();
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.text_styles.push(TextStyle { name: "Beside".into(), font: "cadcraft-beside-engine.shx".into(), ..TextStyle::default() });
|
||||
let path = dir.join("host.dxf");
|
||||
std::fs::write(&path, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap();
|
||||
|
||||
assert!(!cadcraft_fonts::TextFont::resolve("cadcraft-beside-engine.shx").is_shx());
|
||||
let mut s = Session::new();
|
||||
s.execute("open", &json!({ "path": path.to_string_lossy() })).unwrap();
|
||||
let style = s.doc().unwrap().text_style("Beside").cloned().unwrap();
|
||||
let font = cadcraft_fonts::TextFont::resolve_style(&style.font, &style.big_font, style.vertical);
|
||||
assert!(font.is_shx());
|
||||
assert!((cadcraft_fonts::text_width(&font, "AA", 3.0, 1.0) - 6.0).abs() < 1e-9);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! The view twist (VIEWTWIST): DVIEW TWist turns the Model tab's view, ZOOM Previous turns it
|
||||
//! back, each VPORTS tile keeps its own (DXF VPORT group 51), and VIEW Save/Restore keep it.
|
||||
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Session, View, sysvars};
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.viewport_px = (1200.0, 800.0);
|
||||
s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap();
|
||||
s.execute("view.set", &json!({ "center": [10, 10], "height": 20 })).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn typed(s: &mut Session, lines: &[&str]) {
|
||||
for l in lines {
|
||||
s.cmdline(l).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn view(s: &Session) -> View {
|
||||
s.state().unwrap().view()
|
||||
}
|
||||
|
||||
fn twist_deg(s: &Session) -> f64 {
|
||||
view(s).twist.to_degrees()
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dview_twist_sets_viewtwist_and_zoom_previous_turns_it_back() {
|
||||
let mut s = session();
|
||||
assert_eq!(view(&s).twist, 0.0);
|
||||
typed(&mut s, &["dview", "", "tw", "30", ""]);
|
||||
assert!(near(twist_deg(&s), 30.0), "twist {}", twist_deg(&s));
|
||||
let vt = sysvars::get(&s, "VIEWTWIST").and_then(|v| v.as_f64()).unwrap();
|
||||
assert!(near(vt, 30.0), "VIEWTWIST {vt}");
|
||||
// Only the twist changed; the view is not an undo step.
|
||||
assert_eq!((view(&s).center, view(&s).height), (Vec2::new(10.0, 10.0), 20.0));
|
||||
assert_eq!(s.state().unwrap().undo.len(), 1, "just the line");
|
||||
// Zooming keeps the twist; ZOOM Previous steps back through it.
|
||||
s.execute("zoom", &json!({ "mode": "in" })).unwrap();
|
||||
assert!(near(twist_deg(&s), 30.0));
|
||||
s.execute("zoom", &json!({ "mode": "previous" })).unwrap();
|
||||
s.execute("zoom", &json!({ "mode": "previous" })).unwrap();
|
||||
assert_eq!(view(&s).twist, 0.0);
|
||||
assert!(sysvars::is_read_only("VIEWTWIST"));
|
||||
// JSON form; negative angles wrap into 0..360.
|
||||
s.execute("dview", &json!({ "twist": -90 })).unwrap();
|
||||
assert!(near(twist_deg(&s), 270.0));
|
||||
// The 3D options are not available yet; the command carries on.
|
||||
typed(&mut s, &["dview", "", "ca", ""]);
|
||||
assert!(s.current_prompt().is_none());
|
||||
// Not in a layout.
|
||||
let layout = s.doc().unwrap().layouts.first().map(|l| l.name.clone()).unwrap();
|
||||
s.execute("layout.set", &json!({ "name": layout })).unwrap();
|
||||
assert!(s.execute("dview", &json!({ "twist": 10 })).is_err());
|
||||
}
|
||||
|
||||
const PAPER: &str = "This command is not available in Paper space.";
|
||||
|
||||
#[test]
|
||||
fn dview_in_paper_space_says_it_is_not_available() {
|
||||
let mut s = session();
|
||||
let layout = s.doc().unwrap().layouts.first().map(|l| l.name.clone()).unwrap();
|
||||
s.execute("layout.set", &json!({ "name": layout })).unwrap();
|
||||
let e = s.execute("dview", &json!({ "twist": 10 })).unwrap_err();
|
||||
assert_eq!(e.to_string(), PAPER);
|
||||
// Typed, it is refused as it starts: no prompt is left open.
|
||||
let e = s.cmdline("dview").unwrap_err();
|
||||
assert_eq!(e.to_string(), PAPER);
|
||||
assert!(s.current_prompt().is_none());
|
||||
// view.set can't turn a layout's view either (an untwisted one is fine).
|
||||
let e = s.execute("view.set", &json!({ "center": [0, 0], "height": 5, "twist": 15 })).unwrap_err();
|
||||
assert_eq!(e.to_string(), PAPER);
|
||||
s.execute("view.set", &json!({ "center": [0, 0], "height": 5 })).unwrap();
|
||||
assert_eq!(view(&s).twist, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_untiled_view_keeps_its_twist_through_a_dxf_round_trip() {
|
||||
let mut s = session();
|
||||
s.execute("dview", &json!({ "twist": 30 })).unwrap();
|
||||
assert!(s.model_tiles().len() <= 1, "untiled");
|
||||
let dxf = cadcraft_io::write_dxf(s.doc().unwrap());
|
||||
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
|
||||
let written = lines.windows(2).any(|w| w[0] == "51" && w[1].parse::<f64>().is_ok_and(|a| near(a, 30.0)));
|
||||
assert!(written, "the lone *ACTIVE VPORT carries group 51");
|
||||
let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
|
||||
let mut o = Session::new();
|
||||
o.viewport_px = (1200.0, 800.0);
|
||||
o.open_drawing(back.clone(), "single", None);
|
||||
assert!(near(twist_deg(&o), 30.0), "twist {}", twist_deg(&o));
|
||||
assert!(o.model_tiles().len() <= 1, "still untiled");
|
||||
// And again: saved from the reopened session, it is still turned.
|
||||
let again = cadcraft_io::read_dxf(cadcraft_io::write_dxf(o.doc().unwrap()).as_bytes()).unwrap();
|
||||
let mut o2 = Session::new();
|
||||
o2.viewport_px = (1200.0, 800.0);
|
||||
o2.open_drawing(again, "again", None);
|
||||
assert!(near(twist_deg(&o2), 30.0));
|
||||
// Turned back (ZOOM Previous here), the drawing keeps no twist.
|
||||
s.execute("zoom", &json!({ "mode": "previous" })).unwrap();
|
||||
assert_eq!(view(&s).twist, 0.0);
|
||||
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
|
||||
let mut o3 = Session::new();
|
||||
o3.viewport_px = (1200.0, 800.0);
|
||||
o3.open_drawing(back, "plain", None);
|
||||
assert_eq!(view(&o3).twist, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_extents_fits_in_the_twisted_frame() {
|
||||
let mut s = session();
|
||||
s.execute("dview", &json!({ "twist": 90 })).unwrap();
|
||||
s.execute("zoom", &json!({ "mode": "extents" })).unwrap();
|
||||
let v = view(&s);
|
||||
// Turned a quarter, the 100 x 50 line's box stands 100 high on screen.
|
||||
assert!(near(v.twist.to_degrees(), 90.0));
|
||||
assert!((v.center - Vec2::new(50.0, 25.0)).len() < 1e-6, "{:?}", v.center);
|
||||
assert!(near(v.height, 105.0), "height {}", v.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_tile_keeps_its_twist_through_a_dxf_round_trip() {
|
||||
let mut s = session();
|
||||
s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap();
|
||||
s.execute("dview", &json!({ "twist": 30 })).unwrap();
|
||||
let first = s.model_tiles().iter().position(|t| t.active).unwrap();
|
||||
s.activate_tile(1 - first).unwrap();
|
||||
assert_eq!(view(&s).twist, 0.0, "the other tile has its own twist");
|
||||
s.execute("dview", &json!({ "twist": 60 })).unwrap();
|
||||
let mut tw: Vec<f64> = s.model_tiles().iter().map(|t| t.view.twist.to_degrees()).collect();
|
||||
tw.sort_by(f64::total_cmp);
|
||||
assert!(near(tw[0], 30.0) && near(tw[1], 60.0), "{tw:?}");
|
||||
// Switching back shows the first tile's twist again.
|
||||
s.activate_tile(first).unwrap();
|
||||
assert!(near(twist_deg(&s), 30.0));
|
||||
// A saved configuration keeps both twists through DXF (VPORT group 51).
|
||||
s.execute("-vports", &json!({ "option": "save", "name": "Pair" })).unwrap();
|
||||
let dxf = cadcraft_io::write_dxf(s.doc().unwrap());
|
||||
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
|
||||
let written = lines.windows(2).any(|w| w[0] == "51" && w[1].parse::<f64>().is_ok_and(|a| near(a, 30.0)));
|
||||
assert!(written, "group 51 written");
|
||||
let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
|
||||
let pair = back.vports.iter().find(|c| c.name == "Pair").unwrap();
|
||||
let mut tw: Vec<f64> = pair.tiles.iter().map(|t| t.twist.to_degrees()).collect();
|
||||
tw.sort_by(f64::total_cmp);
|
||||
assert!(near(tw[0], 30.0) && near(tw[1], 60.0), "{tw:?}");
|
||||
// Opened, restoring the configuration shows each tile turned as saved.
|
||||
let mut o = Session::new();
|
||||
o.viewport_px = (1200.0, 800.0);
|
||||
o.open_drawing(back, "pair", None);
|
||||
o.execute("-vports", &json!({ "option": "restore", "name": "Pair" })).unwrap();
|
||||
let mut tw: Vec<f64> = o.model_tiles().iter().map(|t| t.view.twist.to_degrees()).collect();
|
||||
tw.sort_by(f64::total_cmp);
|
||||
assert!(near(tw[0], 30.0) && near(tw[1], 60.0), "{tw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_tiled_drawing_shows_the_current_tile_twisted() {
|
||||
let mut s = session();
|
||||
s.execute("dview", &json!({ "twist": 45 })).unwrap();
|
||||
s.execute("-vports", &json!({ "option": "2" })).unwrap();
|
||||
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
|
||||
let mut o = Session::new();
|
||||
o.viewport_px = (1200.0, 800.0);
|
||||
o.open_drawing(back, "tiled", None);
|
||||
assert!(near(twist_deg(&o), 45.0), "twist {}", twist_deg(&o));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_save_and_restore_keep_the_twist() {
|
||||
let mut s = session();
|
||||
s.execute("dview", &json!({ "twist": 30 })).unwrap();
|
||||
s.execute("view", &json!({ "option": "save", "name": "Turned" })).unwrap();
|
||||
let saved = s.doc().unwrap().views.iter().find(|v| v.name == "Turned").map(|v| v.twist.to_degrees()).unwrap();
|
||||
assert!(near(saved, 30.0));
|
||||
s.execute("dview", &json!({ "twist": 0 })).unwrap();
|
||||
s.execute("view.set", &json!({ "center": [80, 40], "height": 3 })).unwrap();
|
||||
assert_eq!(view(&s).twist, 0.0);
|
||||
s.execute("view", &json!({ "option": "restore", "name": "Turned" })).unwrap();
|
||||
assert!(near(twist_deg(&s), 30.0));
|
||||
assert!((view(&s).center - Vec2::new(10.0, 10.0)).len() < 1e-9);
|
||||
// view.set keeps the twist unless given one.
|
||||
s.execute("view.set", &json!({ "center": [0, 0], "height": 5 })).unwrap();
|
||||
assert!(near(twist_deg(&s), 30.0));
|
||||
s.execute("view.set", &json!({ "center": [0, 0], "height": 5, "twist": 15 })).unwrap();
|
||||
assert!(near(twist_deg(&s), 15.0));
|
||||
}
|
||||
@@ -14,3 +14,4 @@ skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
icu_segmenter = { workspace = true }
|
||||
encoding_rs = { workspace = true }
|
||||
|
||||
+72
-13
@@ -1,7 +1,8 @@
|
||||
//! CADCraft text layout.
|
||||
//!
|
||||
//! Turns TEXT and MTEXT into stroke polylines with the built-in single-stroke font
|
||||
//! ("CADCraft Stroke") or glyph outlines of installed TrueType fonts ([`ttf`]), with a
|
||||
//! ("CADCraft Stroke"), the user's SHX shape fonts and big fonts ([`shx`]) or glyph outlines
|
||||
//! of installed TrueType fonts ([`ttf`]), with a
|
||||
//! per-character fallback to installed fonts for characters the chosen font lacks. Handles the
|
||||
//! `%%` control codes, alignment modes, width factor, obliquing, MTEXT inline formatting
|
||||
//! (paragraphs, stacking, height changes), word wrap and attachment points.
|
||||
@@ -18,6 +19,7 @@ pub mod bidi;
|
||||
mod complex;
|
||||
mod linebreak;
|
||||
mod mtext;
|
||||
pub mod shx;
|
||||
mod stroke;
|
||||
pub mod subset;
|
||||
pub mod ttf;
|
||||
@@ -42,8 +44,8 @@ pub struct Run {
|
||||
}
|
||||
|
||||
/// Replace `%%` control codes: `%%d` degree, `%%p` plus/minus, `%%c` diameter, `%%%` percent,
|
||||
/// `%%nnn` character code. Underline/overline toggles (`%%u`, `%%o`) are returned as flags
|
||||
/// per character.
|
||||
/// `%%nnn` character code, and `\U+XXXX` Unicode escapes. Underline/overline toggles (`%%u`,
|
||||
/// `%%o`) are returned as flags per character.
|
||||
pub fn decode_controls(s: &str) -> Vec<(char, bool, bool)> {
|
||||
let mut out = Vec::with_capacity(s.len());
|
||||
let mut under = false;
|
||||
@@ -52,6 +54,14 @@ pub fn decode_controls(s: &str) -> Vec<(char, bool, bool)> {
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars.get(i).copied().unwrap_or(' ');
|
||||
// `\U+XXXX`: a Unicode character by its code.
|
||||
if c == '\\'
|
||||
&& let Some(u) = unicode_escape(&chars, i)
|
||||
{
|
||||
out.push((u, under, over));
|
||||
i += 7;
|
||||
continue;
|
||||
}
|
||||
if c == '%' && chars.get(i + 1) == Some(&'%') {
|
||||
let code = chars.get(i + 2).copied().unwrap_or(' ');
|
||||
match code.to_ascii_lowercase() {
|
||||
@@ -83,6 +93,18 @@ pub fn decode_controls(s: &str) -> Vec<(char, bool, bool)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The character of a `\U+XXXX` escape (four hex digits) starting at `chars[i]`.
|
||||
pub(crate) fn unicode_escape(chars: &[char], i: usize) -> Option<char> {
|
||||
if chars.get(i) != Some(&'\\') || !matches!(chars.get(i + 1), Some('U' | 'u')) || chars.get(i + 2) != Some(&'+') {
|
||||
return None;
|
||||
}
|
||||
let hex: String = chars.get(i + 3..i + 7)?.iter().collect();
|
||||
if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
char::from_u32(u32::from_str_radix(&hex, 16).ok()?)
|
||||
}
|
||||
|
||||
/// Advance width of one character at height 1 and width factor 1.
|
||||
pub fn char_advance(c: char) -> f64 {
|
||||
match stroke::glyph(c) {
|
||||
@@ -279,35 +301,65 @@ pub fn space_width(font: &TextFont, height: f64, width_factor: f64) -> f64 {
|
||||
{
|
||||
return w;
|
||||
}
|
||||
if let TextFont::Shx(t) = font {
|
||||
return shx::width(t, " ", height, width_factor);
|
||||
}
|
||||
stroke_advance(' ', height, width_factor) + stroke::GAP / stroke::CAP * height * width_factor
|
||||
}
|
||||
|
||||
/// The font a piece of text is set in: the built-in stroke font or an installed TrueType /
|
||||
/// OpenType face (outlines). Either way, characters the font lacks come from a fallback font.
|
||||
/// The font a piece of text is set in: the built-in stroke font, an installed TrueType /
|
||||
/// OpenType face (outlines) or the user's SHX fonts (a main font and a big font). Whichever,
|
||||
/// characters the font lacks come from a fallback font.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub enum TextFont {
|
||||
#[default]
|
||||
Stroke,
|
||||
Outline(ttf::Face),
|
||||
Shx(std::sync::Arc<shx::ShxText>),
|
||||
}
|
||||
|
||||
impl TextFont {
|
||||
/// Resolve a text style font name: an installed TTF/OTF/TTC when found; else, for a font we
|
||||
/// can't find (an SHX file, a missing TTF), the FONTALT font ([`ttf::font_alt`]) when set;
|
||||
/// else the stroke font.
|
||||
/// Resolve a text style font name (no big font, horizontal): see [`TextFont::resolve_style`].
|
||||
pub fn resolve(name: &str) -> TextFont {
|
||||
if let Some(f) = ttf::find(name) {
|
||||
TextFont::resolve_style(name, "", false)
|
||||
}
|
||||
|
||||
/// Resolve a text style's fonts: an installed TTF/OTF/TTC when `font` names one; else the
|
||||
/// SHX font and big font when found ([`shx::find`]), a missing one replaced by the FONTALT
|
||||
/// font ([`ttf::font_alt`], which may name both: `simplex.shx,bigfont.shx`); else FONTALT
|
||||
/// as a TrueType font; else the stroke font (which also draws the characters a missing big
|
||||
/// font would have). `vertical` is the style's vertical flag, honoured by fonts made for it.
|
||||
pub fn resolve_style(font: &str, big_font: &str, vertical: bool) -> TextFont {
|
||||
if let Some(f) = ttf::find(font) {
|
||||
return TextFont::Outline(f);
|
||||
}
|
||||
let n = name.trim();
|
||||
if n.is_empty() || n.eq_ignore_ascii_case(BUILTIN_FONT) {
|
||||
let n = font.trim();
|
||||
let builtin = n.is_empty() || n.eq_ignore_ascii_case(BUILTIN_FONT);
|
||||
let alt = ttf::font_alt();
|
||||
let (alt_main, alt_big) = alt.split_once(',').map_or((alt.as_str(), ""), |(a, b)| (a.trim(), b.trim()));
|
||||
let main = if builtin { None } else { shx::find(n).or_else(|| shx::find(alt_main)) };
|
||||
let (big, big_name) = match big_font.trim() {
|
||||
"" => (None, ""),
|
||||
b => match shx::find(b) {
|
||||
Some(f) => (Some(f), b),
|
||||
None => (shx::find(alt_big), alt_big),
|
||||
},
|
||||
};
|
||||
if main.is_some() || big.is_some() {
|
||||
return TextFont::Shx(std::sync::Arc::new(shx::ShxText::new(main, big, big_name, vertical)));
|
||||
}
|
||||
if builtin {
|
||||
return TextFont::Stroke;
|
||||
}
|
||||
ttf::find(&ttf::font_alt()).map(TextFont::Outline).unwrap_or(TextFont::Stroke)
|
||||
ttf::find(alt_main).map(TextFont::Outline).unwrap_or(TextFont::Stroke)
|
||||
}
|
||||
pub fn is_outline(&self) -> bool {
|
||||
matches!(self, TextFont::Outline(_))
|
||||
}
|
||||
/// Whether the text is drawn with SHX fonts.
|
||||
pub fn is_shx(&self) -> bool {
|
||||
matches!(self, TextFont::Shx(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shaped text in local or world coordinates: open strokes (stroke-font glyphs, underlines,
|
||||
@@ -436,6 +488,9 @@ pub fn shape_line_dir(font: &TextFont, s: &str, height: f64, width_factor: f64,
|
||||
{
|
||||
return sh;
|
||||
}
|
||||
if let TextFont::Shx(t) = font {
|
||||
return shx::shape(t, s, height, width_factor, oblique);
|
||||
}
|
||||
shape_stroke_dir(s, height, width_factor, oblique, base_rtl)
|
||||
}
|
||||
|
||||
@@ -450,6 +505,9 @@ pub fn text_width(font: &TextFont, s: &str, height: f64, width_factor: f64) -> f
|
||||
{
|
||||
return w;
|
||||
}
|
||||
if let TextFont::Shx(t) = font {
|
||||
return shx::width(t, s, height, width_factor);
|
||||
}
|
||||
line_width(s, height, width_factor)
|
||||
}
|
||||
|
||||
@@ -529,7 +587,8 @@ pub fn baseline_start(font: &TextFont, s: &str, p: &TextParams) -> Vec2 {
|
||||
fn bottom_depth(font: &TextFont, height: f64) -> f64 {
|
||||
match font {
|
||||
TextFont::Outline(face) => ttf::descent_ratio(face).map_or(height / 3.0, |r| r * height),
|
||||
TextFont::Stroke => height / 3.0,
|
||||
// AutoCAD places it a third of the height down with SHX fonts.
|
||||
TextFont::Stroke | TextFont::Shx(_) => height / 3.0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,13 @@ pub fn plain_mtext(s: &str) -> String {
|
||||
i = j + 1;
|
||||
}
|
||||
'L' | 'l' | 'O' | 'o' | 'K' | 'k' => i += 2,
|
||||
// `\U+XXXX`: a character by its Unicode code, as the layout reads it.
|
||||
'U' if crate::unicode_escape(&chars, i).is_some() => {
|
||||
if let Some(u) = crate::unicode_escape(&chars, i) {
|
||||
out.push(u);
|
||||
}
|
||||
i += 7;
|
||||
}
|
||||
// Codes with an argument terminated by ';' (\f, \F, \H, \W, \Q, \T, \A, \C, \c, \p).
|
||||
_ => {
|
||||
let mut j = i + 2;
|
||||
@@ -287,6 +294,13 @@ fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
|
||||
'O' => derive(&mut fmts, &mut cur, &|f| f.over = true),
|
||||
'o' => derive(&mut fmts, &mut cur, &|f| f.over = false),
|
||||
'K' | 'k' => {}
|
||||
// `\U+XXXX`: a character by its Unicode code.
|
||||
'U' if i >= 2 && crate::unicode_escape(&chars, i - 2).is_some() => {
|
||||
if let Some(u) = crate::unicode_escape(&chars, i - 2) {
|
||||
push_char(&mut word, u, cur);
|
||||
}
|
||||
i += 5;
|
||||
}
|
||||
'S' => {
|
||||
let mut j = i;
|
||||
let (mut top, mut bottom, mut sep) = (String::new(), String::new(), None);
|
||||
@@ -785,6 +799,8 @@ mod tests {
|
||||
assert_eq!(plain_mtext("a\\~b\\\\c"), "a b\\c");
|
||||
assert_eq!(plain_mtext("\\H2.5x;Big"), "Big");
|
||||
assert_eq!(plain_mtext("unterminated \\H2"), "unterminated ");
|
||||
assert_eq!(plain_mtext("\\U+0041B \\U+00E8"), "AB è");
|
||||
assert_eq!(plain_mtext("\\U+12"), "", "a short escape is a code with an argument");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Shape source (`.shp`) to a compiled SHX file, as COMPILE does.
|
||||
//!
|
||||
//! A source is a list of definitions: a header line `*number,defbytes,NAME` followed by its
|
||||
//! specification bytes (on as many lines as needed, ending with 0). Numbers are decimal, or
|
||||
//! hexadecimal after a leading `0` (`012`, `08cfb`); parentheses only group; `;` starts a
|
||||
//! comment. A big font starts with `*BIGFONT nchars,nranges,first,last,…` and a Unicode font
|
||||
//! with `*UNIFONT,6,name` followed by `above,below,modes,encoding,type,0`. Shape 0 is a text
|
||||
//! font's record (`above,below,modes,0`).
|
||||
//!
|
||||
//! Operands are encoded by what they are: a subshape number takes two bytes in a Unicode font
|
||||
//! (and after `7,0` in a big font), displacements and bulges are two's complement, and an arc's
|
||||
//! direction byte (`-043`) keeps its sign in the high bit. `defbytes` is not checked: the
|
||||
//! program's own length is written.
|
||||
|
||||
use super::ShxError;
|
||||
use super::parse::{BIGFONT, SHAPES_10, UNIFONT};
|
||||
|
||||
/// Largest source compiled.
|
||||
const MAX_SOURCE: usize = 8 << 20;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Kind {
|
||||
Shapes,
|
||||
Big,
|
||||
Uni,
|
||||
}
|
||||
|
||||
struct Shape {
|
||||
number: u16,
|
||||
name: String,
|
||||
program: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A number token: value (sign included).
|
||||
fn number(tok: &str, line: usize) -> Result<i64, ShxError> {
|
||||
let t: String = tok.chars().filter(|c| !matches!(c, '(' | ')') && !c.is_whitespace()).collect();
|
||||
let (neg, digits) = match t.strip_prefix('-') {
|
||||
Some(d) => (true, d),
|
||||
None => (false, t.strip_prefix('+').unwrap_or(&t)),
|
||||
};
|
||||
let err = || ShxError::Source(line, format!("not a number: {tok:?}"));
|
||||
let v = if digits.len() > 1 && digits.starts_with('0') {
|
||||
i64::from_str_radix(digits.get(1..).unwrap_or_default(), 16).map_err(|_| err())?
|
||||
} else {
|
||||
digits.parse::<i64>().map_err(|_| err())?
|
||||
};
|
||||
if v > 0xFFFF {
|
||||
return Err(err());
|
||||
}
|
||||
Ok(if neg { -v } else { v })
|
||||
}
|
||||
|
||||
/// The comma-separated numbers of a line (comment removed, blank fields skipped).
|
||||
fn numbers(text: &str, line: usize) -> Result<Vec<i64>, ShxError> {
|
||||
text.split(',').map(str::trim).filter(|t| !t.is_empty() && *t != "(" && *t != ")").map(|t| number(t, line)).collect()
|
||||
}
|
||||
|
||||
/// The spec-byte encoder: walks the tokens as the reader walks the bytes.
|
||||
struct Enc<'a> {
|
||||
toks: &'a [i64],
|
||||
i: usize,
|
||||
out: Vec<u8>,
|
||||
kind: Kind,
|
||||
line: usize,
|
||||
}
|
||||
|
||||
impl Enc<'_> {
|
||||
fn next(&mut self) -> Result<i64, ShxError> {
|
||||
let v = self.toks.get(self.i).copied().ok_or_else(|| ShxError::Source(self.line, "the shape ends inside a command".into()))?;
|
||||
self.i += 1;
|
||||
Ok(v)
|
||||
}
|
||||
fn byte(&mut self) -> Result<(), ShxError> {
|
||||
let v = self.next()?;
|
||||
let b = u8::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not a byte")))?;
|
||||
self.out.push(b);
|
||||
Ok(())
|
||||
}
|
||||
/// Two's complement −128..127.
|
||||
fn signed(&mut self) -> Result<i64, ShxError> {
|
||||
let v = self.next()?;
|
||||
let b = i8::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not −128..127")))?;
|
||||
self.out.extend(b.to_le_bytes());
|
||||
Ok(v)
|
||||
}
|
||||
/// Sign in the high bit (an arc's direction byte).
|
||||
fn sign_magnitude(&mut self) -> Result<(), ShxError> {
|
||||
let v = self.next()?;
|
||||
let m = u8::try_from(v.unsigned_abs())
|
||||
.ok()
|
||||
.filter(|m| *m < 0x80)
|
||||
.ok_or_else(|| ShxError::Source(self.line, format!("{v} is not an octant byte")))?;
|
||||
self.out.push(if v < 0 { m | 0x80 } else { m });
|
||||
Ok(())
|
||||
}
|
||||
fn word(&mut self) -> Result<(), ShxError> {
|
||||
let v = self.next()?;
|
||||
let w = u16::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not a shape number")))?;
|
||||
self.out.extend(w.to_be_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn program(mut self) -> Result<Vec<u8>, ShxError> {
|
||||
while self.i < self.toks.len() {
|
||||
let code = self.next()?;
|
||||
let c = u8::try_from(code).map_err(|_| ShxError::Source(self.line, format!("{code} is not a code")))?;
|
||||
self.out.push(c);
|
||||
match c {
|
||||
0 => break,
|
||||
3 | 4 => self.byte()?,
|
||||
7 => match self.kind {
|
||||
Kind::Uni => self.word()?,
|
||||
Kind::Big => {
|
||||
let first = self.toks.get(self.i).copied().unwrap_or(0);
|
||||
if first == 0 {
|
||||
self.byte()?;
|
||||
self.word()?;
|
||||
for _ in 0..4 {
|
||||
self.byte()?;
|
||||
}
|
||||
} else {
|
||||
self.byte()?;
|
||||
}
|
||||
}
|
||||
Kind::Shapes => self.byte()?,
|
||||
},
|
||||
8 => {
|
||||
self.signed()?;
|
||||
self.signed()?;
|
||||
}
|
||||
9 => loop {
|
||||
let (x, y) = (self.signed()?, self.signed()?);
|
||||
if x == 0 && y == 0 {
|
||||
break;
|
||||
}
|
||||
},
|
||||
10 => {
|
||||
self.byte()?;
|
||||
self.sign_magnitude()?;
|
||||
}
|
||||
11 => {
|
||||
for _ in 0..4 {
|
||||
self.byte()?;
|
||||
}
|
||||
self.sign_magnitude()?;
|
||||
}
|
||||
12 => {
|
||||
for _ in 0..3 {
|
||||
self.signed()?;
|
||||
}
|
||||
}
|
||||
13 => loop {
|
||||
let (x, y) = (self.signed()?, self.signed()?);
|
||||
if x == 0 && y == 0 {
|
||||
break;
|
||||
}
|
||||
self.signed()?;
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if self.out.last() != Some(&0) {
|
||||
self.out.push(0);
|
||||
}
|
||||
Ok(self.out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile shape source into an SHX file (a shape file or text font, a big font, or a Unicode
|
||||
/// font, as its first definition says).
|
||||
pub fn compile(source: &str) -> Result<Vec<u8>, ShxError> {
|
||||
if source.len() > MAX_SOURCE {
|
||||
return Err(ShxError::TooLarge);
|
||||
}
|
||||
let mut kind = Kind::Shapes;
|
||||
let mut ranges: Vec<(u16, u16)> = Vec::new();
|
||||
let mut uni_info: Option<(String, Vec<u8>)> = None;
|
||||
// Definitions: header (number, name, line) and the numbers that follow it.
|
||||
let mut defs: Vec<(i64, String, usize, Vec<i64>)> = Vec::new();
|
||||
let mut uni_header_pending = false;
|
||||
for (no, raw) in source.lines().enumerate() {
|
||||
let line = no + 1;
|
||||
let text = raw.split(';').next().unwrap_or_default().trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(head) = text.strip_prefix('*') {
|
||||
let upper = head.trim_start().to_ascii_uppercase();
|
||||
if let Some(rest) = upper.strip_prefix("BIGFONT") {
|
||||
kind = Kind::Big;
|
||||
let v = numbers(rest, line)?;
|
||||
let n = usize::try_from(v.get(1).copied().unwrap_or(0)).unwrap_or(0);
|
||||
for k in 0..n {
|
||||
let lo = v.get(2 + 2 * k).copied().unwrap_or(0);
|
||||
let hi = v.get(3 + 2 * k).copied().unwrap_or(lo);
|
||||
let to = |x: i64| u16::try_from(x).map_err(|_| ShxError::Source(line, "bad escape range".into()));
|
||||
ranges.push((to(lo)?, to(hi)?));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if upper.starts_with("UNIFONT") {
|
||||
kind = Kind::Uni;
|
||||
let name = head.splitn(3, ',').nth(2).unwrap_or_default().trim().to_string();
|
||||
uni_info = Some((name, Vec::new()));
|
||||
uni_header_pending = true;
|
||||
continue;
|
||||
}
|
||||
uni_header_pending = false;
|
||||
let mut parts = head.splitn(3, ',');
|
||||
let num = number(parts.next().unwrap_or_default().trim(), line)?;
|
||||
let _defbytes = parts.next();
|
||||
let name = parts.next().unwrap_or_default().trim().to_string();
|
||||
defs.push((num, name, line, Vec::new()));
|
||||
continue;
|
||||
}
|
||||
let v = numbers(text, line)?;
|
||||
if uni_header_pending {
|
||||
if let Some((_, info)) = uni_info.as_mut() {
|
||||
for x in v {
|
||||
info.push(u8::try_from(x).map_err(|_| ShxError::Source(line, "bad font record".into()))?);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match defs.last_mut() {
|
||||
Some(d) => d.3.extend(v),
|
||||
None => return Err(ShxError::Source(line, "specification bytes before any *number,defbytes,NAME line".into())),
|
||||
}
|
||||
}
|
||||
let mut shapes: Vec<Shape> = Vec::new();
|
||||
for (num, name, line, toks) in defs {
|
||||
let number = u16::try_from(num).map_err(|_| ShxError::Source(line, format!("shape number {num} out of range")))?;
|
||||
let program = if number == 0 {
|
||||
// The font record: plain bytes.
|
||||
toks.iter().map(|x| u8::try_from(*x).map_err(|_| ShxError::Source(line, "bad font record".into()))).collect::<Result<Vec<u8>, _>>()?
|
||||
} else {
|
||||
Enc { toks: &toks, i: 0, out: Vec::new(), kind, line }.program()?
|
||||
};
|
||||
if shapes.iter().any(|s| s.number == number) {
|
||||
return Err(ShxError::Source(line, format!("shape {number} is defined twice")));
|
||||
}
|
||||
shapes.push(Shape { number, name, program });
|
||||
}
|
||||
if shapes.is_empty() {
|
||||
return Err(ShxError::Empty);
|
||||
}
|
||||
shapes.sort_by_key(|s| s.number);
|
||||
let record = |s: &Shape| -> Result<Vec<u8>, ShxError> {
|
||||
let mut r: Vec<u8> = s.name.bytes().filter(|b| *b != 0).collect();
|
||||
r.push(0);
|
||||
r.extend(&s.program);
|
||||
if r.len() > usize::from(u16::MAX) { Err(ShxError::TooLarge) } else { Ok(r) }
|
||||
};
|
||||
let count = u16::try_from(shapes.len()).map_err(|_| ShxError::TooLarge)?;
|
||||
let len16 = |r: &[u8]| u16::try_from(r.len()).unwrap_or(u16::MAX);
|
||||
let mut out = Vec::new();
|
||||
match kind {
|
||||
Kind::Shapes => {
|
||||
out.extend(SHAPES_10);
|
||||
out.extend(shapes.first().map_or(0, |s| s.number).to_le_bytes());
|
||||
out.extend(shapes.last().map_or(0, |s| s.number).to_le_bytes());
|
||||
out.extend(count.to_le_bytes());
|
||||
let recs: Vec<Vec<u8>> = shapes.iter().map(record).collect::<Result<_, _>>()?;
|
||||
for (s, r) in shapes.iter().zip(&recs) {
|
||||
out.extend(s.number.to_le_bytes());
|
||||
out.extend(len16(r).to_le_bytes());
|
||||
}
|
||||
for r in &recs {
|
||||
out.extend(r);
|
||||
}
|
||||
out.extend(b"EOF");
|
||||
}
|
||||
Kind::Big => {
|
||||
out.extend(BIGFONT);
|
||||
let nranges = u16::try_from(ranges.len()).map_err(|_| ShxError::TooLarge)?;
|
||||
out.extend(count.to_le_bytes());
|
||||
out.extend(count.to_le_bytes());
|
||||
out.extend(nranges.to_le_bytes());
|
||||
for (lo, hi) in &ranges {
|
||||
out.extend(lo.to_le_bytes());
|
||||
out.extend(hi.to_le_bytes());
|
||||
}
|
||||
let recs: Vec<Vec<u8>> = shapes.iter().map(record).collect::<Result<_, _>>()?;
|
||||
let mut off = out.len() + 8 * recs.len();
|
||||
for (s, r) in shapes.iter().zip(&recs) {
|
||||
out.extend(s.number.to_le_bytes());
|
||||
out.extend(len16(r).to_le_bytes());
|
||||
out.extend(u32::try_from(off).map_err(|_| ShxError::TooLarge)?.to_le_bytes());
|
||||
off += r.len();
|
||||
}
|
||||
for r in &recs {
|
||||
out.extend(r);
|
||||
}
|
||||
}
|
||||
Kind::Uni => {
|
||||
out.extend(UNIFONT);
|
||||
let (name, info) = uni_info.unwrap_or_default();
|
||||
out.extend((u32::from(count) + 1).to_le_bytes());
|
||||
let mut rec: Vec<u8> = name.bytes().filter(|b| *b != 0).collect();
|
||||
rec.push(0);
|
||||
rec.extend(&info);
|
||||
out.extend(len16(&rec).to_le_bytes());
|
||||
out.extend(&rec);
|
||||
for s in &shapes {
|
||||
let r = record(s)?;
|
||||
out.extend(s.number.to_le_bytes());
|
||||
out.extend(len16(&r).to_le_bytes());
|
||||
out.extend(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! SHX shape fonts: text fonts, big fonts (East Asian double-byte) and Unicode fonts that a
|
||||
//! drawing's text styles name, read at run time from files the user has. None is bundled.
|
||||
//!
|
||||
//! - [`ShxFont::parse`] reads the compiled containers (`shapes 1.0/1.1`, `bigfont 1.0`,
|
||||
//! `unifont 1.0`) and [`ShxFont::glyph`] runs a shape's pen program (the vector length and
|
||||
//! direction codes and special codes 0–14 of the public shape description language) into
|
||||
//! polylines in font units. Every count, offset and loop in a file is bounded: a hostile or
|
||||
//! truncated file never panics, it reads as far as it is valid or not at all.
|
||||
//! - [`compile`] turns shape source (`.shp`) into a compiled file, as COMPILE does. The tests
|
||||
//! build every font they use with it: no font file is in this repository.
|
||||
//! - Fonts are found by name through the font table (`table`): registered bytes
|
||||
//! ([`register`], the web build's files opened alongside a drawing), an app-set
|
||||
//! [`FontProvider`], the folders of opened drawings ([`add_drawing_dir`]) and the support
|
||||
//! search path ([`set_search_path`], `CADCRAFT_SHXPATH`), after the font map
|
||||
//! ([`set_font_map`]). CADCraft never looks inside another CAD program's installation.
|
||||
//! - [`ShxText`] is a text style's pair of fonts (main + big font) and orientation, which the
|
||||
//! text layout draws ([`crate::TextFont::Shx`]): advances are the pen's end positions, so
|
||||
//! widths and extents follow the font's own metrics.
|
||||
//!
|
||||
//! Binary container layouts (little-endian): a signature line ending in CR LF 0x1A, then
|
||||
//! - shapes: first and last shape number, count, an index of (number, record length), the
|
||||
//! records (a NUL-terminated name, then the program) and `EOF`;
|
||||
//! - big font: index size, count, escape-range count, the ranges (first, last lead byte),
|
||||
//! an index of (number, record length, file offset) and the records;
|
||||
//! - unifont: shape count (32 bits), the length of the font record, the font record (name,
|
||||
//! above, below, modes, encoding, embedding, 0) and then each shape as (number, length,
|
||||
//! record).
|
||||
|
||||
mod compile;
|
||||
mod parse;
|
||||
mod program;
|
||||
mod table;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod text;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, PoisonError, RwLock};
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
|
||||
pub use compile::compile;
|
||||
pub use table::{
|
||||
FontProvider, add_drawing_dir, clear_registered, drawing_dirs, find, font_map, generation, register, search_path, set_font_map, set_provider,
|
||||
set_search_path,
|
||||
};
|
||||
pub use text::ShxText;
|
||||
pub(crate) use text::{shape, width};
|
||||
|
||||
/// Why a font file can't be read or a shape source can't be compiled.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ShxError {
|
||||
/// Not an SHX file (unknown signature).
|
||||
NotShx,
|
||||
/// The file ends inside its header or index.
|
||||
Truncated,
|
||||
/// The file is larger, or has more of something, than CADCraft reads.
|
||||
TooLarge,
|
||||
/// The file is well-formed but holds no shape.
|
||||
Empty,
|
||||
/// Shape source error: line and message.
|
||||
Source(usize, String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ShxError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ShxError::NotShx => f.write_str("not an SHX shape or font file"),
|
||||
ShxError::Truncated => f.write_str("the SHX file is truncated"),
|
||||
ShxError::TooLarge => f.write_str("the SHX file is too large"),
|
||||
ShxError::Empty => f.write_str("the SHX file has no shapes"),
|
||||
ShxError::Source(line, msg) => write!(f, "line {line}: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ShxError {}
|
||||
|
||||
/// The kind of SHX file.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ShxKind {
|
||||
/// A shape file or a text font with single-byte character codes (`shapes 1.0/1.1`).
|
||||
Shapes,
|
||||
/// A big font: double-byte character codes after an escape (lead) byte.
|
||||
BigFont,
|
||||
/// A Unicode font: shape numbers are Unicode code points.
|
||||
Unifont,
|
||||
}
|
||||
|
||||
/// A shape's program run into polylines, in font units (pen starts at the origin).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ShxGlyph {
|
||||
/// Pen-down runs (two points or more).
|
||||
pub strokes: Vec<Vec<Vec2>>,
|
||||
/// Where the pen ends: the advance to the next character.
|
||||
pub advance: Vec2,
|
||||
}
|
||||
|
||||
/// One shape record: where its name and program are in [`ShxFont::data`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Record {
|
||||
name: (usize, usize),
|
||||
program: (usize, usize),
|
||||
}
|
||||
|
||||
/// Glyphs cached per font, keyed by (shape, vertical).
|
||||
type GlyphCache = HashMap<(u16, bool), Option<Arc<ShxGlyph>>>;
|
||||
|
||||
/// Most glyphs cached per font (a big font has thousands; text uses a few hundred).
|
||||
const MAX_CACHED_GLYPHS: usize = 16_384;
|
||||
|
||||
/// A parsed SHX file.
|
||||
pub struct ShxFont {
|
||||
kind: ShxKind,
|
||||
name: String,
|
||||
/// Shape 0 (the font record) is present: the file is a text font, not only shapes.
|
||||
header: bool,
|
||||
above: f64,
|
||||
below: f64,
|
||||
/// Modes 2: horizontal and vertical (code 14 is honoured).
|
||||
dual: bool,
|
||||
/// Unifont encoding: 0 Unicode, 1 packed multibyte, 2 shape file.
|
||||
encoding: u8,
|
||||
/// Extended big font: the font record gives a character width (subshape cell).
|
||||
char_width: f64,
|
||||
/// Big font escape ranges (lead bytes).
|
||||
ranges: Vec<(u16, u16)>,
|
||||
data: Vec<u8>,
|
||||
records: HashMap<u16, Record>,
|
||||
cache: RwLock<GlyphCache>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ShxFont {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ShxFont")
|
||||
.field("kind", &self.kind)
|
||||
.field("name", &self.name)
|
||||
.field("above", &self.above)
|
||||
.field("below", &self.below)
|
||||
.field("dual", &self.dual)
|
||||
.field("shapes", &self.records.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ShxFont {
|
||||
/// Parse a compiled SHX file.
|
||||
pub fn parse(bytes: &[u8]) -> Result<ShxFont, ShxError> {
|
||||
parse::parse(bytes)
|
||||
}
|
||||
pub fn kind(&self) -> ShxKind {
|
||||
self.kind
|
||||
}
|
||||
/// The font name in its font record (shape 0), or empty.
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
/// Whether the file has a font record (shape 0): a text font rather than a shape file.
|
||||
pub fn is_font(&self) -> bool {
|
||||
self.header
|
||||
}
|
||||
/// Vector units uppercase letters rise above the baseline (big font: the character height).
|
||||
/// Text height `h` draws the font at `h / above` per unit. 0 for a shape file.
|
||||
pub fn above(&self) -> f64 {
|
||||
self.above
|
||||
}
|
||||
/// Vector units lowercase descenders drop below the baseline.
|
||||
pub fn below(&self) -> f64 {
|
||||
self.below
|
||||
}
|
||||
/// The font supports vertical text (modes 2).
|
||||
pub fn is_dual(&self) -> bool {
|
||||
self.dual
|
||||
}
|
||||
/// The units per text height: [`Self::above`], or 1 for a file without a font record.
|
||||
pub fn units_per_height(&self) -> f64 {
|
||||
if self.above > 0.0 { self.above } else { 1.0 }
|
||||
}
|
||||
/// Big font escape ranges: `(first, last)` lead bytes.
|
||||
pub fn escape_ranges(&self) -> &[(u16, u16)] {
|
||||
&self.ranges
|
||||
}
|
||||
/// Whether `b` starts a double-byte character in this big font.
|
||||
pub fn is_lead_byte(&self, b: u8) -> bool {
|
||||
self.ranges.iter().any(|(lo, hi)| (*lo..=*hi).contains(&u16::from(b)))
|
||||
}
|
||||
/// Unifont encoding (0 Unicode, 1 packed multibyte, 2 shape file); 0 for other kinds.
|
||||
pub fn encoding(&self) -> u8 {
|
||||
self.encoding
|
||||
}
|
||||
pub fn shape_count(&self) -> usize {
|
||||
self.records.len()
|
||||
}
|
||||
pub fn has_shape(&self, number: u16) -> bool {
|
||||
number != 0 && self.records.contains_key(&number)
|
||||
}
|
||||
/// The shape numbers in the file, sorted (not shape 0).
|
||||
pub fn shape_numbers(&self) -> Vec<u16> {
|
||||
let mut v: Vec<u16> = self.records.keys().copied().filter(|n| *n != 0).collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
/// The name of shape `number` as the file stores it.
|
||||
pub fn shape_name(&self, number: u16) -> Option<String> {
|
||||
let r = self.records.get(&number)?;
|
||||
self.data.get(r.name.0..r.name.1).map(|b| b.iter().map(|c| char::from(*c)).collect())
|
||||
}
|
||||
/// The number of the shape called `name` (case-insensitive), as a linetype's
|
||||
/// `[SHAPE,file.shx]` element names it.
|
||||
pub fn shape_number(&self, name: &str) -> Option<u16> {
|
||||
let want = name.trim();
|
||||
if want.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut found: Vec<u16> = self
|
||||
.records
|
||||
.iter()
|
||||
.filter(|(n, r)| **n != 0 && self.data.get(r.name.0..r.name.1).is_some_and(|b| b.eq_ignore_ascii_case(want.as_bytes())))
|
||||
.map(|(n, _)| *n)
|
||||
.collect();
|
||||
found.sort_unstable();
|
||||
found.first().copied()
|
||||
}
|
||||
/// The program bytes of shape `number`.
|
||||
pub(crate) fn program(&self, number: u16) -> Option<&[u8]> {
|
||||
let r = self.records.get(&number)?;
|
||||
self.data.get(r.program.0..r.program.1)
|
||||
}
|
||||
/// Shape `number` drawn horizontally or vertically, in font units; `None` when the font
|
||||
/// has no such shape or its program is broken. Cached.
|
||||
pub fn glyph(&self, number: u16, vertical: bool) -> Option<Arc<ShxGlyph>> {
|
||||
if !self.has_shape(number) {
|
||||
return None;
|
||||
}
|
||||
let vertical = vertical && self.dual;
|
||||
let key = (number, vertical);
|
||||
if let Some(g) = self.cache.read().unwrap_or_else(PoisonError::into_inner).get(&key) {
|
||||
return g.clone();
|
||||
}
|
||||
let g = program::outline(self, number, vertical).ok().map(Arc::new);
|
||||
let mut c = self.cache.write().unwrap_or_else(PoisonError::into_inner);
|
||||
if c.len() >= MAX_CACHED_GLYPHS {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, g.clone());
|
||||
g
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Reading the compiled SHX containers (layouts in the module documentation). Every count and
|
||||
//! offset comes from the file, so each is checked against its length; records that run past
|
||||
//! the end are dropped and the rest kept.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use super::{Record, ShxError, ShxFont, ShxKind};
|
||||
|
||||
pub(super) const SHAPES_10: &[u8] = b"AutoCAD-86 shapes 1.0\r\n\x1a";
|
||||
pub(super) const SHAPES_11: &[u8] = b"AutoCAD-86 shapes 1.1\r\n\x1a";
|
||||
pub(super) const BIGFONT: &[u8] = b"AutoCAD-86 bigfont 1.0\r\n\x1a";
|
||||
pub(super) const UNIFONT: &[u8] = b"AutoCAD-86 unifont 1.0\r\n\x1a";
|
||||
|
||||
/// Largest file read (the largest big fonts are a few megabytes).
|
||||
pub(super) const MAX_FILE_BYTES: usize = 32 << 20;
|
||||
/// Most escape ranges in a big font (lead bytes: at most 256).
|
||||
const MAX_RANGES: usize = 256;
|
||||
|
||||
/// A checked little-endian reader.
|
||||
struct Rd<'a> {
|
||||
b: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Rd<'a> {
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8], ShxError> {
|
||||
let end = self.pos.checked_add(n).ok_or(ShxError::Truncated)?;
|
||||
let s = self.b.get(self.pos..end).ok_or(ShxError::Truncated)?;
|
||||
self.pos = end;
|
||||
Ok(s)
|
||||
}
|
||||
fn u16(&mut self) -> Result<u16, ShxError> {
|
||||
match self.take(2)? {
|
||||
[a, b] => Ok(u16::from_le_bytes([*a, *b])),
|
||||
_ => Err(ShxError::Truncated),
|
||||
}
|
||||
}
|
||||
fn u32(&mut self) -> Result<u32, ShxError> {
|
||||
match self.take(4)? {
|
||||
[a, b, c, d] => Ok(u32::from_le_bytes([*a, *b, *c, *d])),
|
||||
_ => Err(ShxError::Truncated),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A font being read.
|
||||
struct Font {
|
||||
kind: ShxKind,
|
||||
name: String,
|
||||
header: bool,
|
||||
above: f64,
|
||||
below: f64,
|
||||
dual: bool,
|
||||
encoding: u8,
|
||||
char_width: f64,
|
||||
ranges: Vec<(u16, u16)>,
|
||||
records: HashMap<u16, Record>,
|
||||
}
|
||||
|
||||
impl Font {
|
||||
fn new(kind: ShxKind) -> Font {
|
||||
Font {
|
||||
kind,
|
||||
name: String::new(),
|
||||
header: false,
|
||||
above: 0.0,
|
||||
below: 0.0,
|
||||
dual: false,
|
||||
encoding: 0,
|
||||
char_width: 0.0,
|
||||
ranges: Vec::new(),
|
||||
records: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the record of shape `number` at `start..end` of `data`: a NUL-terminated name, then
|
||||
/// the program. Shape 0 is the font record. A record without a name terminator is dropped;
|
||||
/// the first record of a number wins.
|
||||
fn record(&mut self, data: &[u8], number: u16, start: usize, end: usize) {
|
||||
let Some(bytes) = data.get(start..end) else { return };
|
||||
let Some(nul) = bytes.iter().position(|b| *b == 0) else { return };
|
||||
let name = (start, start + nul);
|
||||
let program = (start + nul + 1, end);
|
||||
if number == 0 {
|
||||
if !self.header {
|
||||
self.font_record(data.get(program.0..program.1).unwrap_or_default(), data.get(name.0..name.1).unwrap_or_default());
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.records.entry(number).or_insert(Record { name, program });
|
||||
}
|
||||
|
||||
/// The font record: above, below, modes (and for a unifont encoding and embedding); an
|
||||
/// extended big font's is character height, 0, modes, character width.
|
||||
fn font_record(&mut self, info: &[u8], name: &[u8]) {
|
||||
self.header = true;
|
||||
self.name = name.iter().take(255).map(|c| char::from(*c)).collect();
|
||||
let byte = |i: usize| info.get(i).copied().unwrap_or(0);
|
||||
self.above = f64::from(byte(0));
|
||||
self.below = f64::from(byte(1));
|
||||
self.dual = byte(2) == 2;
|
||||
if self.kind == ShxKind::Unifont {
|
||||
self.encoding = byte(3);
|
||||
}
|
||||
if self.kind == ShxKind::BigFont && info.len() >= 5 && byte(1) == 0 {
|
||||
// Extended: height, 0, modes, width, 0.
|
||||
self.below = 0.0;
|
||||
self.char_width = f64::from(byte(3));
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(self, data: Vec<u8>) -> Result<ShxFont, ShxError> {
|
||||
if self.records.is_empty() {
|
||||
return Err(ShxError::Empty);
|
||||
}
|
||||
Ok(ShxFont {
|
||||
kind: self.kind,
|
||||
name: self.name,
|
||||
header: self.header,
|
||||
above: self.above,
|
||||
below: self.below,
|
||||
dual: self.dual,
|
||||
encoding: self.encoding,
|
||||
char_width: if self.char_width > 0.0 { self.char_width } else { self.above },
|
||||
ranges: self.ranges,
|
||||
data,
|
||||
records: self.records,
|
||||
cache: RwLock::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse(bytes: &[u8]) -> Result<ShxFont, ShxError> {
|
||||
if bytes.len() > MAX_FILE_BYTES {
|
||||
return Err(ShxError::TooLarge);
|
||||
}
|
||||
if bytes.starts_with(SHAPES_10) || bytes.starts_with(SHAPES_11) {
|
||||
shapes(bytes)
|
||||
} else if bytes.starts_with(BIGFONT) {
|
||||
bigfont(bytes)
|
||||
} else if bytes.starts_with(UNIFONT) {
|
||||
unifont(bytes)
|
||||
} else {
|
||||
Err(ShxError::NotShx)
|
||||
}
|
||||
}
|
||||
|
||||
fn shapes(bytes: &[u8]) -> Result<ShxFont, ShxError> {
|
||||
let mut r = Rd { b: bytes, pos: SHAPES_10.len() };
|
||||
let _first = r.u16()?;
|
||||
let _last = r.u16()?;
|
||||
let count = usize::from(r.u16()?);
|
||||
// The whole index must be there before anything is read from it.
|
||||
let index = r.take(count.checked_mul(4).ok_or(ShxError::TooLarge)?)?;
|
||||
let mut f = Font::new(ShxKind::Shapes);
|
||||
let mut pos = r.pos;
|
||||
for [a, b, c, d] in index.as_chunks::<4>().0 {
|
||||
let (number, len) = (u16::from_le_bytes([*a, *b]), usize::from(u16::from_le_bytes([*c, *d])));
|
||||
let Some(end) = pos.checked_add(len).filter(|e| *e <= bytes.len()) else { break };
|
||||
f.record(bytes, number, pos, end);
|
||||
pos = end;
|
||||
}
|
||||
f.finish(bytes.to_vec())
|
||||
}
|
||||
|
||||
fn bigfont(bytes: &[u8]) -> Result<ShxFont, ShxError> {
|
||||
let mut r = Rd { b: bytes, pos: BIGFONT.len() };
|
||||
let index_size = usize::from(r.u16()?);
|
||||
let count = usize::from(r.u16()?);
|
||||
let nranges = usize::from(r.u16()?);
|
||||
if nranges > MAX_RANGES {
|
||||
return Err(ShxError::TooLarge);
|
||||
}
|
||||
let mut f = Font::new(ShxKind::BigFont);
|
||||
for _ in 0..nranges {
|
||||
let (lo, hi) = (r.u16()?, r.u16()?);
|
||||
f.ranges.push((lo.min(hi), lo.max(hi)));
|
||||
}
|
||||
// The index has `count` entries of 8 bytes; a file whose count doesn't fit but whose index
|
||||
// size does is read by that.
|
||||
let fits = |n: usize| n.checked_mul(8).and_then(|l| r.pos.checked_add(l)).is_some_and(|e| e <= bytes.len());
|
||||
let n = if fits(count) {
|
||||
count
|
||||
} else if fits(index_size) {
|
||||
index_size
|
||||
} else {
|
||||
return Err(ShxError::Truncated);
|
||||
};
|
||||
let index = r.take(n.checked_mul(8).ok_or(ShxError::TooLarge)?)?;
|
||||
for [a, b, c, d, o0, o1, o2, o3] in index.as_chunks::<8>().0 {
|
||||
let number = u16::from_le_bytes([*a, *b]);
|
||||
let len = usize::from(u16::from_le_bytes([*c, *d]));
|
||||
let Ok(off) = usize::try_from(u32::from_le_bytes([*o0, *o1, *o2, *o3])) else { continue };
|
||||
if len == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(end) = off.checked_add(len).filter(|e| *e <= bytes.len()) else { continue };
|
||||
f.record(bytes, number, off, end);
|
||||
}
|
||||
f.finish(bytes.to_vec())
|
||||
}
|
||||
|
||||
fn unifont(bytes: &[u8]) -> Result<ShxFont, ShxError> {
|
||||
let mut r = Rd { b: bytes, pos: UNIFONT.len() };
|
||||
let count = r.u32()?;
|
||||
let info_len = usize::from(r.u16()?);
|
||||
let info = r.take(info_len)?;
|
||||
let mut f = Font::new(ShxKind::Unifont);
|
||||
let nul = info.iter().position(|b| *b == 0).unwrap_or(info.len());
|
||||
f.font_record(info.get(nul.saturating_add(1)..).unwrap_or_default(), info.get(..nul).unwrap_or_default());
|
||||
// `count` includes the font record; never trusted beyond the data that is there.
|
||||
let mut left = count.saturating_sub(1);
|
||||
while left > 0 {
|
||||
left -= 1;
|
||||
let Ok(number) = r.u16() else { break };
|
||||
let Ok(len) = r.u16().map(usize::from) else { break };
|
||||
let start = r.pos;
|
||||
let Some(end) = start.checked_add(len).filter(|e| *e <= bytes.len()) else { break };
|
||||
r.pos = end;
|
||||
f.record(bytes, number, start, end);
|
||||
}
|
||||
f.finish(bytes.to_vec())
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! The shape pen program: vector length/direction bytes and special codes 0–14, run into
|
||||
//! polylines. Bounded everywhere: subshape depth, location stack, steps, points, coordinates.
|
||||
//!
|
||||
//! - A byte with a non-zero high nibble is a vector: length (high nibble) in one of 16
|
||||
//! directions (low nibble, counter-clockwise from east in 22.5° steps; the off-axis ones
|
||||
//! are half-unit or unit steps on the minor axis).
|
||||
//! - 0 end, 1 pen down, 2 pen up, 3/4 divide/multiply vector lengths by the next byte,
|
||||
//! 5/6 push/pop the location, 7 subshape (one byte; two bytes high first in a Unicode font;
|
||||
//! `0`, a two-byte number, base point, width and height in an extended big font),
|
||||
//! 8 one X-Y displacement, 9 displacements up to (0,0), 10 octant arc (radius,
|
||||
//! ±0SC: sign = clockwise, S start octant, C octants, 0 = 8), 11 fractional arc (start
|
||||
//! offset, end offset, high and low radius, ±0SC; offsets in 1/256 of 45° from the octant
|
||||
//! boundaries), 12 bulge arc (dx, dy, bulge: ±127 = semicircle), 13 bulge arcs up to (0,0),
|
||||
//! 14 the next command is for vertical text only (dual-orientation fonts).
|
||||
//! - Displacements and bulges are two's complement bytes; the arc direction byte keeps its
|
||||
//! sign in the high bit.
|
||||
|
||||
use std::f64::consts::{FRAC_PI_4, TAU};
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
|
||||
use super::{ShxFont, ShxGlyph, ShxKind};
|
||||
|
||||
/// Subshape nesting (a cycle ends here).
|
||||
const MAX_DEPTH: usize = 16;
|
||||
/// Location stack (the format promises four).
|
||||
const MAX_STACK: usize = 64;
|
||||
/// Bytes run per glyph, subshapes included.
|
||||
const MAX_STEPS: usize = 200_000;
|
||||
/// Points per glyph.
|
||||
const MAX_POINTS: usize = 50_000;
|
||||
/// Coordinates in font units.
|
||||
const MAX_COORD: f64 = 1.0e7;
|
||||
/// Arc segments per full turn.
|
||||
const ARC_STEPS: f64 = 64.0;
|
||||
|
||||
/// Why a program stops early: the glyph is then not drawn.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Broken;
|
||||
|
||||
type Res = Result<(), Broken>;
|
||||
|
||||
struct Pen<'a> {
|
||||
font: &'a ShxFont,
|
||||
vertical: bool,
|
||||
pos: Vec2,
|
||||
down: bool,
|
||||
/// Vector length factor (codes 3 and 4).
|
||||
scale: f64,
|
||||
/// Per-axis factor of an extended big font subshape.
|
||||
axes: Vec2,
|
||||
stack: Vec<Vec2>,
|
||||
strokes: Vec<Vec<Vec2>>,
|
||||
active: Vec<Vec2>,
|
||||
steps: usize,
|
||||
points: usize,
|
||||
}
|
||||
|
||||
pub(super) fn outline(font: &ShxFont, number: u16, vertical: bool) -> Result<ShxGlyph, Broken> {
|
||||
let mut pen = Pen {
|
||||
font,
|
||||
vertical,
|
||||
pos: Vec2::ZERO,
|
||||
down: true,
|
||||
scale: 1.0,
|
||||
axes: Vec2::new(1.0, 1.0),
|
||||
stack: Vec::new(),
|
||||
strokes: Vec::new(),
|
||||
active: Vec::new(),
|
||||
steps: 0,
|
||||
points: 0,
|
||||
};
|
||||
pen.run(number, 0)?;
|
||||
pen.flush();
|
||||
Ok(ShxGlyph { strokes: pen.strokes, advance: pen.pos })
|
||||
}
|
||||
|
||||
/// The 16 vector directions (unit length on the major axis).
|
||||
fn direction(d: u8) -> Vec2 {
|
||||
let (x, y) = match d & 15 {
|
||||
0 => (1.0, 0.0),
|
||||
1 => (1.0, 0.5),
|
||||
2 => (1.0, 1.0),
|
||||
3 => (0.5, 1.0),
|
||||
4 => (0.0, 1.0),
|
||||
5 => (-0.5, 1.0),
|
||||
6 => (-1.0, 1.0),
|
||||
7 => (-1.0, 0.5),
|
||||
8 => (-1.0, 0.0),
|
||||
9 => (-1.0, -0.5),
|
||||
10 => (-1.0, -1.0),
|
||||
11 => (-0.5, -1.0),
|
||||
12 => (0.0, -1.0),
|
||||
13 => (0.5, -1.0),
|
||||
14 => (1.0, -1.0),
|
||||
_ => (1.0, -0.5),
|
||||
};
|
||||
Vec2::new(x, y)
|
||||
}
|
||||
|
||||
/// A two's complement operand byte.
|
||||
fn signed(b: u8) -> f64 {
|
||||
f64::from(i8::from_le_bytes([b]))
|
||||
}
|
||||
|
||||
/// A program being read: its bytes and position.
|
||||
struct Cur<'p> {
|
||||
b: &'p [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Cur<'_> {
|
||||
fn byte(&mut self) -> Result<u8, Broken> {
|
||||
let v = self.b.get(self.pos).copied().ok_or(Broken)?;
|
||||
self.pos += 1;
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl Pen<'_> {
|
||||
fn step(&mut self) -> Res {
|
||||
self.steps += 1;
|
||||
if self.steps > MAX_STEPS { Err(Broken) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn flush(&mut self) {
|
||||
if self.active.len() >= 2 {
|
||||
self.strokes.push(std::mem::take(&mut self.active));
|
||||
} else {
|
||||
self.active.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the pen by `d` font units (already scaled), drawing when the pen is down.
|
||||
fn to(&mut self, target: Vec2) -> Res {
|
||||
if !target.is_finite() || target.x.abs().max(target.y.abs()) > MAX_COORD {
|
||||
return Err(Broken);
|
||||
}
|
||||
if self.down {
|
||||
if self.active.is_empty() {
|
||||
self.active.push(self.pos);
|
||||
self.points += 1;
|
||||
}
|
||||
self.active.push(target);
|
||||
self.points += 1;
|
||||
if self.points > MAX_POINTS {
|
||||
return Err(Broken);
|
||||
}
|
||||
} else {
|
||||
self.flush();
|
||||
}
|
||||
self.pos = target;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A displacement in vector units (times the length factor and the subshape axes).
|
||||
fn by(&mut self, d: Vec2) -> Res {
|
||||
let k = d * self.scale;
|
||||
self.to(self.pos + Vec2::new(k.x * self.axes.x, k.y * self.axes.y))
|
||||
}
|
||||
|
||||
/// An arc of `radius` vector units from the pen, starting at angle `start` on its circle
|
||||
/// and turning `sweep` radians (positive counter-clockwise).
|
||||
fn arc(&mut self, radius: f64, start: f64, sweep: f64) -> Res {
|
||||
let r = radius * self.scale;
|
||||
if !(r.is_finite() && r > 0.0 && sweep.is_finite()) {
|
||||
return Ok(());
|
||||
}
|
||||
let n = if self.down { (sweep.abs() / TAU * ARC_STEPS).ceil().clamp(1.0, ARC_STEPS) as usize } else { 1 };
|
||||
let from = self.pos;
|
||||
let s0 = Vec2::new(start.cos(), start.sin());
|
||||
for i in 1..=n {
|
||||
let a = start + sweep * i as f64 / n as f64;
|
||||
let d = (Vec2::new(a.cos(), a.sin()) - s0) * r;
|
||||
self.to(from + Vec2::new(d.x * self.axes.x, d.y * self.axes.y))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An arc to the displacement `d` with `bulge` (−127..127, ±127 a semicircle).
|
||||
fn bulge(&mut self, d: Vec2, bulge: f64) -> Res {
|
||||
let b = bulge.clamp(-127.0, 127.0) / 127.0;
|
||||
if b == 0.0 || !self.down || d.len2() == 0.0 {
|
||||
return self.by(d);
|
||||
}
|
||||
// Centre from the chord midpoint along its left normal; sweep 4·atan(b).
|
||||
let centre = d * 0.5 + Vec2::new(-d.y, d.x) * ((1.0 - b * b) / (4.0 * b));
|
||||
let rel = -centre;
|
||||
self.arc(rel.len(), rel.y.atan2(rel.x), 4.0 * b.atan())
|
||||
}
|
||||
|
||||
/// The octant byte `±0SC`: (clockwise, start octant, octants with 0 = 8).
|
||||
fn octants(spec: u8) -> (f64, f64, f64) {
|
||||
let sign = if spec & 0x80 != 0 { -1.0 } else { 1.0 };
|
||||
let s = f64::from((spec >> 4) & 7);
|
||||
let c = match spec & 7 {
|
||||
0 => 8.0,
|
||||
c => f64::from(c),
|
||||
};
|
||||
(sign, s, c)
|
||||
}
|
||||
|
||||
/// The vector unit size of an extended big font's subshape cell.
|
||||
fn cell(&self) -> Vec2 {
|
||||
let h = if self.font.above > 0.0 { self.font.above } else { 1.0 };
|
||||
let w = if self.font.char_width > 0.0 { self.font.char_width } else { h };
|
||||
Vec2::new(w, h)
|
||||
}
|
||||
|
||||
fn run(&mut self, number: u16, depth: usize) -> Res {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err(Broken);
|
||||
}
|
||||
let program = self.font.program(number).ok_or(Broken)?;
|
||||
let mut r = Cur { b: program, pos: 0 };
|
||||
let mut skip = false;
|
||||
while r.pos < r.b.len() {
|
||||
self.step()?;
|
||||
let code = r.byte()?;
|
||||
// Code 14: in a dual-orientation font the next command is for vertical text only.
|
||||
let exec = !skip;
|
||||
skip = false;
|
||||
match code {
|
||||
0 => return Ok(()),
|
||||
1 if exec => self.down = true,
|
||||
2 if exec => {
|
||||
self.down = false;
|
||||
self.flush();
|
||||
}
|
||||
3 | 4 => {
|
||||
let k = f64::from(r.byte()?);
|
||||
if exec && k > 0.0 {
|
||||
let s = if code == 3 { self.scale / k } else { self.scale * k };
|
||||
if !(1e-9..=MAX_COORD).contains(&s) {
|
||||
return Err(Broken);
|
||||
}
|
||||
self.scale = s;
|
||||
}
|
||||
}
|
||||
5 if exec => {
|
||||
if self.stack.len() >= MAX_STACK {
|
||||
return Err(Broken);
|
||||
}
|
||||
self.stack.push(self.pos);
|
||||
}
|
||||
6 if exec => {
|
||||
self.flush();
|
||||
if let Some(p) = self.stack.pop() {
|
||||
self.pos = p;
|
||||
}
|
||||
}
|
||||
7 => self.subshape(&mut r, exec, depth)?,
|
||||
8 | 9 | 12 | 13 => loop {
|
||||
self.step()?;
|
||||
let d = Vec2::new(signed(r.byte()?), signed(r.byte()?));
|
||||
if matches!(code, 9 | 13) && d == Vec2::ZERO {
|
||||
break;
|
||||
}
|
||||
let bulge = if matches!(code, 12 | 13) { signed(r.byte()?) } else { 0.0 };
|
||||
if exec {
|
||||
self.bulge(d, bulge)?;
|
||||
}
|
||||
if matches!(code, 8 | 12) {
|
||||
break;
|
||||
}
|
||||
},
|
||||
10 => {
|
||||
let radius = f64::from(r.byte()?);
|
||||
let (sign, s, c) = Self::octants(r.byte()?);
|
||||
if exec {
|
||||
self.arc(radius, s * FRAC_PI_4, sign * c * FRAC_PI_4)?;
|
||||
}
|
||||
}
|
||||
11 => {
|
||||
let start = f64::from(r.byte()?) / 256.0;
|
||||
let end = match r.byte()? {
|
||||
0 => 1.0,
|
||||
e => f64::from(e) / 256.0,
|
||||
};
|
||||
let radius = f64::from(u16::from_be_bytes([r.byte()?, r.byte()?]));
|
||||
let (sign, s, c) = Self::octants(r.byte()?);
|
||||
if exec {
|
||||
// From `start` past the first octant boundary to `end` past the last
|
||||
// octant's.
|
||||
self.arc(radius, (s + sign * start) * FRAC_PI_4, sign * (c - 1.0 + end - start) * FRAC_PI_4)?;
|
||||
}
|
||||
}
|
||||
14 => skip = self.font.dual && !self.vertical && exec,
|
||||
1..=15 => {}
|
||||
v => {
|
||||
if exec {
|
||||
self.by(direction(v) * f64::from(v >> 4))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Code 7 and its operands.
|
||||
fn subshape(&mut self, r: &mut Cur, exec: bool, depth: usize) -> Res {
|
||||
match self.font.kind {
|
||||
ShxKind::Unifont => {
|
||||
let n = u16::from_be_bytes([r.byte()?, r.byte()?]);
|
||||
if exec {
|
||||
self.run(n, depth + 1)?;
|
||||
}
|
||||
}
|
||||
ShxKind::BigFont => {
|
||||
let first = r.byte()?;
|
||||
if first != 0 {
|
||||
if exec {
|
||||
self.run(u16::from(first), depth + 1)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// Extended: the primitive drawn in the box (x, y, width, height) of the cell.
|
||||
let n = u16::from_be_bytes([r.byte()?, r.byte()?]);
|
||||
let (x, y, w, h) = (f64::from(r.byte()?), f64::from(r.byte()?), f64::from(r.byte()?), f64::from(r.byte()?));
|
||||
if !exec {
|
||||
return Ok(());
|
||||
}
|
||||
let (pos, axes, down) = (self.pos, self.axes, self.down);
|
||||
let cell = self.cell();
|
||||
self.flush();
|
||||
self.pos = pos + Vec2::new(x * axes.x, y * axes.y) * self.scale;
|
||||
self.axes = Vec2::new(axes.x * w / cell.x, axes.y * h / cell.y);
|
||||
let res = self.run(n, depth + 1);
|
||||
self.flush();
|
||||
self.axes = axes;
|
||||
self.pos = pos;
|
||||
self.down = down;
|
||||
res?;
|
||||
}
|
||||
ShxKind::Shapes => {
|
||||
let n = u16::from(r.byte()?);
|
||||
if exec {
|
||||
self.run(n, depth + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Finding SHX files by the name a text style or linetype gives (`romans.shx`, `GBCBIG`,
|
||||
//! `C:\fonts\simplex.shx`): only the file name counts, so a path stored in a drawing never
|
||||
//! reaches another folder or computer.
|
||||
//!
|
||||
//! Order: the font map ([`set_font_map`]) renames the file; then fonts registered by the app
|
||||
//! ([`register`]: the web build's files opened alongside a drawing, tests); then the app's
|
||||
//! [`FontProvider`]; then, natively, the folders of opened drawings ([`add_drawing_dir`], most
|
||||
//! recent first) and the support search path ([`set_search_path`], from `CADCRAFT_SHXPATH` at
|
||||
//! start). Results, found or not, are cached until one of these changes ([`generation`]
|
||||
//! counts the changes, so a viewer knows to regenerate).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
|
||||
|
||||
use super::parse::MAX_FILE_BYTES;
|
||||
use super::{ShxError, ShxFont};
|
||||
|
||||
/// Where an app finds font files CADCraft can't reach itself (a web page's file store, an
|
||||
/// app's own font folder). Called with the normalised file name (`romans.shx`); returns the
|
||||
/// file's bytes. Called without the font table's lock held.
|
||||
pub trait FontProvider: Send + Sync {
|
||||
fn load(&self, file_name: &str) -> Option<Vec<u8>>;
|
||||
}
|
||||
|
||||
/// Most fonts kept loaded, and their total bytes.
|
||||
const MAX_FONTS: usize = 128;
|
||||
const MAX_LOADED_BYTES: usize = 256 << 20;
|
||||
/// Most drawing folders and search path entries remembered.
|
||||
const MAX_DIRS: usize = 32;
|
||||
/// Most font map entries.
|
||||
const MAX_MAP: usize = 1024;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Table {
|
||||
registered: HashMap<String, Arc<ShxFont>>,
|
||||
resolved: HashMap<String, Option<Arc<ShxFont>>>,
|
||||
loaded_bytes: usize,
|
||||
provider: Option<Arc<dyn FontProvider>>,
|
||||
drawing_dirs: Vec<PathBuf>,
|
||||
search_path: Vec<PathBuf>,
|
||||
map: Vec<(String, String)>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
/// Forget cached results: something that changes them changed.
|
||||
fn changed(&mut self) {
|
||||
self.resolved.clear();
|
||||
self.loaded_bytes = self.registered.values().map(|f| f.data.len()).sum();
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn table() -> MutexGuard<'static, Table> {
|
||||
static T: OnceLock<Mutex<Table>> = OnceLock::new();
|
||||
T.get_or_init(|| {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let search_path = std::env::var_os("CADCRAFT_SHXPATH")
|
||||
.map(|p| std::env::split_paths(&p).filter(|d| !d.as_os_str().is_empty()).take(MAX_DIRS).collect())
|
||||
.unwrap_or_default();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let search_path = Vec::new();
|
||||
Mutex::new(Table { search_path, ..Table::default() })
|
||||
})
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The lookup key of a font name: its lower-case file name with `.shx` (`romans.shx`), or
|
||||
/// `None` for a name that isn't an SHX file (a TrueType font, empty, the built-in font).
|
||||
pub(crate) fn key(name: &str) -> Option<String> {
|
||||
let base = name.trim().rsplit(['/', '\\']).next()?.trim();
|
||||
if base.is_empty() || base.len() > 255 || base.contains(':') || base.chars().any(char::is_control) {
|
||||
return None;
|
||||
}
|
||||
let lower = base.to_ascii_lowercase();
|
||||
if lower.ends_with(".shx") {
|
||||
return (lower.len() > 4).then_some(lower);
|
||||
}
|
||||
let ext = Path::new(&lower).extension().map(|e| e.to_string_lossy().to_string());
|
||||
match ext.as_deref() {
|
||||
// `txt` (no extension) is a classic style font name; other extensions are other kinds.
|
||||
None => (lower != crate::BUILTIN_FONT.to_ascii_lowercase() && !lower.contains(' ')).then(|| format!("{lower}.shx")),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register font bytes under a file name (the web build's files opened alongside a drawing;
|
||||
/// tests). Replaces a font registered under the same name.
|
||||
pub fn register(name: &str, bytes: &[u8]) -> Result<(), ShxError> {
|
||||
let k = key(name).ok_or(ShxError::NotShx)?;
|
||||
let font = Arc::new(ShxFont::parse(bytes)?);
|
||||
let mut t = table();
|
||||
if !t.registered.contains_key(&k) && t.registered.len() >= MAX_FONTS {
|
||||
return Err(ShxError::TooLarge);
|
||||
}
|
||||
t.registered.insert(k, font);
|
||||
t.changed();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget every registered font (a web session closing its drawings).
|
||||
pub fn clear_registered() {
|
||||
let mut t = table();
|
||||
t.registered.clear();
|
||||
t.changed();
|
||||
}
|
||||
|
||||
/// Set (or clear) the app's font provider.
|
||||
pub fn set_provider(provider: Option<Arc<dyn FontProvider>>) {
|
||||
let mut t = table();
|
||||
t.provider = provider;
|
||||
t.changed();
|
||||
}
|
||||
|
||||
/// Search the folder of a drawing being opened for the fonts its styles name (most recent
|
||||
/// first).
|
||||
pub fn add_drawing_dir(dir: &Path) {
|
||||
if dir.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut t = table();
|
||||
if t.drawing_dirs.first().is_some_and(|d| d == dir) {
|
||||
return;
|
||||
}
|
||||
t.drawing_dirs.retain(|d| d != dir);
|
||||
t.drawing_dirs.insert(0, dir.to_path_buf());
|
||||
t.drawing_dirs.truncate(MAX_DIRS);
|
||||
t.changed();
|
||||
}
|
||||
|
||||
/// The drawing folders searched, most recent first.
|
||||
pub fn drawing_dirs() -> Vec<PathBuf> {
|
||||
table().drawing_dirs.clone()
|
||||
}
|
||||
|
||||
/// Set the support search path for SHX files: folders separated by `;` (or the platform's
|
||||
/// path separator). Empty clears it.
|
||||
pub fn set_search_path(list: &str) {
|
||||
let dirs: Vec<PathBuf> = list
|
||||
.split(';')
|
||||
.flat_map(|p| if cfg!(any(windows, target_arch = "wasm32")) { vec![PathBuf::from(p)] } else { std::env::split_paths(p).collect() })
|
||||
.filter(|d| !d.as_os_str().is_empty() && !d.to_string_lossy().trim().is_empty())
|
||||
.take(MAX_DIRS)
|
||||
.collect();
|
||||
let mut t = table();
|
||||
t.search_path = dirs;
|
||||
t.changed();
|
||||
}
|
||||
|
||||
/// The support search path, `;`-separated.
|
||||
pub fn search_path() -> String {
|
||||
table().search_path.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(";")
|
||||
}
|
||||
|
||||
/// Set the font map: one `font;replacement` per line (or separated by `|`), as a font mapping
|
||||
/// file lists them. Lines that aren't a pair are ignored; empty clears it.
|
||||
pub fn set_font_map(text: &str) {
|
||||
let map: Vec<(String, String)> = text
|
||||
.split(['\n', '|'])
|
||||
.filter_map(|l| {
|
||||
let (a, b) = l.split_once(';')?;
|
||||
Some((key(a)?, b.trim().to_string()))
|
||||
})
|
||||
.filter(|(_, b)| !b.is_empty())
|
||||
.take(MAX_MAP)
|
||||
.collect();
|
||||
let mut t = table();
|
||||
t.map = map;
|
||||
t.changed();
|
||||
}
|
||||
|
||||
/// The font map, one `font;replacement` per line.
|
||||
pub fn font_map() -> String {
|
||||
table().map.iter().map(|(a, b)| format!("{a};{b}")).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// Counts changes to what fonts are found (registration, provider, folders, map).
|
||||
pub fn generation() -> u64 {
|
||||
table().generation
|
||||
}
|
||||
|
||||
/// The SHX font file a style or linetype names, if CADCraft can find and read it.
|
||||
pub fn find(name: &str) -> Option<Arc<ShxFont>> {
|
||||
let mut k = key(name)?;
|
||||
let (provider, dirs, generation) = {
|
||||
let t = table();
|
||||
// The font map renames once (a map can't loop).
|
||||
if let Some(to) = t.map.iter().find(|(from, _)| *from == k).and_then(|(_, to)| key(to)) {
|
||||
k = to;
|
||||
}
|
||||
if let Some(f) = t.registered.get(&k) {
|
||||
return Some(f.clone());
|
||||
}
|
||||
if let Some(r) = t.resolved.get(&k) {
|
||||
return r.clone();
|
||||
}
|
||||
let dirs: Vec<PathBuf> = t.drawing_dirs.iter().chain(t.search_path.iter()).cloned().collect();
|
||||
(t.provider.clone(), dirs, t.generation)
|
||||
};
|
||||
let font = provider
|
||||
.and_then(|p| p.load(&k))
|
||||
.or_else(|| read_from(&dirs, &k))
|
||||
.filter(|b| b.len() <= MAX_FILE_BYTES)
|
||||
.and_then(|b| ShxFont::parse(&b).ok())
|
||||
.map(Arc::new);
|
||||
let mut t = table();
|
||||
// Something changed while the file was read: keep the result out of the new cache.
|
||||
if t.generation != generation {
|
||||
return font;
|
||||
}
|
||||
if t.resolved.len() >= MAX_FONTS * 4 {
|
||||
t.resolved.clear();
|
||||
t.loaded_bytes = t.registered.values().map(|f| f.data.len()).sum();
|
||||
}
|
||||
let size = font.as_ref().map_or(0, |f| f.data.len());
|
||||
if t.loaded_bytes.saturating_add(size) > MAX_LOADED_BYTES {
|
||||
// Over the memory bound: used, not kept.
|
||||
return font;
|
||||
}
|
||||
t.loaded_bytes += size;
|
||||
t.resolved.insert(k, font.clone());
|
||||
font
|
||||
}
|
||||
|
||||
/// Most directory entries looked at for a font file whose name differs only in case.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_DIR_ENTRIES: usize = 10_000;
|
||||
|
||||
/// The file `name` (a lower-case key) in the first of `dirs` that has it, whatever the case of
|
||||
/// its name: lower-case, upper-case, else any other case found in the directory (`Simplex.shx`
|
||||
/// on a case-sensitive file system).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_from(dirs: &[PathBuf], name: &str) -> Option<Vec<u8>> {
|
||||
use std::io::Read as _;
|
||||
let read = |file: &Path| -> Option<Vec<u8>> {
|
||||
let meta = std::fs::metadata(file).ok()?;
|
||||
if !meta.is_file() || meta.len() > MAX_FILE_BYTES as u64 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
let limit = u64::try_from(MAX_FILE_BYTES).unwrap_or(u64::MAX).saturating_add(1);
|
||||
std::fs::File::open(file).and_then(|f| f.take(limit).read_to_end(&mut bytes)).ok()?;
|
||||
Some(bytes)
|
||||
};
|
||||
for d in dirs {
|
||||
if let Some(b) = [d.join(name), d.join(name.to_ascii_uppercase())].iter().find_map(|f| read(f)) {
|
||||
return Some(b);
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(d) else { continue };
|
||||
let found = entries
|
||||
.take(MAX_DIR_ENTRIES)
|
||||
.filter_map(Result::ok)
|
||||
.find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(name)))
|
||||
.and_then(|e| read(&e.path()));
|
||||
if found.is_some() {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_from(_dirs: &[PathBuf], _name: &str) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
//! SHX tests. Every font here is compiled from shape source written for these tests: no font
|
||||
//! file is read from disk or committed.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
|
||||
use super::*;
|
||||
use crate::{TextFont, TextParams};
|
||||
|
||||
/// A small dual-orientation text font: 6 units above, 2 below.
|
||||
pub(crate) const TEXT_FONT: &str = "\
|
||||
;; CADCraft test font (written for the tests)
|
||||
*0,4,cadcraft test
|
||||
6,2,2,0
|
||||
*32,3,SPACE
|
||||
2,060,0
|
||||
; A: up 6, right 4, down 6, then a gap of 2
|
||||
*65,7,UCA
|
||||
1,064,040,06C,2,020,0
|
||||
; B: a displacement out and back, then 5 on
|
||||
*66,10,UCB
|
||||
2,010,1,8,(3,4),8,(-3,-4),2,050,0
|
||||
; C: vertical-only moves (code 14)
|
||||
*67,12,UCC
|
||||
2,14,8,(0,-8),1,064,2,14,8,(0,-6),040,0
|
||||
; the degree sign: a full octant circle
|
||||
*256,12,DEGREE
|
||||
2,8,(1,4),1,10,(1,0),2,8,(2,-4),0
|
||||
*257,6,PLUSMINUS
|
||||
1,040,2,020,0
|
||||
*258,6,DIAMETER
|
||||
1,044,2,020,0
|
||||
";
|
||||
|
||||
/// A Unicode font with the same letters, a subshape and the diameter sign at U+2205.
|
||||
const UNI_FONT: &str = "\
|
||||
*UNIFONT,6,cadcraft uni
|
||||
6,2,0,0,0,0
|
||||
*00041,7,uca
|
||||
1,064,040,06C,2,020,0
|
||||
*000C5,9,ring a
|
||||
7,00041,2,8,(-4,7),1,040,2,8,(2,-7),0
|
||||
*02205,6,diameter
|
||||
1,044,2,020,0
|
||||
";
|
||||
|
||||
/// A big font with Shift-JIS lead bytes: one square character (U+3042, 0x82A0).
|
||||
const BIG_FONT: &str = "\
|
||||
*BIGFONT 2,1,081,09F
|
||||
*0,4,cadcraft big
|
||||
8,0,0,0
|
||||
*082A0,7,square
|
||||
1,080,084,088,08C,2,080,0
|
||||
";
|
||||
|
||||
/// An extended big font: two primitives of a 16 x 16 cell drawn into one character.
|
||||
const EXT_BIG_FONT: &str = "\
|
||||
*BIGFONT 2,1,081,09F
|
||||
*0,5,cadcraft ext
|
||||
16,0,0,16,0
|
||||
*08F01,10,box
|
||||
4,2,1,080,084,088,08C,3,2,0
|
||||
*082A2,20,two boxes
|
||||
7,0,08F01,0,0,8,16,7,0,08F01,8,0,8,16,2,4,2,080,3,2,0
|
||||
";
|
||||
|
||||
fn font(src: &str) -> ShxFont {
|
||||
ShxFont::parse(&compile(src).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// A shape file (no font record) with one shape from `spec`.
|
||||
fn one(spec: &str) -> ShxFont {
|
||||
font(&format!("*1,1,T\n{spec}\n"))
|
||||
}
|
||||
|
||||
fn adv(spec: &str) -> Vec2 {
|
||||
one(spec).glyph(1, false).unwrap().advance
|
||||
}
|
||||
|
||||
fn close(a: Vec2, b: Vec2) -> bool {
|
||||
(a - b).len() < 1e-6
|
||||
}
|
||||
|
||||
fn dir(deg: f64) -> Vec2 {
|
||||
let r = deg.to_radians();
|
||||
Vec2::new(r.cos(), r.sin())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_vector_direction() {
|
||||
let want = [
|
||||
(1.0, 0.0),
|
||||
(1.0, 0.5),
|
||||
(1.0, 1.0),
|
||||
(0.5, 1.0),
|
||||
(0.0, 1.0),
|
||||
(-0.5, 1.0),
|
||||
(-1.0, 1.0),
|
||||
(-1.0, 0.5),
|
||||
(-1.0, 0.0),
|
||||
(-1.0, -0.5),
|
||||
(-1.0, -1.0),
|
||||
(-0.5, -1.0),
|
||||
(0.0, -1.0),
|
||||
(0.5, -1.0),
|
||||
(1.0, -1.0),
|
||||
(1.0, -0.5),
|
||||
];
|
||||
for (d, (x, y)) in want.iter().enumerate() {
|
||||
let a = adv(&format!("02{d:X},0"));
|
||||
assert!(close(a, Vec2::new(2.0 * x, 2.0 * y)), "direction {d}: {a:?}");
|
||||
}
|
||||
// Length 15, the longest vector.
|
||||
assert!(close(adv("0F0,0"), Vec2::new(15.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_up_down_scale_push_pop() {
|
||||
let f = one("2,010,1,020,2,010,1,010,0");
|
||||
let g = f.glyph(1, false).unwrap();
|
||||
assert_eq!(g.strokes, vec![vec![Vec2::new(1.0, 0.0), Vec2::new(3.0, 0.0)], vec![Vec2::new(4.0, 0.0), Vec2::new(5.0, 0.0)]]);
|
||||
// 4 multiplies, 3 divides: 3 + 1.
|
||||
assert!(close(adv("4,3,010,3,3,010,0"), Vec2::new(4.0, 0.0)));
|
||||
// Push, draw, pop: the next run starts where the push was.
|
||||
let g = one("5,040,6,014,0").glyph(1, false).unwrap();
|
||||
assert_eq!(g.strokes.len(), 2);
|
||||
assert!(close(g.strokes[1][0], Vec2::ZERO) && close(g.advance, Vec2::new(0.0, 1.0)));
|
||||
// An unmatched pop is ignored.
|
||||
assert!(close(adv("6,010,0"), Vec2::new(1.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn displacements() {
|
||||
assert!(close(adv("8,(-3,5),0"), Vec2::new(-3.0, 5.0)));
|
||||
assert!(close(adv("9,(1,1),(2,-1),(-127,0),(0,0),0"), Vec2::new(-124.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn octant_and_fractional_arcs() {
|
||||
// A full circle comes back to where it started.
|
||||
let g = one("10,(2,0),0").glyph(1, false).unwrap();
|
||||
assert!(close(g.advance, Vec2::ZERO));
|
||||
let maxx = g.strokes.iter().flatten().map(|p| p.x).fold(f64::MIN, f64::max);
|
||||
assert!((maxx - 0.0).abs() < 1e-9 && g.strokes.iter().flatten().any(|p| (p.x + 4.0).abs() < 1e-6), "circle left of the pen");
|
||||
// Clockwise from octant 4 (180°), three octants: ends at 45°.
|
||||
assert!(close(adv("10,(1,-043),0"), dir(45.0) - dir(180.0)));
|
||||
// Counter-clockwise quarter from 0°.
|
||||
assert!(close(adv("10,(3,002),0"), (dir(90.0) - dir(0.0)) * 3.0));
|
||||
// The documented fractional example: 55° to 95°, radius 3 (start octant 1, two octants).
|
||||
let a0 = 45.0 + 56.0 / 256.0 * 45.0;
|
||||
let a1 = 90.0 + 28.0 / 256.0 * 45.0;
|
||||
assert!(close(adv("11,(56,28,0,3,012),0"), (dir(a1) - dir(a0)) * 3.0));
|
||||
// High radius byte: 256 + 4.
|
||||
assert!(close(adv("11,(0,0,1,4,001),0"), (dir(45.0) - dir(0.0)) * 260.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulge_arcs() {
|
||||
// A counter-clockwise semicircle up the chord bulges to the right... of the chord's left
|
||||
// normal: from (0,0) to (0,4) about (0,2), through (2,2).
|
||||
let g = one("12,(0,4,127),0").glyph(1, false).unwrap();
|
||||
assert!(close(g.advance, Vec2::new(0.0, 4.0)));
|
||||
assert!(g.strokes.iter().flatten().any(|p| close(*p, Vec2::new(2.0, 2.0))), "{:?}", g.strokes);
|
||||
// Bulge 0 is a straight line.
|
||||
assert_eq!(one("12,(3,0,0),0").glyph(1, false).unwrap().strokes, vec![vec![Vec2::ZERO, Vec2::new(3.0, 0.0)]]);
|
||||
// Polyarc: two arcs, then the (0,0) end without a bulge.
|
||||
let g = one("13,(4,0,127),(0,4,-127),(0,0),010,0").glyph(1, false).unwrap();
|
||||
assert!(close(g.advance, Vec2::new(5.0, 4.0)));
|
||||
assert!(g.strokes.iter().flatten().any(|p| (p.y + 2.0).abs() < 1e-6), "first arc dips below");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_only_commands_and_text_font_record() {
|
||||
let f = font(TEXT_FONT);
|
||||
assert_eq!((f.kind(), f.name(), f.above(), f.below(), f.is_dual(), f.is_font()), (ShxKind::Shapes, "cadcraft test", 6.0, 2.0, true, true));
|
||||
let h = f.glyph(67, false).unwrap();
|
||||
assert_eq!(h.strokes, vec![vec![Vec2::ZERO, Vec2::new(0.0, 6.0)]]);
|
||||
let v = f.glyph(67, true).unwrap();
|
||||
assert_eq!(v.strokes, vec![vec![Vec2::new(0.0, -8.0), Vec2::new(0.0, -2.0)]]);
|
||||
assert!(close(v.advance, Vec2::new(4.0, -8.0)));
|
||||
// A font that isn't dual runs code 14's command in horizontal text too.
|
||||
let g = one("2,14,8,(0,-8),0").glyph(1, false).unwrap();
|
||||
assert!(close(g.advance, Vec2::new(0.0, -8.0)));
|
||||
assert_eq!(f.shape_number("ucb"), Some(66));
|
||||
assert_eq!(f.shape_name(65).as_deref(), Some("UCA"));
|
||||
assert_eq!(f.shape_numbers().first(), Some(&32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subshapes_shapes_unicode_and_extended_big_font() {
|
||||
let s = font("*1,4,BAR\n1,030,0\n*2,6,TWO\n7,1,7,1,0\n");
|
||||
assert!(close(s.glyph(2, false).unwrap().advance, Vec2::new(6.0, 0.0)));
|
||||
let u = font(UNI_FONT);
|
||||
assert_eq!((u.kind(), u.name(), u.above(), u.encoding()), (ShxKind::Unifont, "cadcraft uni", 6.0, 0));
|
||||
let ring = u.glyph(0xC5, false).unwrap();
|
||||
assert_eq!(ring.strokes.len(), 2, "the A and the ring's bar");
|
||||
assert!(close(ring.advance, Vec2::new(8.0, 0.0)));
|
||||
let e = font(EXT_BIG_FONT);
|
||||
assert_eq!((e.kind(), e.above()), (ShxKind::BigFont, 16.0));
|
||||
let g = e.glyph(0x82A2, false).unwrap();
|
||||
assert_eq!(g.strokes.len(), 2);
|
||||
let xs: Vec<(f64, f64)> = g.strokes.iter().map(|s| s.iter().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.x), b.max(p.x)))).collect();
|
||||
assert_eq!(xs, vec![(0.0, 8.0), (8.0, 16.0)], "each primitive squeezed into half the cell");
|
||||
let ys = g.strokes.iter().flatten().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
|
||||
assert_eq!(ys, (0.0, 16.0));
|
||||
assert!(close(g.advance, Vec2::new(16.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_font_header_and_escape_ranges() {
|
||||
let b = font(BIG_FONT);
|
||||
assert_eq!(b.kind(), ShxKind::BigFont);
|
||||
assert_eq!(b.escape_ranges(), &[(0x81, 0x9F)]);
|
||||
assert!(b.is_lead_byte(0x82) && !b.is_lead_byte(0x41) && !b.is_lead_byte(0xA0));
|
||||
assert_eq!(b.above(), 8.0);
|
||||
assert!(b.has_shape(0x82A0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_and_deep_subshapes_are_bounded() {
|
||||
let f = font("*1,3,SELF\n7,1,0\n*2,3,A\n7,3,0\n*3,3,B\n7,2,0\n*4,3,MISSING\n7,9,0\n");
|
||||
for n in 1..=4 {
|
||||
assert!(f.glyph(n, false).is_none(), "shape {n}");
|
||||
}
|
||||
// A chain deeper than the bound fails; a shallow one draws.
|
||||
let mut src = String::new();
|
||||
for n in 1..=40u16 {
|
||||
if n == 40 {
|
||||
src.push_str(&format!("*{n},2,L\n010,0\n"));
|
||||
} else {
|
||||
src.push_str(&format!("*{n},3,L\n7,{},0\n", n + 1));
|
||||
}
|
||||
}
|
||||
let f = font(&src);
|
||||
assert!(f.glyph(1, false).is_none() && f.glyph(30, false).is_some());
|
||||
// Doubling with subshapes: 2^n steps are cut off by the step bound.
|
||||
let mut src = String::from("*1,2,L\n010,0\n");
|
||||
for n in 2..=10u16 {
|
||||
src.push_str(&format!("*{n},9,D\n7,{0},7,{0},7,{0},7,{0},0\n", n - 1));
|
||||
}
|
||||
let f = font(&src);
|
||||
assert!(f.glyph(10, false).is_none() && f.glyph(3, false).is_some());
|
||||
// Pushes without pops, and runaway scales.
|
||||
let pushes = format!("{}0", "5,".repeat(100));
|
||||
assert!(one(&pushes).glyph(1, false).is_none());
|
||||
let scales = format!("{}010,0", "4,255,".repeat(10));
|
||||
assert!(one(&scales).glyph(1, false).is_none());
|
||||
// A zero radius or a zero factor draws nothing and doesn't fail.
|
||||
assert!(one("10,(0,001),4,0,010,0").glyph(1, false).is_some());
|
||||
}
|
||||
|
||||
/// Parse every prefix and many mutations of `bytes`; draw every shape of whatever parses.
|
||||
fn hammer(bytes: &[u8]) {
|
||||
let draw = |b: &[u8]| {
|
||||
if let Ok(f) = ShxFont::parse(b) {
|
||||
for n in f.shape_numbers() {
|
||||
let _ = f.glyph(n, false);
|
||||
let _ = f.glyph(n, true);
|
||||
}
|
||||
let _ = f.shape_number("X");
|
||||
}
|
||||
};
|
||||
for n in 0..=bytes.len() {
|
||||
draw(&bytes[..n]);
|
||||
}
|
||||
let mut seed: u64 = 0x5EED_1234;
|
||||
for _ in 0..2000 {
|
||||
let mut b = bytes.to_vec();
|
||||
for _ in 0..3 {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
let i = (seed >> 33) as usize % b.len().max(1);
|
||||
if let Some(x) = b.get_mut(i) {
|
||||
*x = (seed >> 24) as u8;
|
||||
}
|
||||
}
|
||||
draw(&b);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_files_never_panic() {
|
||||
for src in [TEXT_FONT, UNI_FONT, BIG_FONT, EXT_BIG_FONT] {
|
||||
hammer(&compile(src).unwrap());
|
||||
}
|
||||
// Counts far beyond the data.
|
||||
let mut shapes = parse::SHAPES_10.to_vec();
|
||||
shapes.extend([1, 0, 0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
assert_eq!(ShxFont::parse(&shapes).err(), Some(ShxError::Truncated));
|
||||
let mut big = parse::BIGFONT.to_vec();
|
||||
big.extend([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
assert_eq!(ShxFont::parse(&big).err(), Some(ShxError::TooLarge));
|
||||
let mut big = parse::BIGFONT.to_vec();
|
||||
big.extend([0xFF, 0xFF, 0xFF, 0xFF, 0, 0]);
|
||||
assert_eq!(ShxFont::parse(&big).err(), Some(ShxError::Truncated));
|
||||
// A big font index pointing anywhere.
|
||||
let mut big = parse::BIGFONT.to_vec();
|
||||
big.extend([1, 0, 1, 0, 0, 0, 0x41, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F]);
|
||||
assert_eq!(ShxFont::parse(&big).err(), Some(ShxError::Empty));
|
||||
let mut uni = parse::UNIFONT.to_vec();
|
||||
uni.extend([0xFF, 0xFF, 0xFF, 0xFF, 2, 0, b'x', 0]);
|
||||
assert_eq!(ShxFont::parse(&uni).err(), Some(ShxError::Empty));
|
||||
assert_eq!(ShxFont::parse(b"not a font").err(), Some(ShxError::NotShx));
|
||||
assert_eq!(ShxFont::parse(&[]).err(), Some(ShxError::NotShx));
|
||||
// Hostile shape source.
|
||||
for src in ["*1,2,X\n7\n", "*1,2,X\n9,(1,1)\n", "*99999,1,X\n0\n", "*1,1,X\n300,0\n", "010,0\n", "*1,1,X\n1,0zz,0\n", "*1,1,X\n0\n*1,1,Y\n0\n"] {
|
||||
assert!(compile(src).is_err(), "{src:?}");
|
||||
}
|
||||
assert!(compile("").is_err());
|
||||
}
|
||||
|
||||
/// Register a compiled test font under `name` (each test its own names: the table is shared).
|
||||
fn reg(name: &str, src: &str) {
|
||||
register(name, &compile(src).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_metrics_follow_the_font() {
|
||||
reg("cadcraft-test-metrics.shx", TEXT_FONT);
|
||||
let f = TextFont::resolve_style("CADCRAFT-TEST-METRICS", "", false);
|
||||
assert!(f.is_shx() && !f.is_outline());
|
||||
let h = 3.0;
|
||||
// Advances are the pen's end positions: A 6, B 6, at h / above = 0.5.
|
||||
assert!((crate::text_width(&f, "AB", h, 1.0) - 6.0).abs() < 1e-9);
|
||||
assert!((crate::text_width(&f, "AB", h, 0.5) - 3.0).abs() < 1e-9);
|
||||
let sh = crate::shape_line(&f, "AB", h, 1.0, 0.0);
|
||||
assert!((sh.width - 6.0).abs() < 1e-9);
|
||||
let ys = sh.strokes.iter().flatten().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
|
||||
assert_eq!(ys, (0.0, 3.0), "cap height = text height");
|
||||
// %%d %%p %%c come from shapes 256-258; \U+0041 is A.
|
||||
assert!((crate::text_width(&f, "%%d", h, 1.0) - 1.5).abs() < 1e-9);
|
||||
assert!((crate::text_width(&f, "%%p%%c", h, 1.0) - 4.0).abs() < 1e-9);
|
||||
assert!((crate::text_width(&f, "\\U+0041", h, 1.0) - 3.0).abs() < 1e-9);
|
||||
assert_eq!(crate::shape_line(&f, "\\U+0041", h, 1.0, 0.0), crate::shape_line(&f, "A", h, 1.0, 0.0));
|
||||
// A character the font lacks comes from the stroke font, with its advance.
|
||||
let z = crate::text_width(&f, "Z", h, 1.0);
|
||||
assert!((z - crate::stroke_advance('Z', h, 1.0)).abs() < 1e-9);
|
||||
assert!(!crate::shape_line(&f, "Z", h, 1.0, 0.0).strokes.is_empty());
|
||||
// Widths and shapes agree, with obliquing, underline and mixed fallbacks.
|
||||
for s in ["AB AZ", "%%uAB%%u", "A\u{3042}B"] {
|
||||
let sh = crate::shape_line(&f, s, h, 0.8, 0.2);
|
||||
assert!((sh.width - crate::text_width(&f, s, h, 0.8)).abs() < 1e-9, "{s}");
|
||||
}
|
||||
// Placement: centred text sits on the alignment point by the font's own width.
|
||||
let p = TextParams { h: crate::Align::Center, align_pt: Some(Vec2::new(10.0, 0.0)), ..TextParams::new(Vec2::ZERO, h) };
|
||||
let (_, bb) = crate::place(&f, "AB", &p);
|
||||
assert!((bb.center().x - 10.0).abs() < 1e-9 && (bb.width() - 6.0).abs() < 1e-9);
|
||||
// MTEXT with the style's SHX font, and its \U+ escape.
|
||||
let mp = crate::MTextParams { font: f.clone(), ..crate::MTextParams::new(Vec2::ZERO, h) };
|
||||
let m = crate::layout_mtext_with("\\U+0041B", &mp);
|
||||
assert_eq!(m.lines, vec!["AB".to_string()]);
|
||||
assert_eq!(crate::space_width(&f, h, 1.0), 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unifont_text_and_specials() {
|
||||
reg("cadcraft-test-uni.shx", UNI_FONT);
|
||||
let f = TextFont::resolve("cadcraft-test-uni.shx");
|
||||
assert!((crate::text_width(&f, "A\u{C5}", 6.0, 1.0) - 14.0).abs() < 1e-9);
|
||||
// %%c is the diameter sign, at U+2205 in this font.
|
||||
assert!((crate::text_width(&f, "%%c", 6.0, 1.0) - 2.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_font_draws_double_byte_characters() {
|
||||
reg("cadcraft-test-main.shx", TEXT_FONT);
|
||||
reg("cadcraft-test-big.shx", BIG_FONT);
|
||||
let f = TextFont::resolve_style("cadcraft-test-main.shx", "cadcraft-test-big.shx", false);
|
||||
let TextFont::Shx(t) = &f else { panic!("SHX") };
|
||||
assert_eq!(t.big_font_encoding(), "Shift_JIS");
|
||||
let h = 2.5;
|
||||
// あ (Shift-JIS 82A0) fills a square cell of the text height.
|
||||
let w = crate::text_width(&f, "\u{3042}", h, 1.0);
|
||||
assert!((w - h).abs() < 1e-9, "{w}");
|
||||
let sh = crate::shape_line(&f, "A\u{3042}", h, 1.0, 0.0);
|
||||
let big: Vec<Vec2> = sh.strokes.iter().flatten().copied().filter(|p| p.x > 2.5 - 1e-9).collect();
|
||||
let ys = big.iter().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
|
||||
assert_eq!(ys, (0.0, h));
|
||||
// Only the big font found: Latin letters from the stroke font, the big font's own.
|
||||
let f = TextFont::resolve_style("cadcraft-missing-main-font.shx", "cadcraft-test-big.shx", false);
|
||||
assert!(f.is_shx());
|
||||
assert!((crate::text_width(&f, "\u{3042}", h, 1.0) - h).abs() < 1e-9);
|
||||
assert!((crate::text_width(&f, "A", h, 1.0) - crate::stroke_advance('A', h, 1.0)).abs() < 1e-9);
|
||||
// Neither found: the stroke font as before.
|
||||
assert!(!TextFont::resolve_style("cadcraft-missing-a.shx", "cadcraft-missing-b.shx", false).is_shx());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_font_code_pages() {
|
||||
for (name, ranges, want) in [
|
||||
("gbcbig.shx", vec![(0xA1, 0xFE)], "GBK"),
|
||||
("chineset.shx", vec![(0xA1, 0xFE)], "Big5"),
|
||||
("whgtxt.shx", vec![(0xA1, 0xFE)], "EUC-KR"),
|
||||
("extfont2.shx", vec![(0x81, 0x9F)], "Shift_JIS"),
|
||||
("custom.shx", vec![(0x81, 0x9F), (0xE0, 0xFC)], "Shift_JIS"),
|
||||
("custom.shx", vec![(0xA1, 0xFE)], "GBK"),
|
||||
] {
|
||||
let src = format!(
|
||||
"*BIGFONT 1,{},{}\n*0,4,x\n8,0,0,0\n*0FFFF,2,x\n010,0\n",
|
||||
ranges.len(),
|
||||
ranges.iter().map(|(a, b)| format!("0{a:X},0{b:X}")).collect::<Vec<_>>().join(",")
|
||||
);
|
||||
let b = std::sync::Arc::new(font(&src));
|
||||
assert_eq!(ShxText::new(None, Some(b), name, false).big_font_encoding(), want, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_text_runs_down() {
|
||||
reg("cadcraft-test-vertical.shx", TEXT_FONT);
|
||||
let v = TextFont::resolve_style("cadcraft-test-vertical.shx", "", true);
|
||||
let sh = crate::shape_line(&v, "CC", 1.0, 1.0, 0.0);
|
||||
let miny = sh.strokes.iter().flatten().map(|p| p.y).fold(f64::MAX, f64::min);
|
||||
// Two characters, each 8 units (8/6 of the height) down.
|
||||
assert!((miny + 16.0 / 6.0).abs() < 1e-9, "{miny}");
|
||||
assert!(sh.strokes.iter().flatten().all(|p| p.y <= 1e-9), "below the insertion point");
|
||||
// A font without modes 2 stays horizontal.
|
||||
reg("cadcraft-test-horizontal.shx", "*0,4,h\n6,2,0,0\n*67,3,C\n1,064,0\n");
|
||||
let h = TextFont::resolve_style("cadcraft-test-horizontal.shx", "", true);
|
||||
assert!(crate::shape_line(&h, "CC", 1.0, 1.0, 0.0).strokes.iter().flatten().all(|p| p.y >= -1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_and_lookup() {
|
||||
assert_eq!(table::key("ROMANS.SHX").as_deref(), Some("romans.shx"));
|
||||
assert_eq!(table::key("txt").as_deref(), Some("txt.shx"));
|
||||
assert_eq!(table::key(r"C:\Fonts\Simplex.shx").as_deref(), Some("simplex.shx"));
|
||||
assert_eq!(table::key("../x/gbcbig.shx").as_deref(), Some("gbcbig.shx"));
|
||||
for n in ["", "arial.ttf", "CADCraft Stroke", "Times New Roman", ".shx", "a\u{0}.shx"] {
|
||||
assert_eq!(table::key(n), None, "{n:?}");
|
||||
}
|
||||
assert!(register("not-a-font.ttf", &compile(TEXT_FONT).unwrap()).is_err());
|
||||
assert!(register("broken.shx", b"AutoCAD-86 shapes 1.0\r\n\x1a").is_err());
|
||||
// A path stored in a drawing only names the file.
|
||||
reg("cadcraft-test-path.shx", TEXT_FONT);
|
||||
assert!(find(r"\\server\share\CADCRAFT-TEST-PATH.SHX").is_some());
|
||||
assert!(find("cadcraft-never-registered.shx").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arcs_follow_the_circle() {
|
||||
// Every point of an octant arc is on its circle.
|
||||
let g = one("10,(5,034),0").glyph(1, false).unwrap();
|
||||
let c = -dir(135.0) * 5.0;
|
||||
for p in g.strokes.iter().flatten() {
|
||||
assert!(((*p - c).len() - 5.0).abs() < 1e-9);
|
||||
}
|
||||
assert!(close(g.advance, (dir(135.0 + 180.0) - dir(135.0)) * 5.0));
|
||||
let _ = PI;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Text set in SHX fonts: a text style's main font and big font, horizontal or vertical.
|
||||
//!
|
||||
//! Each character is drawn by the font that has it: a double-byte character of the big font's
|
||||
//! code page (its lead byte in an escape range) from the big font, others from the main font
|
||||
//! (character code, or Unicode in a Unicode font; `%%d` `%%p` `%%c` from shapes 256–258 or their
|
||||
//! older or Unicode places). A font draws at text height / its `above` value, and its pen's end
|
||||
//! position is the advance: there is no added letter gap. Characters neither font has come from
|
||||
//! the built-in stroke font (and its fallback fonts), so nothing goes missing.
|
||||
//!
|
||||
//! Vertical text (a style flag, for fonts with modes 2) runs each shape's vertical-only
|
||||
//! commands (code 14) and follows the pen downward.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
use encoding_rs::Encoding;
|
||||
|
||||
use super::{ShxFont, ShxKind};
|
||||
use crate::{Shaped, bidi};
|
||||
|
||||
/// A text style's SHX fonts: the main font, the big font and the orientation. Either font may
|
||||
/// be missing (not found): its characters then come from the stroke font.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShxText {
|
||||
pub main: Option<Arc<ShxFont>>,
|
||||
pub big: Option<Arc<ShxFont>>,
|
||||
/// The style is vertical and the main font supports it.
|
||||
pub vertical: bool,
|
||||
/// The big font's code page (from its name and escape ranges).
|
||||
big_encoding: &'static Encoding,
|
||||
}
|
||||
|
||||
impl ShxText {
|
||||
/// The fonts of a style: `big_name` is the big font's file name (it picks the code page).
|
||||
pub fn new(main: Option<Arc<ShxFont>>, big: Option<Arc<ShxFont>>, big_name: &str, vertical: bool) -> ShxText {
|
||||
let big_encoding = big.as_deref().map_or(encoding_rs::GBK, |b| big_font_encoding(big_name, b.escape_ranges()));
|
||||
let vertical = vertical && main.as_ref().is_some_and(|m| m.is_dual());
|
||||
ShxText { main, big, vertical, big_encoding }
|
||||
}
|
||||
/// The code page of the big font's double-byte codes.
|
||||
pub fn big_font_encoding(&self) -> &'static str {
|
||||
self.big_encoding.name()
|
||||
}
|
||||
}
|
||||
|
||||
/// The code page a big font's double-byte codes are in: known Japanese, Chinese and Korean
|
||||
/// big font names, else its escape ranges (Shift-JIS lead bytes from 0x81; the others start at
|
||||
/// 0xA1 or above, read as GBK).
|
||||
fn big_font_encoding(name: &str, ranges: &[(u16, u16)]) -> &'static Encoding {
|
||||
let stem = name.trim().rsplit(['/', '\\']).next().unwrap_or_default().to_ascii_lowercase();
|
||||
let stem = stem.trim_end_matches(".shx").trim_start_matches('@');
|
||||
if stem.starts_with("whg") || stem.starts_with("wht") || stem.contains("kor") || stem.contains("ksc") {
|
||||
return encoding_rs::EUC_KR;
|
||||
}
|
||||
if stem.starts_with("chineset") || stem.contains("big5") || stem.starts_with("cht") {
|
||||
return encoding_rs::BIG5;
|
||||
}
|
||||
if stem.starts_with("gb") || stem.starts_with("hz") || stem.contains("chs") {
|
||||
return encoding_rs::GBK;
|
||||
}
|
||||
if stem.starts_with("bigfont") || stem.starts_with("extfont") || stem.contains("jis") || stem.contains("jpn") {
|
||||
return encoding_rs::SHIFT_JIS;
|
||||
}
|
||||
if ranges.iter().any(|(lo, _)| (0x81..0xA1).contains(lo)) { encoding_rs::SHIFT_JIS } else { encoding_rs::GBK }
|
||||
}
|
||||
|
||||
/// The double-byte code of `c` in `enc`, if it has one.
|
||||
fn double_byte(c: char, enc: &'static Encoding) -> Option<u16> {
|
||||
let mut buf = [0u8; 4];
|
||||
let (bytes, _, errors) = enc.encode(c.encode_utf8(&mut buf));
|
||||
match (errors, bytes.as_ref()) {
|
||||
(false, [a, b]) => Some(u16::from_be_bytes([*a, *b])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape numbers that may draw `c` in `font`, best first.
|
||||
fn candidates(c: char, font: &ShxFont) -> [Option<u16>; 4] {
|
||||
let u = u32::from(c);
|
||||
let unicode = font.kind() == ShxKind::Unifont && font.encoding() == 0;
|
||||
let code = |x: u32| u16::try_from(x).ok();
|
||||
// The special characters of `%%d` `%%p` `%%c`: shapes 256–258, the Latin-1 or Unicode code,
|
||||
// and the oldest fonts' 127–129.
|
||||
let special = match c {
|
||||
'°' => Some((256, 0xB0, 127)),
|
||||
'±' => Some((257, 0xB1, 128)),
|
||||
'⌀' | '∅' => Some((258, u, 129)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((s, latin, old)) = special {
|
||||
return if unicode {
|
||||
[code(u), code(if c == '⌀' { 0x2205 } else { latin }), Some(s), Some(old)]
|
||||
} else {
|
||||
[Some(s), code(latin), Some(old), None]
|
||||
};
|
||||
}
|
||||
if unicode || u < 0x80 {
|
||||
return [code(u), None, None, None];
|
||||
}
|
||||
// A single-byte font: the Windows Latin-1 code of the character (not the old special
|
||||
// codes 127–129).
|
||||
let mut buf = [0u8; 4];
|
||||
let (bytes, _, errors) = encoding_rs::WINDOWS_1252.encode(c.encode_utf8(&mut buf));
|
||||
match (errors, bytes.as_ref()) {
|
||||
(false, [b]) if !(127..=129).contains(b) => [Some(u16::from(*b)), None, None, None],
|
||||
_ => [None, None, None, None],
|
||||
}
|
||||
}
|
||||
|
||||
/// The font and shape that draw `c`.
|
||||
fn pick(t: &ShxText, c: char) -> Option<(&ShxFont, u16)> {
|
||||
let from_main = || {
|
||||
let m = t.main.as_deref()?;
|
||||
candidates(c, m).into_iter().flatten().find(|n| m.has_shape(*n)).map(|n| (m, n))
|
||||
};
|
||||
let from_big = || {
|
||||
let b = t.big.as_deref()?;
|
||||
let code = double_byte(c, t.big_encoding)?;
|
||||
let [lead, _] = code.to_be_bytes();
|
||||
(b.is_lead_byte(lead) && b.has_shape(code)).then_some((b, code))
|
||||
};
|
||||
if u32::from(c) < 0x100 || matches!(c, '⌀' | '∅') { from_main().or_else(from_big) } else { from_big().or_else(from_main) }
|
||||
}
|
||||
|
||||
fn finite_or(v: f64, d: f64) -> f64 {
|
||||
if v.is_finite() { v } else { d }
|
||||
}
|
||||
|
||||
/// Shape one line (baseline at y = 0, x from 0).
|
||||
pub(crate) fn shape(t: &ShxText, s: &str, height: f64, width_factor: f64, oblique: f64) -> Shaped {
|
||||
let h = if height.is_finite() && height > 0.0 { height } else { 1.0 };
|
||||
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
|
||||
let shear = finite_or(oblique, 0.0).tan().clamp(-10.0, 10.0);
|
||||
let chars = crate::decode_controls(s);
|
||||
let logical: Vec<char> = chars.iter().map(|v| v.0).collect();
|
||||
let mut out = Shaped::default();
|
||||
let mut spans: Vec<(f64, f64, bool, bool)> = Vec::new();
|
||||
let mut pen = Vec2::ZERO;
|
||||
let mut max_x: f64 = 0.0;
|
||||
let runs = if t.vertical { vec![(0..logical.len(), false)] } else { bidi::visual_runs(&logical, None) };
|
||||
for (run, rtl) in runs {
|
||||
let order: Box<dyn Iterator<Item = usize>> = if rtl { Box::new(run.rev()) } else { Box::new(run) };
|
||||
for i in order {
|
||||
let Some(&(c, under, over)) = chars.get(i) else { continue };
|
||||
if bidi::is_format_control(c) {
|
||||
continue;
|
||||
}
|
||||
let c = if rtl { bidi::mirror(c) } else { c };
|
||||
let x0 = pen.x;
|
||||
match pick(t, c).and_then(|(f, n)| Some((f, f.glyph(n, t.vertical)?))) {
|
||||
Some((f, g)) => {
|
||||
let k = h / f.units_per_height();
|
||||
let place = |p: Vec2| Vec2::new(pen.x + p.x * k * wf + p.y * k * shear, pen.y + p.y * k);
|
||||
for st in &g.strokes {
|
||||
let pts: Vec<Vec2> = st.iter().map(|p| place(*p)).collect();
|
||||
max_x = pts.iter().fold(max_x, |m, p| m.max(p.x));
|
||||
out.strokes.push(pts);
|
||||
}
|
||||
let adv = Vec2::new(g.advance.x * k * wf, g.advance.y * k);
|
||||
pen = if t.vertical { pen + adv } else { Vec2::new(pen.x + adv.x, pen.y) };
|
||||
}
|
||||
None => {
|
||||
// The stroke font draws it, at the pen.
|
||||
let mut fb = crate::shape_stroke(&c.to_string(), h, wf, oblique);
|
||||
let at = pen;
|
||||
fb.map(|p| p + at);
|
||||
max_x = max_x.max(at.x + fb.width);
|
||||
out.extend(fb);
|
||||
if t.vertical {
|
||||
pen.y -= h * 1.5;
|
||||
} else {
|
||||
pen.x += crate::stroke_advance(c, h, wf);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !t.vertical {
|
||||
spans.push((x0, pen.x, under, over));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.strokes.extend(crate::decorations(&spans, h));
|
||||
out.width = if t.vertical { max_x.max(0.0) } else { pen.x.max(0.0) };
|
||||
out
|
||||
}
|
||||
|
||||
/// The width of one line: the sum of the advances.
|
||||
pub(crate) fn width(t: &ShxText, s: &str, height: f64, width_factor: f64) -> f64 {
|
||||
if t.vertical || bidi::has_rtl(s) {
|
||||
return shape(t, s, height, width_factor, 0.0).width;
|
||||
}
|
||||
let h = if height.is_finite() && height > 0.0 { height } else { 1.0 };
|
||||
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
|
||||
crate::decode_controls(s)
|
||||
.into_iter()
|
||||
.filter(|(c, _, _)| !bidi::is_format_control(*c))
|
||||
.map(|(c, _, _)| match pick(t, c).and_then(|(f, n)| Some((f, f.glyph(n, false)?))) {
|
||||
Some((f, g)) => g.advance.x * h / f.units_per_height() * wf,
|
||||
None => crate::stroke_advance(c, h, wf),
|
||||
})
|
||||
.sum::<f64>()
|
||||
.max(0.0)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! How SHX fonts are found: drawing folders, the search path, the font map, an app's font
|
||||
//! provider and FONTALT. Process-wide settings, so one test in its own binary. The fonts are
|
||||
//! compiled here from shape source written for the test; nothing is read from a CAD install.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_fonts::shx::{self, FontProvider};
|
||||
use cadcraft_fonts::{TextFont, ttf};
|
||||
|
||||
const FONT: &str = "*0,4,lookup test\n6,2,0,0\n*65,4,UCA\n1,064,0\n";
|
||||
|
||||
struct Provider;
|
||||
|
||||
impl FontProvider for Provider {
|
||||
fn load(&self, file_name: &str) -> Option<Vec<u8>> {
|
||||
(file_name == "cadcraft-provided.shx").then(|| shx::compile(FONT).ok()).flatten()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_are_found_where_the_drawing_and_the_app_keep_them() {
|
||||
let bytes = shx::compile(FONT).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-shx-lookup-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("cadcraft-beside.shx"), &bytes).unwrap();
|
||||
|
||||
// Beside the drawing: found once the drawing's folder is known; a later change of folders
|
||||
// starts a new generation (viewers regenerate).
|
||||
assert!(shx::find("cadcraft-beside.shx").is_none());
|
||||
let g = shx::generation();
|
||||
shx::add_drawing_dir(&dir);
|
||||
assert!(shx::generation() != g);
|
||||
assert_eq!(shx::drawing_dirs().first(), Some(&dir));
|
||||
assert!(shx::find("CADCRAFT-BESIDE").is_some_and(|f| f.has_shape(65)));
|
||||
assert!(TextFont::resolve("cadcraft-beside.shx").is_shx());
|
||||
// A file whose name has another case than the drawing's (found on case-sensitive systems
|
||||
// too, by looking through the folder).
|
||||
std::fs::write(dir.join("CadCraft-Mixed.SHX"), &bytes).unwrap();
|
||||
assert!(shx::find("cadcraft-mixed.shx").is_some());
|
||||
|
||||
// The search path, and the font map renaming a font to one that is there.
|
||||
let other = dir.join("lib");
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
std::fs::write(other.join("cadcraft-onpath.shx"), &bytes).unwrap();
|
||||
assert!(shx::find("cadcraft-onpath.shx").is_none());
|
||||
shx::set_search_path(&other.to_string_lossy());
|
||||
assert!(shx::find("cadcraft-onpath.shx").is_some());
|
||||
shx::set_font_map("cadcraft-old.shx;cadcraft-onpath.shx\nnonsense");
|
||||
assert_eq!(shx::font_map(), "cadcraft-old.shx;cadcraft-onpath.shx");
|
||||
assert!(shx::find("cadcraft-old").is_some());
|
||||
shx::set_font_map("");
|
||||
|
||||
// An app's provider (the web build's file store).
|
||||
assert!(shx::find("cadcraft-provided.shx").is_none());
|
||||
shx::set_provider(Some(Arc::new(Provider)));
|
||||
assert!(shx::find("cadcraft-provided.shx").is_some());
|
||||
shx::set_provider(None);
|
||||
assert!(shx::find("cadcraft-provided.shx").is_none());
|
||||
|
||||
// FONTALT replaces a missing SHX font (and may name a big font after a comma).
|
||||
assert!(!TextFont::resolve("cadcraft-missing.shx").is_shx());
|
||||
ttf::set_font_alt("cadcraft-beside.shx");
|
||||
assert!(TextFont::resolve("cadcraft-missing.shx").is_shx());
|
||||
assert!(!TextFont::resolve("").is_shx(), "the built-in font is not replaced");
|
||||
ttf::set_font_alt("");
|
||||
|
||||
// Registered bytes (files opened alongside a drawing on the web) and clearing them.
|
||||
shx::register("cadcraft-registered.shx", &bytes).unwrap();
|
||||
assert!(shx::find("cadcraft-registered.shx").is_some());
|
||||
shx::clear_registered();
|
||||
assert!(shx::find("cadcraft-registered.shx").is_none());
|
||||
|
||||
shx::set_search_path("");
|
||||
assert_eq!(shx::search_path(), "");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ serde = { workspace = true }
|
||||
miniz_oxide = "0.9"
|
||||
craft-pdf = { workspace = true, features = ["write"] }
|
||||
quick-xml = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's).
|
||||
|
||||
@@ -881,6 +881,23 @@ pub(crate) fn parse_constraints(text: &str) -> Option<(Vec<Constraint>, Parametr
|
||||
Some((p.constraints, p.parametric))
|
||||
}
|
||||
|
||||
/// A view centre as a file stores it (VIEWPORT and VPORT group 12, VIEW group 10), as the
|
||||
/// drawing point at the middle of the view. The stored centre is in display coordinates: their
|
||||
/// origin is the view's `target` (VIEWPORT and VPORT group 17, VIEW group 12) and their axes are
|
||||
/// turned by the view's `twist` (radians). Turning back keeps a twisted view on its part of the
|
||||
/// drawing however far that is from the origin; a non-finite result keeps the stored centre.
|
||||
pub(crate) fn dcs_center_to_model(c: cadcraft_geom::Vec2, twist: f64, target: cadcraft_geom::Vec2) -> cadcraft_geom::Vec2 {
|
||||
let m = c.rotate(-twist) + if target.is_finite() { target } else { cadcraft_geom::Vec2::ZERO };
|
||||
if m.is_finite() { m } else { c }
|
||||
}
|
||||
|
||||
/// The display-coordinate view centre a file stores for the drawing point `m` at the middle of
|
||||
/// a view with `twist`, its target at the origin (the inverse of [`dcs_center_to_model`]).
|
||||
pub(crate) fn model_center_to_dcs(m: cadcraft_geom::Vec2, twist: f64) -> cadcraft_geom::Vec2 {
|
||||
let c = m.rotate(twist);
|
||||
if c.is_finite() { c } else { m }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -23,10 +23,8 @@ impl T<'_> {
|
||||
/// origin is the view target (`target`), so the point in the middle of a plan view is their
|
||||
/// sum: a file whose view centre is 0,0 keeps the location in the target (#2492). A sum that
|
||||
/// overflows leaves the stored centre.
|
||||
pub(super) fn view_center(&self, center: i32, target: i32) -> Vec2 {
|
||||
let c = self.p(center).xy();
|
||||
let sum = c + self.p(target).xy();
|
||||
if sum.is_finite() { sum } else { c }
|
||||
pub(super) fn view_center(&self, center: i32, target: i32, twist: f64) -> Vec2 {
|
||||
crate::dxf_ext::dcs_center_to_model(self.p(center).xy(), twist, self.p(target).xy())
|
||||
}
|
||||
pub(super) fn all_f(&self, code: i32) -> Vec<f64> {
|
||||
self.0.iter().filter(|t| t.code == code).map(Tag::f64).collect()
|
||||
|
||||
@@ -178,14 +178,32 @@ pub(super) fn setup_layouts(d: &mut Drawing, layout_objs: &mut [LayoutDef], rx:
|
||||
(paper_block_for, first_layout)
|
||||
}
|
||||
|
||||
/// The drawing's layer names (lower-cased), so adding the layer an entity names is a set lookup
|
||||
/// rather than a scan of the layer table per entity: many distinct names made that quadratic
|
||||
/// (CWE-407).
|
||||
struct LayerSet(std::collections::HashSet<String>);
|
||||
|
||||
impl LayerSet {
|
||||
fn new(d: &Drawing) -> Self {
|
||||
Self(d.layers.iter().map(|l| l.name.to_ascii_lowercase()).collect())
|
||||
}
|
||||
/// [`Drawing::ensure_layer`] through the set.
|
||||
fn ensure(&mut self, d: &mut Drawing, name: &str) {
|
||||
if self.0.insert(name.to_ascii_lowercase()) {
|
||||
d.layers.push(cadcraft_doc::Layer::new(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Place the BLOCKS entities: model and paper space blocks go to their space, the rest become
|
||||
/// block definitions.
|
||||
pub(super) fn place_blocks(d: &mut Drawing, blocks: Vec<BlockDef>, rx: &mut Rx, paper_block_for: &HashMap<String, String>, first_layout: &str) {
|
||||
let mut layers = LayerSet::new(d);
|
||||
for (name, base, description, ents, xref) in blocks {
|
||||
let up = name.to_ascii_uppercase();
|
||||
if up == "*MODEL_SPACE" {
|
||||
for (_, _, e) in ents {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
layers.ensure(d, &e.common.layer);
|
||||
d.model.push(e);
|
||||
}
|
||||
continue;
|
||||
@@ -241,8 +259,9 @@ pub(super) fn place_entities(
|
||||
paper_block_for: &HashMap<String, String>,
|
||||
first_layout: &str,
|
||||
) {
|
||||
let mut layers = LayerSet::new(d);
|
||||
for (owner, paper, e) in entities {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
layers.ensure(d, &e.common.layer);
|
||||
let owner_name = owner.and_then(|o| rx.brs.get(&o.trim().to_ascii_uppercase()).cloned()).map(|n| n.to_ascii_uppercase());
|
||||
let to_paper = paper || owner_name.as_deref().is_some_and(|n| n.starts_with("*PAPER_SPACE"));
|
||||
if to_paper {
|
||||
@@ -314,7 +333,17 @@ pub(super) fn apply_sortents(
|
||||
paper_for: &HashMap<String, String>,
|
||||
first_layout: &str,
|
||||
) {
|
||||
for (owner, map) in tables {
|
||||
// Each sort rebuilds its whole space, so a space named by many tables is sorted once, by the
|
||||
// last of them (which decided its order anyway); repeated tables made import quadratic.
|
||||
let target = |owner: &String| {
|
||||
let up = brs.get(owner)?.to_ascii_uppercase();
|
||||
Some(if up.starts_with("*PAPER_SPACE") { paper_for.get(&up).cloned().unwrap_or_else(|| first_layout.to_string()) + "\0paper" } else { up })
|
||||
};
|
||||
let last: HashMap<String, usize> = tables.iter().enumerate().filter_map(|(i, (owner, _))| Some((target(owner)?, i))).collect();
|
||||
for (i, (owner, map)) in tables.iter().enumerate() {
|
||||
if target(owner).and_then(|t| last.get(&t)) != Some(&i) {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = brs.get(owner) else { continue };
|
||||
let up = name.to_ascii_uppercase();
|
||||
// A sort handle of 0 draws last (ezdxf `reorder`, which notes AutoCAD defines it, undocumented).
|
||||
|
||||
@@ -243,20 +243,25 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef
|
||||
k
|
||||
}
|
||||
"ACAD_TABLE" => EntityKind::Table(acad_table(tags, table_cells_left)),
|
||||
"VIEWPORT" => EntityKind::Viewport(Viewport {
|
||||
center: t.p(10),
|
||||
width: t.fd(40, 1.0),
|
||||
height: t.fd(41, 1.0),
|
||||
// 12/22 is in display coordinates, whose origin is the view target 17/27/37.
|
||||
view_center: t.view_center(12, 17),
|
||||
view_height: t.fd(45, 1.0),
|
||||
id: t.i(69).unwrap_or(0) as u32,
|
||||
locked: t.i(90).unwrap_or(0) & 16384 != 0,
|
||||
frozen_layers: crate::dxf_ext::read_frozen(tags),
|
||||
layer_colors: crate::dxf_ext::read_layer_colors(tags),
|
||||
// Status flags: 65536 = non-rectangular clipping (by the 340 entity), 131072 = off.
|
||||
clip: t.s(340).and_then(|h| Handle::parse_hex(&h)).filter(|h| h.0 != 0 && t.i(90).unwrap_or(0) & 65536 != 0),
|
||||
on: t.i(90).unwrap_or(0) & 131072 == 0,
|
||||
"VIEWPORT" => EntityKind::Viewport({
|
||||
// View twist (group 51, degrees).
|
||||
let twist = t.f(51).map_or(0.0, |a| cadcraft_geom::norm_angle(a.to_radians()));
|
||||
Viewport {
|
||||
center: t.p(10),
|
||||
width: t.fd(40, 1.0),
|
||||
height: t.fd(41, 1.0),
|
||||
// 12/22 is in display coordinates, whose origin is the view target 17/27/37.
|
||||
view_center: t.view_center(12, 17, twist),
|
||||
view_height: t.fd(45, 1.0),
|
||||
id: t.i(69).unwrap_or(0) as u32,
|
||||
locked: t.i(90).unwrap_or(0) & 16384 != 0,
|
||||
frozen_layers: crate::dxf_ext::read_frozen(tags),
|
||||
layer_colors: crate::dxf_ext::read_layer_colors(tags),
|
||||
// Status flags: 65536 = non-rectangular clipping (by the 340 entity), 131072 = off.
|
||||
clip: t.s(340).and_then(|h| Handle::parse_hex(&h)).filter(|h| h.0 != 0 && t.i(90).unwrap_or(0) & 65536 != 0),
|
||||
on: t.i(90).unwrap_or(0) & 131072 == 0,
|
||||
twist,
|
||||
}
|
||||
}),
|
||||
"WIPEOUT" => {
|
||||
let o = t.p(10);
|
||||
@@ -299,7 +304,9 @@ pub(super) fn acad_table(tags: &[Tag], table_cells_left: &mut usize) -> Table {
|
||||
// Large declared sizes must be backed by cell records (no allocation from a bare count).
|
||||
let n_cells = tail.iter().filter(|x| x.code == 171).count();
|
||||
let declared = rows.saturating_mul(cols);
|
||||
if declared > MAX_CELLS || (declared > 10_000 && n_cells < declared) {
|
||||
// A table with no rows or no columns has no cells: keep neither dimension, so its row heights
|
||||
// and column widths (allocated per dimension, outside the cell budget) are not allocated.
|
||||
if declared == 0 || declared > MAX_CELLS || (declared > 10_000 && n_cells < declared) {
|
||||
rows = 0;
|
||||
cols = 0;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,9 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
// appends in O(1) instead of scanning the growing layer list — otherwise many distinct layer
|
||||
// names make import quadratic (CWE-407).
|
||||
let mut layer_index: HashMap<String, usize> = d.layers.iter().enumerate().map(|(i, l)| (l.name.to_ascii_lowercase(), i)).collect();
|
||||
// The same for linetypes (first definition wins) and text styles (last definition wins).
|
||||
let mut linetype_names: std::collections::HashSet<String> = d.linetypes.iter().map(|l| l.name.to_ascii_lowercase()).collect();
|
||||
let mut style_index: HashMap<String, usize> = d.text_styles.iter().enumerate().map(|(i, s)| (s.name.to_ascii_lowercase(), i)).collect();
|
||||
for (kind, tg) in recs {
|
||||
let t = T(&tg);
|
||||
match kind.as_str() {
|
||||
@@ -159,9 +162,10 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
}
|
||||
"LTYPE" => {
|
||||
let name = t.s(2).unwrap_or_default();
|
||||
if name.is_empty() || d.linetype(&name).is_some() || d.linetypes.len() >= MAX_OBJECTS {
|
||||
if name.is_empty() || linetype_names.contains(&name.to_ascii_lowercase()) || d.linetypes.len() >= MAX_OBJECTS {
|
||||
continue;
|
||||
}
|
||||
linetype_names.insert(name.to_ascii_lowercase());
|
||||
let pattern = ltype_pattern(&tg, <ype_styles);
|
||||
d.linetypes.push(Linetype { name, description: t.s(3).unwrap_or_default(), pattern });
|
||||
}
|
||||
@@ -184,9 +188,12 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
vertical: t.i(70).unwrap_or(0) & 4 != 0,
|
||||
annotative: annotative(&tg),
|
||||
};
|
||||
match d.text_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(&name)) {
|
||||
match style_index.get(&name.to_ascii_lowercase()).and_then(|&i| d.text_styles.get_mut(i)) {
|
||||
Some(x) => *x = st,
|
||||
None => d.text_styles.push(st),
|
||||
None => {
|
||||
style_index.insert(name.to_ascii_lowercase(), d.text_styles.len());
|
||||
d.text_styles.push(st);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The first *ACTIVE viewport holds the current snap/grid settings.
|
||||
@@ -217,8 +224,9 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
if name.is_empty() || !(h.is_finite() && w.is_finite() && h > 0.0 && w > 0.0) || d.views.len() >= MAX_OBJECTS {
|
||||
continue;
|
||||
}
|
||||
let twist = t.f(50).map_or(0.0, |a| cadcraft_geom::norm_angle(a.to_radians()));
|
||||
// The view centre 10/20 is in display coordinates, whose origin is the target 12/22/32.
|
||||
let center = t.view_center(10, 12);
|
||||
let center = t.view_center(10, 12, twist);
|
||||
if !(center.x.is_finite() && center.y.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
@@ -228,7 +236,6 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
};
|
||||
// 70 bit 1: a paper space view; its layout is CADCraft xdata.
|
||||
let layout = (t.i(70).unwrap_or(0) & 1 != 0).then(|| field("LAYOUT").unwrap_or_default());
|
||||
let twist = t.f(50).filter(|a| a.is_finite()).map_or(0.0, |a| a.rem_euclid(360.0).to_radians());
|
||||
let v = NamedView { name: name.clone(), center, height: h, width: w, layer_state: field("LAYERSTATE"), twist, layout };
|
||||
match d.views.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(x) => *x = v,
|
||||
@@ -279,8 +286,9 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// One *Active viewport is no tiling: the view is the editor's (it zooms to the extents).
|
||||
d.vports.retain(|c| c.tiles.len() > usize::from(c.is_active()));
|
||||
// One *Active viewport is no tiling: the view is the editor's (it zooms to the extents),
|
||||
// kept only for its view twist (see `cadcraft_engine::DocState`).
|
||||
d.vports.retain(|c| c.tiles.len() > usize::from(c.is_active()) || c.tiles.first().is_some_and(|t| t.twist != 0.0));
|
||||
}
|
||||
|
||||
fn index_table_handles(recs: &[(String, Vec<Tag>)], rx: &mut Rx) {
|
||||
@@ -317,8 +325,10 @@ const MAX_VPORT_TILES: usize = 64;
|
||||
/// are fractions of the drawing area; unusable tiles are skipped.
|
||||
fn vport_record(d: &mut Drawing, t: &T) {
|
||||
let name = t.s(2).map(|n| n.trim().to_string()).unwrap_or_default();
|
||||
// View twist (group 51, degrees).
|
||||
let twist = t.f(51).map_or(0.0, |a| cadcraft_geom::norm_angle(a.to_radians()));
|
||||
// The view centre 12/22 is in display coordinates, whose origin is the view target 17/27/37.
|
||||
let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.view_center(12, 17));
|
||||
let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.view_center(12, 17, twist));
|
||||
let height = t.fd(40, 0.0);
|
||||
let unit = |v: f64| v.is_finite() && (-1e-9..=1.0 + 1e-9).contains(&v);
|
||||
let ok = [min.x, min.y, max.x, max.y].into_iter().all(unit)
|
||||
@@ -332,7 +342,7 @@ fn vport_record(d: &mut Drawing, t: &T) {
|
||||
}
|
||||
let aspect = t.f(41).filter(|a| a.is_finite() && *a > 0.0).unwrap_or(0.0);
|
||||
let clamp = |p: Vec2| Vec2::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0));
|
||||
let tile = VportTile { min: clamp(min), max: clamp(max), center, height, aspect };
|
||||
let tile = VportTile { min: clamp(min), max: clamp(max), center, height, aspect, twist };
|
||||
let name = if name.eq_ignore_ascii_case(VportConfig::ACTIVE) { VportConfig::ACTIVE.to_string() } else { name };
|
||||
let room = d.vports.len() < MAX_OBJECTS;
|
||||
match d.vports.iter_mut().find(|c| c.name.eq_ignore_ascii_case(&name)) {
|
||||
|
||||
@@ -684,8 +684,15 @@ fn write_viewport_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper:
|
||||
// Status 68: a positive value is "on" (the stacking order; 1 is the active viewport), 0 off.
|
||||
w.i(68, if v.on { i64::from(v.id.clamp(1, 32767)) } else { 0 });
|
||||
w.i(69, i64::from(v.id));
|
||||
w.p2(12, v.view_center);
|
||||
// Within one turn, so a hostile twist (from JSON or MCP) writes a sane angle.
|
||||
let twist = cadcraft_geom::norm_angle(v.view_twist());
|
||||
// The view centre in display coordinates (turned by the twist).
|
||||
w.p2(12, dxf_ext::model_center_to_dcs(v.view_center, twist));
|
||||
w.f(45, v.view_height);
|
||||
// View twist (degrees), when the viewport has one.
|
||||
if twist != 0.0 {
|
||||
w.f(51, twist.to_degrees());
|
||||
}
|
||||
// Status flags: 16384 = display locked, 32768 = always set, 65536 = clipped by the
|
||||
// 340 entity, 131072 = off.
|
||||
let clip = v.clip.filter(|h| *h != e.handle && d.entity(*h).is_some());
|
||||
|
||||
@@ -72,13 +72,24 @@ pub(super) fn paper_view(w: &mut W, d: &Drawing, e: &Entity, owner: &str, cx: &C
|
||||
/// The VPORT table: the `*Active` model space tiles (one viewport on the extents when there is no
|
||||
/// tiled configuration) and the saved configurations, each tile a record, the current one first.
|
||||
pub(super) fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) {
|
||||
let single = VportTile {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::new(1.0, 1.0),
|
||||
center: if ext.is_empty() { Vec2::new(6.0, 4.5) } else { ext.center() },
|
||||
height: if ext.is_empty() { 9.0 } else { (ext.height() * 1.1).max(1e-6) },
|
||||
aspect: 1.6,
|
||||
// An untiled view's twist: the lone `*Active` tile the editor keeps for it.
|
||||
let twist = d
|
||||
.vports
|
||||
.iter()
|
||||
.find(|c| c.is_active() && c.tiles.len() == 1)
|
||||
.and_then(|c| c.tiles.first())
|
||||
.map_or(0.0, |t| cadcraft_geom::norm_angle(t.twist));
|
||||
// The extents fitted as the turned view sees them.
|
||||
let (center, height) = if ext.is_empty() {
|
||||
(Vec2::new(6.0, 4.5), 9.0)
|
||||
} else if twist == 0.0 {
|
||||
(ext.center(), (ext.height() * 1.1).max(1e-6))
|
||||
} else {
|
||||
let r = Bounds2::from_points(ext.corners().map(|p| p.rotate(twist)));
|
||||
let height = r.height() * 1.1;
|
||||
(r.center().rotate(-twist), if height.is_finite() { height.max(1e-6) } else { 9.0 })
|
||||
};
|
||||
let single = VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center, height, aspect: 1.6, twist };
|
||||
let tiled = d.vports.iter().find(|c| c.is_active() && c.tiles.len() > 1);
|
||||
let mut records: Vec<(&str, &VportTile)> = match tiled {
|
||||
Some(c) => c.tiles.iter().map(|t| (VportConfig::ACTIVE, t)).collect(),
|
||||
@@ -93,7 +104,8 @@ pub(super) fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) {
|
||||
w.i(70, 0);
|
||||
w.p2(10, finite(t.min, Vec2::ZERO));
|
||||
w.p2(11, finite(t.max, Vec2::new(1.0, 1.0)));
|
||||
w.p2(12, finite(t.center, Vec2::ZERO));
|
||||
let twist = cadcraft_geom::norm_angle(t.twist);
|
||||
w.p2(12, dxf_ext::model_center_to_dcs(finite(t.center, Vec2::ZERO), twist));
|
||||
// Snap grid origin and rotation (SNAPBASE, SNAPANG in degrees): R13+ files keep them here.
|
||||
w.p2(13, d.header.point("SNAPBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO));
|
||||
w.p2(14, d.header.snap_unit());
|
||||
@@ -104,6 +116,7 @@ pub(super) fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) {
|
||||
w.f(41, if t.aspect.is_finite() && t.aspect > 0.0 { t.aspect } else { 1.0 });
|
||||
w.f(42, 50.0);
|
||||
w.f(50, d.header.f64("SNAPANG", 0.0));
|
||||
w.f(51, twist.to_degrees());
|
||||
}
|
||||
w.s(0, "ENDTAB");
|
||||
}
|
||||
|
||||
@@ -136,14 +136,15 @@ fn write_tables(w: &mut W, plan: TableWritePlan<'_>) -> HashMap<String, String>
|
||||
w.s(2, &v.name);
|
||||
w.i(70, i64::from(v.layout.is_some()));
|
||||
w.f(40, v.height);
|
||||
w.p2(10, v.center);
|
||||
let twist = cadcraft_geom::norm_angle(v.twist);
|
||||
w.p2(10, dxf_ext::model_center_to_dcs(v.center, twist));
|
||||
w.f(41, v.width);
|
||||
w.p(11, Vec3::Z);
|
||||
w.p(12, Vec3::ZERO);
|
||||
w.f(42, 50.0);
|
||||
w.f(43, 0.0);
|
||||
w.f(44, 0.0);
|
||||
w.f(50, if v.twist.is_finite() { v.twist.to_degrees() } else { 0.0 });
|
||||
w.f(50, twist.to_degrees());
|
||||
w.i(71, 0);
|
||||
w.i(281, 0);
|
||||
w.i(72, 0);
|
||||
|
||||
+32
-8
@@ -14,6 +14,10 @@ mod dxf_write;
|
||||
mod dxf_xref;
|
||||
pub mod pdf;
|
||||
pub mod plotter;
|
||||
/// Raster image files (PNG, JPEG, BMP, TIFF) decoded to pixels.
|
||||
pub mod raster;
|
||||
/// Open → save → reopen comparison and the real-file corpus harness (gaps FF1, FF5).
|
||||
pub mod roundtrip;
|
||||
pub mod svg;
|
||||
mod svg_read;
|
||||
|
||||
@@ -69,19 +73,25 @@ fn ext(name: impl AsRef<Path>) -> String {
|
||||
/// Read a drawing; the format comes from the content (DXF ASCII/binary, DWG) or the name
|
||||
/// (SVG and gzip-compressed SVGZ).
|
||||
pub fn read(bytes: &[u8], name: impl AsRef<Path>) -> Result<Drawing> {
|
||||
let name = name.as_ref();
|
||||
read_source(bytes, name.as_ref()).map(|(d, _)| d)
|
||||
}
|
||||
|
||||
/// [`read`], also returning the DXF rendition a DWG was read through (`None` for other files),
|
||||
/// which the round-trip harness counts.
|
||||
pub(crate) fn read_source(bytes: &[u8], name: &Path) -> Result<(Drawing, Option<Vec<u8>>)> {
|
||||
if cadcraft_dwg::is_dwg(bytes) {
|
||||
let (dxf, notes) = cadcraft_dwg::decode(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?;
|
||||
let mut d = dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")))?;
|
||||
d.read_notes = notes;
|
||||
return Ok(d);
|
||||
return Ok((d, Some(dxf)));
|
||||
}
|
||||
match (Format::from_path(name), ext(name).as_str()) {
|
||||
let d = match (Format::from_path(name), ext(name).as_str()) {
|
||||
(Some(Format::Svg | Format::Svgz), _) => svg_read::read(bytes),
|
||||
(Some(Format::Dxf), _) => dxf_read::read(bytes),
|
||||
(_, e) if bytes.len() > 4 => dxf_read::read(bytes).map_err(|_| IoError::Unsupported(e.to_string())),
|
||||
(_, e) => Err(IoError::Unsupported(e.to_string())),
|
||||
}
|
||||
}?;
|
||||
Ok((d, None))
|
||||
}
|
||||
|
||||
/// Write a drawing in the format chosen by the name's extension. Images (PNG, SVG, PDF) show
|
||||
@@ -111,15 +121,29 @@ pub fn write_with(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Re
|
||||
// The DWG bridge reads our R2000 DXF and writes the DWG at the version asked for. Without
|
||||
// one, at least 2004: R2000 DWG has no true colours, transparency, table styles or
|
||||
// gradients, so a drawing from an R2000 DXF doesn't lose them by default.
|
||||
Some(Format::Dwg) if !opts.binary => {
|
||||
let version = opts.version.unwrap_or_else(|| version.max(Version::R2004));
|
||||
cadcraft_dwg::encode_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format)
|
||||
}
|
||||
Some(Format::Dwg) if !opts.binary => write_dwg(d, opts.version.unwrap_or_else(|| version.max(Version::R2004))).map(|(dwg, _)| dwg),
|
||||
_ if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{}`", ext(name)))),
|
||||
_ => write_framed(d, name, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`write_with`], also returning what the file leaves out of the drawing: for a DWG, how many
|
||||
/// records of each DXF type the DWG writer can't write (other programs' custom entities and
|
||||
/// objects); for other formats, nothing.
|
||||
pub fn write_noted(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Result<(Vec<u8>, cadcraft_dwg::Unwritten)> {
|
||||
let name = name.as_ref();
|
||||
if matches!(Format::from_path(name), Some(Format::Dwg)) && !opts.binary {
|
||||
return write_dwg(d, opts.version.unwrap_or_else(|| save_version(d).max(Version::R2004)));
|
||||
}
|
||||
write_with(d, name, opts).map(|b| (b, cadcraft_dwg::Unwritten::new()))
|
||||
}
|
||||
|
||||
/// The DWG bridge reads our R2000 DXF and writes the DWG at `version`, in the DWG worker when
|
||||
/// there is one.
|
||||
fn write_dwg(d: &Drawing, version: Version) -> Result<(Vec<u8>, cadcraft_dwg::Unwritten)> {
|
||||
cadcraft_dwg::encode_noted(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format)
|
||||
}
|
||||
|
||||
/// Like [`write`], but image formats (PNG, SVG, PDF) show `window` (a model-space rectangle in
|
||||
/// drawing units, centred and scaled to fit the image without distortion) instead of the
|
||||
/// extents. Formats that are not images (DXF, DWG) ignore the window. The window is checked with
|
||||
|
||||
@@ -873,6 +873,8 @@ fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts:
|
||||
}
|
||||
}
|
||||
Kind::Point => point(s, &mut batch, &p.map, raw, m),
|
||||
// Raster images are not written to PDF yet: their frame is.
|
||||
Kind::Image { .. } => {}
|
||||
}
|
||||
}
|
||||
flush(s, &mut batch);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Raster image files decoded to pixels (IMAGEATTACH): PNG, JPEG, BMP and TIFF, the format taken
|
||||
//! from the content. Files are hostile input: the size is read from the header and checked
|
||||
//! before any pixel is decoded.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use cadcraft_doc::Raster;
|
||||
|
||||
use crate::{IoError, Result};
|
||||
|
||||
/// Largest image file read (bytes).
|
||||
pub const MAX_IMAGE_BYTES: usize = 256 << 20;
|
||||
/// Most pixels decoded per image (four bytes each in memory).
|
||||
pub const MAX_IMAGE_PIXELS: u64 = 128_000_000;
|
||||
|
||||
fn bad(e: impl std::fmt::Display) -> IoError {
|
||||
IoError::Format(format!("image: {e}"))
|
||||
}
|
||||
|
||||
/// A reader over an image file, once it is small enough and of a format we read.
|
||||
fn reader(bytes: &[u8]) -> Result<image::ImageReader<Cursor<&[u8]>>> {
|
||||
if bytes.len() > MAX_IMAGE_BYTES {
|
||||
return Err(bad(format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20)));
|
||||
}
|
||||
// The format comes from the file's first bytes, not from its name.
|
||||
let file = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(bad)?;
|
||||
if file.format().is_none() {
|
||||
return Err(bad("not a PNG, JPEG, BMP or TIFF file"));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// The width and height in pixels of the image in `bytes`, read from its header.
|
||||
pub fn probe(bytes: &[u8]) -> Result<(u32, u32)> {
|
||||
let (width, height) = reader(bytes)?.into_dimensions().map_err(bad)?;
|
||||
if width == 0 || height == 0 {
|
||||
return Err(bad("the image is empty"));
|
||||
}
|
||||
if u64::from(width).saturating_mul(u64::from(height)) > MAX_IMAGE_PIXELS {
|
||||
return Err(bad(format!("{width} x {height} pixels is more than {} megapixels", MAX_IMAGE_PIXELS / 1_000_000)));
|
||||
}
|
||||
Ok((width, height))
|
||||
}
|
||||
|
||||
/// Decode the image in `bytes`.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Raster> {
|
||||
// The size is checked first, from the header alone.
|
||||
let (width, height) = probe(bytes)?;
|
||||
let mut file = reader(bytes)?;
|
||||
file.limits(decode_limits(width, height));
|
||||
let pixels = file.decode().map_err(bad)?.into_rgba8();
|
||||
Raster::new(pixels.width(), pixels.height(), pixels.into_raw()).ok_or_else(|| bad("the image is empty"))
|
||||
}
|
||||
|
||||
/// What the decoder may allocate for an image of this size: the pixels themselves (up to 16
|
||||
/// bytes each before they become 8-bit RGBA) and its own working buffers.
|
||||
fn decode_limits(width: u32, height: u32) -> image::Limits {
|
||||
let pixels = u64::from(width).saturating_mul(u64::from(height));
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(width);
|
||||
limits.max_image_height = Some(height);
|
||||
limits.max_alloc = Some(pixels.saturating_mul(16).saturating_add(64 << 20));
|
||||
limits
|
||||
}
|
||||
|
||||
/// `raster` as a PNG file.
|
||||
pub fn encode_png(raster: &Raster) -> Result<Vec<u8>> {
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
image::write_buffer_with_format(
|
||||
&mut out,
|
||||
raster.rgba(),
|
||||
raster.width(),
|
||||
raster.height(),
|
||||
image::ExtendedColorType::Rgba8,
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.map_err(bad)?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
//! What a DXF file holds, counted from its groups, and the same counts of a drawing read from it:
|
||||
//! together they show what opening a file leaves out (the DXF rendition of a DWG for DWG files).
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use cadcraft_doc::Drawing;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{Category, Change, Comparison, Options, Out};
|
||||
|
||||
/// Counts of what a file (or a drawing) holds.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Census {
|
||||
/// Entities of model space and every layout, per DXF type.
|
||||
pub entities: BTreeMap<String, usize>,
|
||||
/// Entities of block definitions (other than the model and paper space blocks), per type.
|
||||
pub block_entities: BTreeMap<String, usize>,
|
||||
/// Block definitions other than the model and paper space blocks and the generated ones.
|
||||
pub blocks: usize,
|
||||
/// Blocks CADCraft generates and so doesn't read (tables' blocks, standard arrowheads
|
||||
/// nothing inserts); their entities are not counted.
|
||||
pub generated_blocks: usize,
|
||||
/// Table records per table type (LAYER, LTYPE, STYLE, DIMSTYLE, VIEW, UCS…).
|
||||
pub tables: BTreeMap<String, usize>,
|
||||
/// Non-graphical objects per type. For a drawing: only those kept without being modelled.
|
||||
pub objects: BTreeMap<String, usize>,
|
||||
/// CLASS records. For a drawing: only those kept for unmodelled objects.
|
||||
pub classes: usize,
|
||||
}
|
||||
|
||||
/// Records that belong to the record before them (or delimit blocks), not entities of their own.
|
||||
const SUB_RECORDS: &[&str] = &["VERTEX", "SEQEND", "ATTRIB", "BLOCK", "ENDBLK"];
|
||||
|
||||
fn bump(m: &mut BTreeMap<String, usize>, k: &str) {
|
||||
let n = m.entry(k.to_string()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
|
||||
/// A layout's own paper-space viewport (VIEWPORT with ID 1, group 69): part of the layout, not
|
||||
/// an entity of its own for CADCraft.
|
||||
fn is_overall_viewport(t: &str, g: &[cadcraft_dxf::Tag]) -> bool {
|
||||
t == "VIEWPORT" && g.iter().any(|x| x.code == 69 && x.i64() == 1)
|
||||
}
|
||||
|
||||
fn is_space_block(name: &str) -> bool {
|
||||
let up = name.trim().to_ascii_uppercase();
|
||||
up.starts_with("*MODEL_SPACE") || up.starts_with("*PAPER_SPACE")
|
||||
}
|
||||
|
||||
/// Count what a DXF file (ASCII or binary) holds; `None` when it can't be parsed.
|
||||
pub fn census(dxf: &[u8]) -> Option<Census> {
|
||||
let tags = cadcraft_dxf::parse(dxf).ok()?;
|
||||
let mut c = Census::default();
|
||||
// Blocks CADCraft generates (tables' blocks, standard arrowheads nothing inserts) are left out
|
||||
// on read and rebuilt on save: counted apart.
|
||||
let mut table_blocks: HashSet<String> = HashSet::new();
|
||||
let mut inserted: HashSet<String> = HashSet::new();
|
||||
let mut blocks: Vec<(String, BTreeMap<String, usize>)> = Vec::new();
|
||||
let mut note_refs = |t: &str, g: &[cadcraft_dxf::Tag]| {
|
||||
let name = || g.iter().find(|x| x.code == 2).map(|x| x.str().trim().to_ascii_uppercase());
|
||||
match t {
|
||||
"ACAD_TABLE" => table_blocks.extend(name()),
|
||||
"INSERT" => inserted.extend(name()),
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
for s in cadcraft_dxf::sections(&tags) {
|
||||
let records = cadcraft_dxf::records(&s.tags);
|
||||
match s.name.trim().to_ascii_uppercase().as_str() {
|
||||
"ENTITIES" => {
|
||||
for (t, g) in &records {
|
||||
note_refs(t, g);
|
||||
if !SUB_RECORDS.contains(&t.as_str()) && !is_overall_viewport(t, g) {
|
||||
bump(&mut c.entities, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
"BLOCKS" => {
|
||||
let mut space = false;
|
||||
for (t, g) in &records {
|
||||
note_refs(t, g);
|
||||
match t.as_str() {
|
||||
"BLOCK" => {
|
||||
let name = g.iter().find(|x| x.code == 2).map(cadcraft_dxf::Tag::str).unwrap_or_default();
|
||||
space = is_space_block(&name);
|
||||
if !space {
|
||||
blocks.push((name.trim().to_ascii_uppercase(), BTreeMap::new()));
|
||||
}
|
||||
}
|
||||
t if SUB_RECORDS.contains(&t) || is_overall_viewport(t, g) => {}
|
||||
t if space => bump(&mut c.entities, t),
|
||||
t => {
|
||||
if let Some((_, m)) = blocks.last_mut() {
|
||||
bump(m, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"TABLES" => {
|
||||
for (t, _) in &records {
|
||||
if t != "TABLE" && t != "ENDTAB" {
|
||||
bump(&mut c.tables, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
"OBJECTS" => {
|
||||
for (t, _) in &records {
|
||||
bump(&mut c.objects, t);
|
||||
}
|
||||
}
|
||||
"CLASSES" => c.classes = c.classes.saturating_add(records.iter().filter(|(t, _)| t == "CLASS").count()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (name, m) in blocks {
|
||||
if table_blocks.contains(&name) || (crate::dxf_ext::is_standard_arrow(&name) && !inserted.contains(&name)) {
|
||||
c.generated_blocks = c.generated_blocks.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
c.blocks = c.blocks.saturating_add(1);
|
||||
for (t, n) in m {
|
||||
let slot = c.block_entities.entry(t).or_insert(0);
|
||||
*slot = slot.saturating_add(n);
|
||||
}
|
||||
}
|
||||
Some(c)
|
||||
}
|
||||
|
||||
/// The same counts of a drawing: its entities, blocks and table records, and the objects and
|
||||
/// classes it keeps without modelling them.
|
||||
pub fn census_of(d: &Drawing) -> Census {
|
||||
let mut c = Census::default();
|
||||
for e in d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())) {
|
||||
bump(&mut c.entities, e.kind.dxf_name());
|
||||
}
|
||||
for b in d.blocks.values() {
|
||||
for e in b.entities.iter() {
|
||||
bump(&mut c.block_entities, e.kind.dxf_name());
|
||||
}
|
||||
}
|
||||
c.blocks = d.blocks.len();
|
||||
for (t, n) in [
|
||||
("LAYER", d.layers.len()),
|
||||
("LTYPE", d.linetypes.len()),
|
||||
("STYLE", d.text_styles.len()),
|
||||
("DIMSTYLE", d.dim_styles.len()),
|
||||
("VIEW", d.views.len()),
|
||||
("UCS", d.ucss.len()),
|
||||
] {
|
||||
c.tables.insert(t.to_string(), n);
|
||||
}
|
||||
for o in &d.foreign.objects {
|
||||
bump(&mut c.objects, &o.kind);
|
||||
}
|
||||
c.classes = d.foreign.classes.len();
|
||||
c
|
||||
}
|
||||
|
||||
/// Polylines may change between the old-style POLYLINE and LWPOLYLINE when read: counted
|
||||
/// together.
|
||||
fn merged(m: &BTreeMap<String, usize>) -> BTreeMap<String, usize> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (k, n) in m {
|
||||
let key = if k == "POLYLINE" || k == "LWPOLYLINE" { "POLYLINE+LWPOLYLINE" } else { k.as_str() };
|
||||
let slot = out.entry(key.to_string()).or_insert(0usize);
|
||||
*slot = slot.saturating_add(*n);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn compare_counts(out: &mut Out, place: &str, file: &BTreeMap<String, usize>, read: &BTreeMap<String, usize>) {
|
||||
let (file, read) = (merged(file), merged(read));
|
||||
let keys: std::collections::BTreeSet<&String> = file.keys().chain(read.keys()).collect();
|
||||
for k in keys {
|
||||
let (f, r) = (file.get(k).copied().unwrap_or(0), read.get(k).copied().unwrap_or(0));
|
||||
if r < f {
|
||||
out.push_n(Category::Read, Change::Lost, f - r, format!("{place} {k}"), format!("{k}: {f} in the file, {r} read"));
|
||||
} else if r > f {
|
||||
out.push_n(Category::Read, Change::Added, r - f, format!("{place} {k}"), format!("{k}: {f} in the file, {r} read"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a drawing read from a file holds less (or more) than the file: entities per type in the
|
||||
/// spaces and in blocks, blocks, and the table records CADCraft models. Blocks CADCraft generates
|
||||
/// (tables' blocks, unused standard arrowheads) are left out on both sides.
|
||||
pub fn compare_census(file: &Census, d: &Drawing, opts: &Options) -> Comparison {
|
||||
let read = census_of(d);
|
||||
let mut out = Out::new(opts);
|
||||
compare_counts(&mut out, "spaces", &file.entities, &read.entities);
|
||||
compare_counts(&mut out, "blocks", &file.block_entities, &read.block_entities);
|
||||
if file.blocks != read.blocks {
|
||||
let change = if read.blocks < file.blocks { Change::Lost } else { Change::Added };
|
||||
out.push_n(
|
||||
Category::Read,
|
||||
change,
|
||||
file.blocks.abs_diff(read.blocks),
|
||||
"BLOCK definitions",
|
||||
format!("{} in the file, {} read", file.blocks, read.blocks),
|
||||
);
|
||||
}
|
||||
for t in ["LAYER", "LTYPE", "STYLE", "DIMSTYLE", "VIEW", "UCS"] {
|
||||
let (f, r) = (file.tables.get(t).copied().unwrap_or(0), read.tables.get(t).copied().unwrap_or(0));
|
||||
if f != r {
|
||||
let change = if r < f { Change::Lost } else { Change::Added };
|
||||
out.push_n(Category::Read, change, f.abs_diff(r), format!("{t} table"), format!("{f} records in the file, {r} read"));
|
||||
}
|
||||
}
|
||||
out.cmp
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
//! The corpus harness: every DXF and DWG file of a folder is opened, saved as DXF and as DWG,
|
||||
//! reopened and compared with the drawing first opened; the findings go to a Markdown and a JSON
|
||||
//! report. A file that fails, or even makes CADCraft panic, is a reported failure: each stage runs
|
||||
//! under `catch_unwind` here, as a last resort of the harness only.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use cadcraft_doc::Drawing;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{Category, Census, Change, Comparison, Options, Tally, census, census_of, clip, compare, compare_census};
|
||||
|
||||
/// A drawing file format the harness checks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Format {
|
||||
Dxf,
|
||||
Dwg,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
pub fn ext(self) -> &'static str {
|
||||
match self {
|
||||
Format::Dxf => "dxf",
|
||||
Format::Dwg => "dwg",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Format::Dxf => "DXF",
|
||||
Format::Dwg => "DWG",
|
||||
}
|
||||
}
|
||||
/// The format of a file's bytes: DWG by its magic, otherwise DXF.
|
||||
pub fn of(bytes: &[u8]) -> Format {
|
||||
if cadcraft_dwg::is_dwg(bytes) { Format::Dwg } else { Format::Dxf }
|
||||
}
|
||||
fn other(self) -> Format {
|
||||
match self {
|
||||
Format::Dxf => Format::Dwg,
|
||||
Format::Dwg => Format::Dxf,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the harness runs.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CorpusOptions {
|
||||
pub compare: Options,
|
||||
/// Also save in the other format (DWG → DXF, DXF → DWG).
|
||||
pub cross_format: bool,
|
||||
/// Count what each file holds, to report what opening it leaves out.
|
||||
pub census: bool,
|
||||
/// Keep the saved files in this folder (and reopen them from there).
|
||||
pub keep_saved: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for CorpusOptions {
|
||||
fn default() -> Self {
|
||||
CorpusOptions { compare: Options::default(), cross_format: true, census: true, keep_saved: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest file the harness opens.
|
||||
pub const MAX_FILE_BYTES: u64 = 1 << 30;
|
||||
/// Deepest folder nesting walked.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
/// Most files taken from one corpus.
|
||||
const MAX_FILES: usize = 100_000;
|
||||
|
||||
/// One save → reopen of a drawing.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteReport {
|
||||
/// `dwg → dxf`.
|
||||
pub route: String,
|
||||
pub saved_bytes: usize,
|
||||
/// Where the saved file was kept, when it was.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub saved_path: Option<String>,
|
||||
pub save_error: Option<String>,
|
||||
pub reopen_error: Option<String>,
|
||||
/// What reopening reported (records the reader couldn't place or decode).
|
||||
pub reopen_notes: Vec<String>,
|
||||
/// What the writer itself left out of the saved file: records per DXF type (upper case) it
|
||||
/// can't write in that format (for DWG: other programs' custom entities and objects). Each
|
||||
/// is lost; the reopened drawing's entity counts show the same entities missing, and a strict
|
||||
/// corpus run fails on any.
|
||||
pub not_written: BTreeMap<String, usize>,
|
||||
/// The reopened drawing against the drawing first opened.
|
||||
pub comparison: Comparison,
|
||||
pub seconds: f64,
|
||||
}
|
||||
|
||||
impl RouteReport {
|
||||
pub fn ok(&self) -> bool {
|
||||
self.save_error.is_none() && self.reopen_error.is_none()
|
||||
}
|
||||
/// Saved and reopened with no difference at all.
|
||||
pub fn lossless(&self) -> bool {
|
||||
self.ok() && self.not_written.is_empty() && self.comparison.is_empty()
|
||||
}
|
||||
/// How many records the writer left out ([`Self::not_written`]).
|
||||
pub fn not_written_total(&self) -> usize {
|
||||
self.not_written.values().fold(0usize, |a, n| a.saturating_add(*n))
|
||||
}
|
||||
}
|
||||
|
||||
/// `2 ACME_WIDGET, 1 OLE2FRAME`.
|
||||
fn per_type(m: &BTreeMap<String, usize>) -> String {
|
||||
m.iter().map(|(t, n)| format!("{n} {t}")).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Everything found for one file.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileReport {
|
||||
/// The path relative to the corpus folder.
|
||||
pub path: String,
|
||||
pub format: Option<Format>,
|
||||
/// `AC1032` for a DWG, the `$ACADVER` of a DXF.
|
||||
pub version: String,
|
||||
pub bytes: u64,
|
||||
pub open_error: Option<String>,
|
||||
/// What opening reported (a DWG's records the reader skipped, damaged records).
|
||||
pub open_notes: Vec<String>,
|
||||
pub open_seconds: f64,
|
||||
/// What the file holds (for a DWG: its DXF rendition), when counted.
|
||||
pub file_census: Option<Census>,
|
||||
/// What the drawing read from it holds.
|
||||
pub drawing_census: Option<Census>,
|
||||
/// What the drawing holds less or more than the file.
|
||||
pub read: Comparison,
|
||||
pub routes: Vec<RouteReport>,
|
||||
}
|
||||
|
||||
impl FileReport {
|
||||
pub fn opened(&self) -> bool {
|
||||
self.open_error.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
impl CorpusReport {
|
||||
/// What makes a strict corpus run fail, one line each: a file that didn't open, a round trip
|
||||
/// that didn't save or reopen, and records a writer left out ([`RouteReport::not_written`]).
|
||||
/// Other differences are reported, not failed on.
|
||||
pub fn strict_failures(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for f in &self.files {
|
||||
if !f.opened() {
|
||||
out.push(format!("{}: failed to open", f.path));
|
||||
}
|
||||
for r in &f.routes {
|
||||
if !r.ok() {
|
||||
out.push(format!("{} ({}): failed to save or reopen", f.path, r.route));
|
||||
}
|
||||
if !r.not_written.is_empty() {
|
||||
out.push(format!("{} ({}): lost on save, not written: {}", f.path, r.route, per_type(&r.not_written)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole corpus run.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CorpusReport {
|
||||
pub corpus: String,
|
||||
/// UTC, `2026-10-21 09:30`.
|
||||
pub generated: String,
|
||||
pub cadcraft_version: String,
|
||||
pub abs_tol: f64,
|
||||
pub rel_tol: f64,
|
||||
pub files: Vec<FileReport>,
|
||||
}
|
||||
|
||||
/// Run `f`, turning a panic into an error message.
|
||||
fn guard<T>(f: impl FnOnce() -> T) -> Result<T, String> {
|
||||
std::panic::catch_unwind(AssertUnwindSafe(f)).map_err(|p| {
|
||||
let msg =
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "no message".into());
|
||||
format!("CRASH (panic): {msg}")
|
||||
})
|
||||
}
|
||||
|
||||
/// A drawing's notes on what reading it left out.
|
||||
fn notes(d: &Drawing) -> Vec<String> {
|
||||
let mut v = d.read_notes.clone();
|
||||
for (section, n) in &d.skipped_records {
|
||||
v.push(format!("{n} record(s) of the {section} section couldn't be placed and were skipped"));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Save `d` as `to`, reopen it and compare. `keep` is the file to keep the saved bytes in (and
|
||||
/// reopen them from).
|
||||
pub fn round_trip(d: &Drawing, from: Format, to: Format, opts: &Options, keep: Option<&Path>) -> RouteReport {
|
||||
let start = Instant::now();
|
||||
let mut r = RouteReport { route: format!("{} → {}", from.ext(), to.ext()), ..RouteReport::default() };
|
||||
let name = format!("roundtrip.{}", to.ext());
|
||||
let saved = match guard(|| crate::write_noted(d, &name, &crate::SaveOptions::default())) {
|
||||
Ok(Ok((b, not_written))) => {
|
||||
r.not_written = not_written;
|
||||
b
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
r.save_error = Some(e.to_string());
|
||||
r.seconds = start.elapsed().as_secs_f64();
|
||||
return r;
|
||||
}
|
||||
Err(e) => {
|
||||
r.save_error = Some(e);
|
||||
r.seconds = start.elapsed().as_secs_f64();
|
||||
return r;
|
||||
}
|
||||
};
|
||||
r.saved_bytes = saved.len();
|
||||
let mut bytes = saved;
|
||||
if let Some(path) = keep {
|
||||
let kept = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|_| std::fs::write(path, &bytes)).and_then(|_| std::fs::read(path));
|
||||
match kept {
|
||||
Ok(b) => {
|
||||
bytes = b;
|
||||
r.saved_path = Some(path.display().to_string());
|
||||
}
|
||||
Err(e) => r.reopen_notes.push(format!("couldn't keep the saved file at {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
let reopened = match guard(|| crate::read(&bytes, &name)) {
|
||||
Ok(Ok(d2)) => d2,
|
||||
Ok(Err(e)) => {
|
||||
r.reopen_error = Some(e.to_string());
|
||||
r.seconds = start.elapsed().as_secs_f64();
|
||||
return r;
|
||||
}
|
||||
Err(e) => {
|
||||
r.reopen_error = Some(e);
|
||||
r.seconds = start.elapsed().as_secs_f64();
|
||||
return r;
|
||||
}
|
||||
};
|
||||
r.reopen_notes.extend(notes(&reopened));
|
||||
match guard(|| compare(d, &reopened, opts)) {
|
||||
Ok(c) => r.comparison = c,
|
||||
Err(e) => r.reopen_error = Some(format!("comparing: {e}")),
|
||||
}
|
||||
attribute_unwritten(&mut r, to);
|
||||
r.seconds = start.elapsed().as_secs_f64();
|
||||
r
|
||||
}
|
||||
|
||||
/// Say on the lost entity rows of a type the writer couldn't write that this is why they are
|
||||
/// lost, so a writer's gap reads as one and isn't taken for a reader's or a comparison's.
|
||||
fn attribute_unwritten(r: &mut RouteReport, to: Format) {
|
||||
if r.not_written.is_empty() {
|
||||
return;
|
||||
}
|
||||
for d in r.comparison.differences.iter_mut().filter(|d| d.category == Category::Count && d.change == Change::Lost) {
|
||||
if let Some(t) = r.not_written.keys().find(|t| d.detail.starts_with(&format!("{t}: "))) {
|
||||
d.detail.push_str(&format!(" (not written: the {} writer can't write {t})", to.label()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kept saved file of `rel` saved as `to`, in `dir`.
|
||||
fn kept_path(dir: &Path, rel: &str, to: Format) -> PathBuf {
|
||||
let flat: String = rel.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '_' } else { c }).collect();
|
||||
dir.join(format!("{flat}.roundtrip.{}", to.ext()))
|
||||
}
|
||||
|
||||
/// Check one file's bytes: open, count, save in each format, reopen, compare. `rel` names it in
|
||||
/// the report.
|
||||
pub fn check_bytes(bytes: &[u8], rel: &str, opts: &CorpusOptions) -> FileReport {
|
||||
let format = Format::of(bytes);
|
||||
let mut f = FileReport { path: rel.to_string(), format: Some(format), bytes: bytes.len() as u64, ..FileReport::default() };
|
||||
if format == Format::Dwg {
|
||||
f.version = cadcraft_dwg::version(bytes).unwrap_or_default();
|
||||
}
|
||||
let start = Instant::now();
|
||||
let name = format!("source.{}", format.ext());
|
||||
let opened = guard(|| crate::read_source(bytes, std::path::Path::new(&name)));
|
||||
f.open_seconds = start.elapsed().as_secs_f64();
|
||||
let (d, dxf) = match opened {
|
||||
Ok(Ok(x)) => x,
|
||||
Ok(Err(e)) => {
|
||||
f.open_error = Some(e.to_string());
|
||||
return f;
|
||||
}
|
||||
Err(e) => {
|
||||
f.open_error = Some(e);
|
||||
return f;
|
||||
}
|
||||
};
|
||||
if format == Format::Dxf {
|
||||
f.version = d.header.str("ACADVER", "");
|
||||
}
|
||||
f.open_notes = notes(&d);
|
||||
f.drawing_census = guard(|| census_of(&d)).ok();
|
||||
if opts.census {
|
||||
let source = dxf.as_deref().unwrap_or(bytes);
|
||||
match guard(|| census(source)) {
|
||||
Ok(Some(c)) => {
|
||||
if let Ok(read) = guard(|| compare_census(&c, &d, &opts.compare)) {
|
||||
f.read = read;
|
||||
}
|
||||
f.file_census = Some(c);
|
||||
}
|
||||
Ok(None) => f.open_notes.push("the file's groups couldn't be counted".into()),
|
||||
Err(e) => f.open_notes.push(format!("counting the file's groups: {e}")),
|
||||
}
|
||||
}
|
||||
let mut targets = vec![format];
|
||||
if opts.cross_format {
|
||||
targets.push(format.other());
|
||||
}
|
||||
for to in targets {
|
||||
let keep = opts.keep_saved.as_deref().map(|dir| kept_path(dir, rel, to));
|
||||
f.routes.push(round_trip(&d, format, to, &opts.compare, keep.as_deref()));
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// Check one file on disk; `rel` names it in the report.
|
||||
pub fn check_file(path: &Path, rel: &str, opts: &CorpusOptions) -> FileReport {
|
||||
let fail = |e: String| FileReport { path: rel.to_string(), open_error: Some(e), ..FileReport::default() };
|
||||
match std::fs::metadata(path) {
|
||||
Ok(m) if m.len() > MAX_FILE_BYTES => return fail(format!("the file is {} bytes, more than the {MAX_FILE_BYTES} the harness opens", m.len())),
|
||||
Ok(_) => {}
|
||||
Err(e) => return fail(format!("can't read the file: {e}")),
|
||||
}
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => check_bytes(&bytes, rel, opts),
|
||||
Err(e) => fail(format!("can't read the file: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a path names a DXF or DWG file (by extension).
|
||||
pub fn is_drawing_file(path: &Path) -> bool {
|
||||
path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).is_some_and(|e| e == "dxf" || e == "dwg")
|
||||
}
|
||||
|
||||
/// Every DXF and DWG file under `dir`, sorted, leaving out hidden folders and the folders in
|
||||
/// `skip` (the report folder). Symbolic links are not followed.
|
||||
pub fn corpus_files(dir: &Path, skip: &[PathBuf]) -> Vec<PathBuf> {
|
||||
walk(dir, skip).files
|
||||
}
|
||||
|
||||
/// What [`walk`] found, and what it couldn't look at, so a report never passes for a whole one.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Walk {
|
||||
pub files: Vec<PathBuf>,
|
||||
/// Folders and entries that couldn't be read, with why.
|
||||
pub unreadable: Vec<String>,
|
||||
/// Files past [`MAX_FILES`] or folders past the depth limit were left out.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// [`corpus_files`], also saying what it had to leave out.
|
||||
pub fn walk(dir: &Path, skip: &[PathBuf]) -> Walk {
|
||||
let skip: Vec<PathBuf> = skip.iter().map(|p| p.canonicalize().unwrap_or_else(|_| p.clone())).collect();
|
||||
let mut w = Walk::default();
|
||||
let mut stack = vec![(dir.to_path_buf(), 0usize)];
|
||||
while let Some((d, depth)) = stack.pop() {
|
||||
let entries = match std::fs::read_dir(&d) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
w.unreadable.push(format!("{}: {e}", d.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(e) => {
|
||||
w.unreadable.push(format!("{}: {e}", d.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
let ft = match entry.file_type() {
|
||||
Ok(ft) => ft,
|
||||
Err(e) => {
|
||||
w.unreadable.push(format!("{}: {e}", path.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if ft.is_dir() {
|
||||
let hidden = entry.file_name().to_string_lossy().starts_with('.');
|
||||
let canon = path.canonicalize().unwrap_or_else(|_| path.clone());
|
||||
if hidden || skip.contains(&canon) {
|
||||
continue;
|
||||
}
|
||||
if depth < MAX_DEPTH {
|
||||
stack.push((path, depth + 1));
|
||||
} else {
|
||||
w.truncated = true;
|
||||
}
|
||||
} else if ft.is_file() && is_drawing_file(&path) {
|
||||
if w.files.len() < MAX_FILES {
|
||||
w.files.push(path);
|
||||
} else {
|
||||
w.truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
w.files.sort();
|
||||
w.unreadable.sort();
|
||||
w
|
||||
}
|
||||
|
||||
/// `path` relative to `root`, with forward slashes.
|
||||
pub fn relative(path: &Path, root: &Path) -> String {
|
||||
let rel = path.strip_prefix(root).unwrap_or(path);
|
||||
rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::<Vec<_>>().join("/")
|
||||
}
|
||||
|
||||
/// Check `files` (paths under `root`, which names the corpus in the report), calling `progress`
|
||||
/// with (index, count, relative path) before each.
|
||||
pub fn run(root: &Path, files: &[PathBuf], opts: &CorpusOptions, progress: &mut dyn FnMut(usize, usize, &str)) -> CorpusReport {
|
||||
let mut report = CorpusReport {
|
||||
corpus: root.display().to_string(),
|
||||
generated: now_utc(),
|
||||
cadcraft_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
abs_tol: opts.compare.abs_tol,
|
||||
rel_tol: opts.compare.rel_tol,
|
||||
files: Vec::new(),
|
||||
};
|
||||
for (i, path) in files.iter().enumerate() {
|
||||
let rel = relative(path, root);
|
||||
progress(i, files.len(), &rel);
|
||||
let f = match guard(|| check_file(path, &rel, opts)) {
|
||||
Ok(f) => f,
|
||||
Err(e) => FileReport { path: rel, open_error: Some(e), ..FileReport::default() },
|
||||
};
|
||||
report.files.push(f);
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// The current time, UTC, as `2026-10-21 09:30`.
|
||||
fn now_utc() -> String {
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let days = i64::try_from(secs / 86_400).unwrap_or(0);
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
// Days to a civil date (proleptic Gregorian), after Howard Hinnant's public-domain algorithm.
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
format!("{year:04}-{month:02}-{day:02} {h:02}:{m:02} UTC")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Rendering
|
||||
|
||||
/// Most differences listed per round trip in the Markdown report (all are in the JSON).
|
||||
const MD_ROWS: usize = 300;
|
||||
|
||||
/// Text for a Markdown table cell.
|
||||
fn cell(s: &str) -> String {
|
||||
s.replace('|', "\\|").replace(['\r', '\n'], " ")
|
||||
}
|
||||
|
||||
fn size(n: u64) -> String {
|
||||
if n >= 1 << 20 {
|
||||
format!("{:.1} MB", n as f64 / f64::from(1u32 << 20))
|
||||
} else if n >= 1 << 10 {
|
||||
format!("{:.0} KB", n as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{n} B")
|
||||
}
|
||||
}
|
||||
|
||||
fn status(err: &Option<String>) -> String {
|
||||
match err {
|
||||
None => "ok".into(),
|
||||
Some(e) if e.starts_with("CRASH") => "**CRASH**".into(),
|
||||
Some(_) => "**failed**".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tally_cells(t: Tally) -> String {
|
||||
format!("{} | {} | {}", t.lost, t.added, t.changed)
|
||||
}
|
||||
|
||||
impl CorpusReport {
|
||||
pub fn to_json(&self) -> String {
|
||||
serde_json::to_string_pretty(self).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
|
||||
}
|
||||
|
||||
/// Totals per category over every round trip.
|
||||
pub fn totals(&self) -> std::collections::BTreeMap<Category, Tally> {
|
||||
let mut all: std::collections::BTreeMap<Category, Tally> = std::collections::BTreeMap::new();
|
||||
for r in self.files.iter().flat_map(|f| f.routes.iter()) {
|
||||
for (c, t) in &r.comparison.totals {
|
||||
let slot = all.entry(*c).or_default();
|
||||
slot.add(Change::Lost, t.lost);
|
||||
slot.add(Change::Added, t.added);
|
||||
slot.add(Change::Changed, t.changed);
|
||||
}
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
pub fn to_markdown(&self) -> String {
|
||||
let mut s = String::new();
|
||||
let opened = self.files.iter().filter(|f| f.opened()).count();
|
||||
let routes: Vec<&RouteReport> = self.files.iter().flat_map(|f| f.routes.iter()).collect();
|
||||
let save_failed = routes.iter().filter(|r| r.save_error.is_some()).count();
|
||||
let reopen_failed = routes.iter().filter(|r| r.reopen_error.is_some()).count();
|
||||
let lossless = routes.iter().filter(|r| r.lossless()).count();
|
||||
let crashes = self.files.iter().filter(|f| f.open_error.as_deref().is_some_and(|e| e.starts_with("CRASH"))).count()
|
||||
+ routes.iter().filter(|r| [&r.save_error, &r.reopen_error].iter().any(|e| e.as_deref().is_some_and(|e| e.contains("CRASH")))).count();
|
||||
s.push_str("# CADCraft round-trip report\n\n");
|
||||
s.push_str(&format!("- Corpus: `{}`\n", self.corpus));
|
||||
s.push_str(&format!("- Generated: {} by CADCraft {}\n", self.generated, self.cadcraft_version));
|
||||
s.push_str(&format!("- Tolerance for numbers: {:e} absolute + {:e} relative\n", self.abs_tol, self.rel_tol));
|
||||
s.push_str(&format!(
|
||||
"- Files: {} ({opened} opened, {} failed to open); round trips: {} ({save_failed} failed to save, {reopen_failed} failed to reopen, {lossless} without any difference); crashes: {crashes}\n",
|
||||
self.files.len(),
|
||||
self.files.len() - opened,
|
||||
routes.len()
|
||||
));
|
||||
let mut unwritten: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for r in &routes {
|
||||
for (t, n) in &r.not_written {
|
||||
let slot = unwritten.entry(format!("{t} ({})", r.route)).or_insert(0);
|
||||
*slot = slot.saturating_add(*n);
|
||||
}
|
||||
}
|
||||
if unwritten.is_empty() {
|
||||
s.push_str("- Lost on save (records a writer can't write): none\n\n");
|
||||
} else {
|
||||
s.push_str(&format!("- **Lost on save** (records a writer can't write): {}\n\n", per_type(&unwritten)));
|
||||
}
|
||||
s.push_str(
|
||||
"Each file is opened, saved in its own format and in the other one, and the saved file is reopened and compared with \
|
||||
the drawing first opened. *Read* counts what the file holds that the drawing read from it doesn't (for a DWG: its \
|
||||
DXF rendition). Lost/added/changed count items (entities, records, values).\n\n",
|
||||
);
|
||||
s.push_str("## Summary\n\n");
|
||||
s.push_str("| File | Format | Size | Open | Read lost | Round trip | Save | Reopen | Not written | Lost | Added | Changed |\n");
|
||||
s.push_str("|---|---|---|---|---|---|---|---|---|---|---|---|\n");
|
||||
for f in &self.files {
|
||||
let fmt = f.format.map_or("?".to_string(), |x| format!("{} {}", x.label(), f.version).trim().to_string());
|
||||
let open = if f.opened() { format!("ok ({:.1} s)", f.open_seconds) } else { status(&f.open_error) };
|
||||
let lead = format!("| `{}` | {} | {} | {} | {} ", cell(&f.path), cell(&fmt), size(f.bytes), open, f.read.tally(Category::Read).lost);
|
||||
if f.routes.is_empty() {
|
||||
s.push_str(&format!("{lead}| — | — | — | — | — | — | — |\n"));
|
||||
}
|
||||
for (i, r) in f.routes.iter().enumerate() {
|
||||
let head = if i == 0 { lead.clone() } else { "| | | | | ".to_string() };
|
||||
let t = r.comparison.total();
|
||||
let reopen = if r.save_error.is_some() { "—".to_string() } else { status(&r.reopen_error) };
|
||||
let unwritten = if r.not_written.is_empty() { "0".to_string() } else { format!("**{}**", r.not_written_total()) };
|
||||
s.push_str(&format!("{head}| {} | {} | {} | {unwritten} | {} |\n", r.route, status(&r.save_error), reopen, tally_cells(t)));
|
||||
}
|
||||
}
|
||||
s.push_str("\n## By category (all round trips)\n\n| Category | Lost | Added | Changed |\n|---|---|---|---|\n");
|
||||
let totals = self.totals();
|
||||
for c in Category::ALL {
|
||||
if let Some(t) = totals.get(&c).filter(|t| t.total() > 0) {
|
||||
s.push_str(&format!("| {} | {} |\n", c.label(), tally_cells(*t)));
|
||||
}
|
||||
}
|
||||
if totals.values().all(|t| t.total() == 0) {
|
||||
s.push_str("| (no differences) | 0 | 0 | 0 |\n");
|
||||
}
|
||||
s.push_str("\n## Files\n");
|
||||
for f in &self.files {
|
||||
self.file_markdown(f, &mut s);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn file_markdown(&self, f: &FileReport, s: &mut String) {
|
||||
s.push_str(&format!("\n### `{}`\n\n", f.path));
|
||||
let fmt = f.format.map_or("unknown format".to_string(), |x| format!("{} {}", x.label(), f.version).trim().to_string());
|
||||
s.push_str(&format!("- {fmt}, {}\n", size(f.bytes)));
|
||||
match &f.open_error {
|
||||
Some(e) => {
|
||||
s.push_str(&format!("- Open: {}: {}\n", status(&f.open_error), cell(e)));
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
let what = f.drawing_census.as_ref().map_or(String::new(), |c| {
|
||||
let n: usize = c.entities.values().sum();
|
||||
let nb: usize = c.block_entities.values().sum();
|
||||
format!(
|
||||
": {n} entities in model and paper space, {} blocks holding {nb} entities, {} layers, {} unmodelled objects kept",
|
||||
c.blocks,
|
||||
c.tables.get("LAYER").copied().unwrap_or(0),
|
||||
c.objects.values().sum::<usize>()
|
||||
)
|
||||
});
|
||||
s.push_str(&format!("- Open: ok in {:.1} s{what}\n", f.open_seconds));
|
||||
}
|
||||
}
|
||||
for n in &f.open_notes {
|
||||
s.push_str(&format!("- Note on opening: {}\n", cell(n)));
|
||||
}
|
||||
if let Some(c) = &f.file_census
|
||||
&& !c.objects.is_empty()
|
||||
{
|
||||
let kept = f.drawing_census.as_ref().map(|d| &d.objects);
|
||||
let list: Vec<String> = c
|
||||
.objects
|
||||
.iter()
|
||||
.map(|(k, n)| match kept.and_then(|m| m.get(k)) {
|
||||
Some(m) => format!("{k} {n} ({m} kept as unmodelled)"),
|
||||
None => format!("{k} {n}"),
|
||||
})
|
||||
.collect();
|
||||
s.push_str(&format!("- Objects in the file: {}\n", cell(&list.join(", "))));
|
||||
}
|
||||
if !f.read.is_empty() {
|
||||
s.push_str("\n#### Read (file → drawing)\n\n");
|
||||
diff_table(&f.read, s);
|
||||
}
|
||||
for r in &f.routes {
|
||||
s.push_str(&format!("\n#### {}\n\n", r.route));
|
||||
match (&r.save_error, &r.reopen_error) {
|
||||
(Some(e), _) => s.push_str(&format!("- Save: {}: {}\n", status(&r.save_error), cell(e))),
|
||||
(None, Some(e)) => {
|
||||
s.push_str(&format!("- Save: ok ({})\n", size(r.saved_bytes as u64)));
|
||||
s.push_str(&format!("- Reopen: {}: {}\n", status(&r.reopen_error), cell(e)));
|
||||
}
|
||||
(None, None) => {
|
||||
let t = r.comparison.total();
|
||||
s.push_str(&format!(
|
||||
"- Saved {} and reopened in {:.1} s: {} lost, {} added, {} changed\n",
|
||||
size(r.saved_bytes as u64),
|
||||
r.seconds,
|
||||
t.lost,
|
||||
t.added,
|
||||
t.changed
|
||||
));
|
||||
}
|
||||
}
|
||||
if !r.not_written.is_empty() {
|
||||
s.push_str(&format!("- **Lost on save**, not written (the writer can't write these types): {}\n", per_type(&r.not_written)));
|
||||
}
|
||||
if let Some(p) = &r.saved_path {
|
||||
s.push_str(&format!("- Saved file: `{p}`\n"));
|
||||
}
|
||||
for n in &r.reopen_notes {
|
||||
s.push_str(&format!("- Note on reopening: {}\n", cell(n)));
|
||||
}
|
||||
if !r.comparison.is_empty() {
|
||||
s.push('\n');
|
||||
diff_table(&r.comparison, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `report.md` and `report.json` into `dir` (created if needed); returns their paths.
|
||||
pub fn write(&self, dir: &Path) -> std::io::Result<(PathBuf, PathBuf)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let (md, json) = (dir.join("report.md"), dir.join("report.json"));
|
||||
std::fs::write(&md, self.to_markdown())?;
|
||||
std::fs::write(&json, self.to_json())?;
|
||||
Ok((md, json))
|
||||
}
|
||||
}
|
||||
|
||||
/// A comparison's differences as a Markdown table (the first [`MD_ROWS`]).
|
||||
fn diff_table(c: &Comparison, s: &mut String) {
|
||||
let tallies: Vec<String> = Category::ALL
|
||||
.iter()
|
||||
.filter_map(|cat| {
|
||||
let t = c.tally(*cat);
|
||||
(t.total() > 0).then(|| format!("{} {}/{}/{}", cat.label(), t.lost, t.added, t.changed))
|
||||
})
|
||||
.collect();
|
||||
s.push_str(&format!("By category (lost/added/changed): {}\n\n", tallies.join(" · ")));
|
||||
s.push_str("| Category | Change | Where | Detail |\n|---|---|---|---|\n");
|
||||
for d in c.differences.iter().take(MD_ROWS) {
|
||||
s.push_str(&format!("| {} | {} | {} | {} |\n", d.category.label(), d.change.label(), cell(&clip(&d.place, 80)), cell(&clip(&d.detail, 400))));
|
||||
}
|
||||
let more = c.differences.len().saturating_sub(MD_ROWS).saturating_add(c.omitted);
|
||||
if more > 0 {
|
||||
s.push_str(&format!("\n… and {more} more differences (see report.json; differences beyond the listing limit are only counted).\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::foreign::{ForeignExtra, ForeignObject};
|
||||
use cadcraft_doc::*;
|
||||
use cadcraft_geom::{PolyVertex, Vec2, Vec3};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn tag(code: i32, value: &str) -> RawTag {
|
||||
RawTag { code, value: value.into() }
|
||||
}
|
||||
|
||||
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) })
|
||||
}
|
||||
|
||||
/// A small drawing touching every part the comparator looks at.
|
||||
fn drawing() -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
d.layers.push(Layer { name: "Walls".into(), color: Color::Index(1), ..Layer::default() });
|
||||
let walls = Common { layer: "Walls".into(), ..Common::default() };
|
||||
d.add(&Space::Model, walls.clone(), line((0.0, 0.0), (10.0, 0.0))).unwrap();
|
||||
d.add(&Space::Model, walls, line((0.0, 5.0), (10.0, 5.0))).unwrap();
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(3.0, 4.0, 0.0), radius: 2.5 })).unwrap();
|
||||
let tagged = Common { xdata: vec![tag(1001, "ACME"), tag(1000, "door"), tag(1040, "2.5")], ..Common::default() };
|
||||
d.add(
|
||||
&Space::Model,
|
||||
tagged,
|
||||
EntityKind::LwPolyline(LwPolyline {
|
||||
vertices: vec![PolyVertex::new(Vec2::ZERO), PolyVertex::with_bulge(Vec2::new(4.0, 0.0), 0.5), PolyVertex::new(Vec2::new(4.0, 3.0))],
|
||||
closed: true,
|
||||
const_width: 0.0,
|
||||
elevation: 0.0,
|
||||
plinegen: false,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
d.add(
|
||||
&Space::Model,
|
||||
Common::default(),
|
||||
EntityKind::Unknown(Unknown { dxf_type: "ACME_WIDGET".into(), tags: vec![tag(100, "AcDbEntity"), tag(10, "1.0"), tag(20, "2.0")] }),
|
||||
)
|
||||
.unwrap();
|
||||
d.add(&Space::Paper("Layout1".into()), Common::default(), line((1.0, 1.0), (2.0, 2.0))).unwrap();
|
||||
let mut b = Block::new("DOOR");
|
||||
let h = d.new_handle();
|
||||
b.entities.push(Entity::new(h, line((0.0, 0.0), (0.0, 1.0))));
|
||||
d.blocks.insert("DOOR".into(), Arc::new(b));
|
||||
let f = Arc::make_mut(&mut d.foreign);
|
||||
f.objects.push(ForeignObject { handle: Handle(0x500), kind: "XRECORD".into(), tags: vec![tag(100, "AcDbXrecord"), tag(1, "kept")] });
|
||||
f.dictionary.push(("ACME_DATA".into(), Handle(0x500)));
|
||||
f.classes.push(vec![tag(1, "ACME_WIDGET"), tag(2, "AcmeWidget"), tag(3, "Acme")]);
|
||||
f.records.insert(("LAYER".into(), "WALLS".into()), ForeignExtra { xdata: vec![tag(1001, "ACME"), tag(1070, "3")], ..ForeignExtra::default() });
|
||||
d
|
||||
}
|
||||
|
||||
fn cmp(a: &Drawing, b: &Drawing) -> Comparison {
|
||||
compare(a, b, &Options::default())
|
||||
}
|
||||
|
||||
fn only(c: &Comparison, cat: Category, change: Change) -> Vec<&Difference> {
|
||||
c.differences.iter().filter(|d| d.category == cat && d.change == change).collect()
|
||||
}
|
||||
|
||||
fn first_handle(d: &Drawing, kind: &str) -> Handle {
|
||||
d.model.iter().find(|e| e.kind.dxf_name() == kind).map(|e| e.handle).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_drawings_have_no_differences() {
|
||||
let d = drawing();
|
||||
let c = cmp(&d, &d.clone());
|
||||
assert!(c.is_empty(), "{:#?}", c.differences);
|
||||
assert_eq!(c.total(), Tally::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_is_compared_by_handle_within_the_tolerance() {
|
||||
let a = drawing();
|
||||
let h = first_handle(&a, "LINE");
|
||||
let mut b = a.clone();
|
||||
b.modify_entity(h, |e| {
|
||||
if let EntityKind::Line(l) = &mut e.kind {
|
||||
l.b.x += 1e-9;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert!(cmp(&a, &b).is_empty(), "a change within the tolerance is no difference");
|
||||
b.modify_entity(h, |e| {
|
||||
if let EntityKind::Line(l) = &mut e.kind {
|
||||
l.b.x = 11.0;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let c = cmp(&a, &b);
|
||||
let g = only(&c, Category::Geometry, Change::Changed);
|
||||
assert_eq!(g.len(), 1, "{:#?}", c.differences);
|
||||
assert!(g[0].place.contains(&h.hex()) && g[0].place.starts_with("Model LINE"), "{:?}", g[0]);
|
||||
assert!(g[0].detail.contains("b.x: 10") && g[0].detail.contains("11"), "{:?}", g[0]);
|
||||
assert_eq!(c.tally(Category::Geometry).changed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angles_a_whole_turn_apart_are_the_same() {
|
||||
let mut a = drawing();
|
||||
let h = a
|
||||
.add(&Space::Model, Common::default(), EntityKind::Arc(cadcraft_doc::Arc { center: Vec3::ZERO, radius: 2.0, start: -0.5, end: 1.0 }))
|
||||
.unwrap();
|
||||
let mut b = a.clone();
|
||||
b.modify_entity(h, |e| {
|
||||
if let EntityKind::Arc(x) = &mut e.kind {
|
||||
x.start += std::f64::consts::TAU;
|
||||
x.end -= std::f64::consts::TAU;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert!(cmp(&a, &b).is_empty(), "{:#?}", cmp(&a, &b).differences);
|
||||
// A radius is no angle.
|
||||
b.modify_entity(h, |e| {
|
||||
if let EntityKind::Arc(x) = &mut e.kind {
|
||||
x.radius += std::f64::consts::TAU;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(cmp(&a, &b).tally(Category::Geometry).changed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_and_type_changes_are_reported() {
|
||||
let a = drawing();
|
||||
let h = first_handle(&a, "CIRCLE");
|
||||
let mut b = a.clone();
|
||||
b.modify_entity(h, |e| e.common.color = Color::Index(3)).unwrap();
|
||||
let c = cmp(&a, &b);
|
||||
assert_eq!(only(&c, Category::Properties, Change::Changed).len(), 1, "{:#?}", c.differences);
|
||||
b.modify_entity(h, |e| e.kind = line((0.0, 0.0), (1.0, 1.0))).unwrap();
|
||||
let c = cmp(&a, &b);
|
||||
let g = only(&c, Category::Geometry, Change::Changed);
|
||||
assert!(g.iter().any(|d| d.detail == "type CIRCLE → LINE"), "{:#?}", c.differences);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_and_added_entities_are_counted_per_type_and_space() {
|
||||
let a = drawing();
|
||||
let h = first_handle(&a, "CIRCLE");
|
||||
let mut b = a.clone();
|
||||
b.remove_entity(h);
|
||||
let mut paper = b.clone();
|
||||
paper.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Point(Point { p: Vec3::ZERO, angle: 0.0 })).unwrap();
|
||||
let c = cmp(&a, &paper);
|
||||
let lost = only(&c, Category::Count, Change::Lost);
|
||||
assert_eq!(lost.len(), 1, "{:#?}", c.differences);
|
||||
assert_eq!(lost[0].place, "Model CIRCLE");
|
||||
assert!(lost[0].detail.contains("1 → 0") && lost[0].detail.contains(&h.hex()), "{:?}", lost[0]);
|
||||
let added = only(&c, Category::Count, Change::Added);
|
||||
assert_eq!(added.len(), 1);
|
||||
assert_eq!(added[0].place, "Layout Layout1 POINT");
|
||||
assert_eq!(c.tally(Category::Count), Tally { lost: 1, added: 1, changed: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renumbered_entities_are_matched_by_content() {
|
||||
let a = drawing();
|
||||
let mut b = a.clone();
|
||||
// Every line gets a new handle, in reverse order, and one of them moves.
|
||||
let lines: Vec<Arc<Entity>> = b.model.iter().filter(|e| e.kind.dxf_name() == "LINE").cloned().collect();
|
||||
for (i, e) in lines.iter().rev().enumerate() {
|
||||
b.model.remove(e.handle);
|
||||
let mut e = Entity::clone(e);
|
||||
e.handle = Handle(0x9000 + i as u64);
|
||||
b.model.push(e);
|
||||
}
|
||||
let c = cmp(&a, &b);
|
||||
assert_eq!(c.tally(Category::Count), Tally::default(), "{:#?}", c.differences);
|
||||
assert_eq!(c.tally(Category::Geometry), Tally::default(), "pairs by content, not by order: {:#?}", c.differences);
|
||||
assert_eq!(c.tally(Category::Handle).changed, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entities_moving_between_spaces_are_reported() {
|
||||
let a = drawing();
|
||||
let h = first_handle(&a, "CIRCLE");
|
||||
let mut b = a.clone();
|
||||
let e = b.remove_entity(h).unwrap();
|
||||
if let Some(l) = b.layouts.first_mut() {
|
||||
l.entities.push(Entity::clone(&e));
|
||||
}
|
||||
let c = cmp(&a, &b);
|
||||
let moved = only(&c, Category::Handle, Change::Changed);
|
||||
assert_eq!(moved.len(), 1, "{:#?}", c.differences);
|
||||
assert_eq!(moved[0].detail, format!("1 entities moved from Model to Layout Layout1 ({})", h.hex()));
|
||||
assert_eq!(c.tally(Category::Count), Tally::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreferenced_anonymous_blocks_dropped_on_save_are_one_row() {
|
||||
let mut a = drawing();
|
||||
for name in ["*D5", "*D6"] {
|
||||
let mut blk = Block::new(name);
|
||||
for _ in 0..2 {
|
||||
let h = a.new_handle();
|
||||
blk.entities.push(Entity::new(h, line((0.0, 0.0), (1.0, 0.0))));
|
||||
}
|
||||
a.blocks.insert(name.into(), Arc::new(blk));
|
||||
}
|
||||
let mut b = a.clone();
|
||||
b.blocks.remove("*D5");
|
||||
b.blocks.remove("*D6");
|
||||
let c = cmp(&a, &b);
|
||||
let lost = only(&c, Category::Count, Change::Lost);
|
||||
assert_eq!(lost.len(), 1, "{:#?}", c.differences);
|
||||
assert_eq!(lost[0].detail, "4 entities of 2 anonymous blocks nothing refers to (e.g. *D5) were not saved");
|
||||
assert_eq!(c.tally(Category::Count).lost, 4);
|
||||
assert_eq!(c.tally(Category::Block), Tally { lost: 2, added: 0, changed: 0 }, "{:#?}", c.differences);
|
||||
assert_eq!(only(&c, Category::Block, Change::Lost).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renamed_anonymous_blocks_are_matched_by_their_entities() {
|
||||
let mut a = drawing();
|
||||
let mut blk = Block::new("*D1");
|
||||
let h = a.new_handle();
|
||||
blk.entities.push(Entity::new(h, line((0.0, 0.0), (5.0, 0.0))));
|
||||
a.blocks.insert("*D1".into(), Arc::new(blk.clone()));
|
||||
let key = ("BLOCK_RECORD".to_string(), "*D1".to_string());
|
||||
Arc::make_mut(&mut a.foreign).records.insert(key, ForeignExtra { xdata: vec![tag(1001, "ACME"), tag(1070, "1")], ..ForeignExtra::default() });
|
||||
let mut b = a.clone();
|
||||
b.blocks.remove("*D1");
|
||||
blk.name = "*D7".into();
|
||||
b.blocks.insert("*D7".into(), Arc::new(blk));
|
||||
let f = Arc::make_mut(&mut b.foreign);
|
||||
let extra = f.records.remove(&("BLOCK_RECORD".to_string(), "*D1".to_string())).unwrap();
|
||||
f.records.insert(("BLOCK_RECORD".into(), "*D7".into()), extra);
|
||||
let c = cmp(&a, &b);
|
||||
assert_eq!(c.tally(Category::Count), Tally::default(), "{:#?}", c.differences);
|
||||
assert_eq!(c.tally(Category::Xdata), Tally::default(), "{:#?}", c.differences);
|
||||
assert_eq!(c.tally(Category::Block), Tally { lost: 0, added: 0, changed: 1 }, "{:#?}", c.differences);
|
||||
assert!(c.differences.iter().any(|d| d.detail == "1 anonymous blocks saved under new names (e.g. *D1 → *D7)"), "{:#?}", c.differences);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdata_extension_dictionaries_and_opaque_entities_are_compared() {
|
||||
let a = drawing();
|
||||
let pl = first_handle(&a, "LWPOLYLINE");
|
||||
let unknown = first_handle(&a, "ACME_WIDGET");
|
||||
let mut b = a.clone();
|
||||
b.modify_entity(pl, |e| e.common.xdata.clear()).unwrap();
|
||||
b.modify_entity(unknown, |e| {
|
||||
if let EntityKind::Unknown(u) = &mut e.kind {
|
||||
u.tags.pop();
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
Arc::make_mut(&mut b.foreign).records.clear();
|
||||
let mut a2 = a.clone();
|
||||
Arc::make_mut(&mut a2.foreign)
|
||||
.entities
|
||||
.insert(pl, ForeignExtra { xdict: Some(Handle(0x600)), reactors: vec![Handle(0x601)], ..ForeignExtra::default() });
|
||||
let c = cmp(&a2, &b);
|
||||
let x = only(&c, Category::Xdata, Change::Lost);
|
||||
assert_eq!(x.len(), 2, "the entity's and the layer's: {:#?}", c.differences);
|
||||
assert!(x.iter().any(|d| d.place == "LAYER WALLS" && d.detail.starts_with("application ACME")), "{x:#?}");
|
||||
assert!(x.iter().any(|d| d.place.contains("LWPOLYLINE")), "{x:#?}");
|
||||
assert_eq!(c.tally(Category::ExtDict).lost, 2, "the dictionary and a reactor: {:#?}", c.differences);
|
||||
let o = only(&c, Category::Opaque, Change::Changed);
|
||||
assert_eq!(o.len(), 1, "{:#?}", c.differences);
|
||||
assert!(o[0].detail.contains("3 groups → 2"), "{:?}", o[0]);
|
||||
// Numbers in kept groups compare within the tolerance.
|
||||
let mut b2 = a.clone();
|
||||
b2.modify_entity(unknown, |e| {
|
||||
if let EntityKind::Unknown(u) = &mut e.kind {
|
||||
u.tags[1].value = "1.0000000000001".into();
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert!(cmp(&a, &b2).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_blocks_and_layouts_are_compared_by_name() {
|
||||
let a = drawing();
|
||||
let mut b = a.clone();
|
||||
b.layers.retain(|l| l.name != "Walls");
|
||||
b.linetypes.push(Linetype { name: "Dashed".into(), description: String::new(), pattern: Vec::new() });
|
||||
if let Some(s) = b.text_styles.first_mut() {
|
||||
s.height = 7.0;
|
||||
}
|
||||
b.blocks.remove("DOOR");
|
||||
b.layouts.retain(|l| l.name != "Layout2");
|
||||
let c = cmp(&a, &b);
|
||||
let t = |cat, ch, place: &str| c.differences.iter().any(|d| d.category == cat && d.change == ch && d.place == place);
|
||||
assert!(t(Category::Table, Change::Lost, "LAYER Walls"), "{:#?}", c.differences);
|
||||
assert!(t(Category::Table, Change::Added, "LTYPE Dashed"));
|
||||
assert!(t(Category::Table, Change::Changed, "STYLE Standard"));
|
||||
assert!(t(Category::Block, Change::Lost, "BLOCK DOOR"));
|
||||
assert!(t(Category::Layout, Change::Lost, "LAYOUT Layout2"));
|
||||
// The block's entity is lost with it, counted where it was.
|
||||
assert!(t(Category::Count, Change::Lost, "Block DOOR LINE"), "{:#?}", c.differences);
|
||||
// Names match without case.
|
||||
let mut upper = a.clone();
|
||||
if let Some(l) = upper.layers.iter_mut().find(|l| l.name == "Walls") {
|
||||
l.name = "WALLS".into();
|
||||
}
|
||||
let c = cmp(&a, &upper);
|
||||
assert_eq!(c.tally(Category::Table).lost, 0, "{:#?}", c.differences);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmodelled_objects_dictionary_entries_and_classes_are_compared() {
|
||||
let a = drawing();
|
||||
let mut b = a.clone();
|
||||
let f = Arc::make_mut(&mut b.foreign);
|
||||
f.objects.clear();
|
||||
f.dictionary.clear();
|
||||
f.classes.clear();
|
||||
let c = cmp(&a, &b);
|
||||
assert_eq!(c.tally(Category::Object).lost, 1, "{:#?}", c.differences);
|
||||
assert_eq!(c.tally(Category::Dictionary).lost, 1);
|
||||
assert_eq!(c.tally(Category::Class).lost, 1);
|
||||
// A renumbered object is matched by its groups.
|
||||
let mut b = a.clone();
|
||||
Arc::make_mut(&mut b.foreign).objects[0].handle = Handle(0x700);
|
||||
let c = cmp(&a, &b);
|
||||
assert_eq!(c.tally(Category::Object), Tally::default(), "{:#?}", c.differences);
|
||||
assert_eq!(c.tally(Category::Handle).changed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_variables_are_compared_without_volatile_ones() {
|
||||
let a = drawing();
|
||||
let mut b = a.clone();
|
||||
b.header.set_f64("LTSCALE", 2.0);
|
||||
b.header.vars.remove("TEXTSIZE");
|
||||
b.header.set_str("HANDSEED", "FFFF");
|
||||
b.header.set_str("TDUPDATE", "2461000.5");
|
||||
// 1 and 1.0 are the same value.
|
||||
b.header.set_i64("DIMSCALE", 1);
|
||||
let c = cmp(&a, &b);
|
||||
assert!(only(&c, Category::Header, Change::Changed).iter().any(|d| d.place == "$LTSCALE"), "{:#?}", c.differences);
|
||||
assert!(only(&c, Category::Header, Change::Lost).iter().any(|d| d.place == "$TEXTSIZE"));
|
||||
assert_eq!(c.tally(Category::Header).total(), 2, "{:#?}", c.differences);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_listing_limit_keeps_counting() {
|
||||
let a = drawing();
|
||||
let mut b = a.clone();
|
||||
for i in 0..20 {
|
||||
b.header.set_i64(&format!("USERI{i}"), i);
|
||||
}
|
||||
let c = compare(&a, &b, &Options { max_differences: 5, ..Options::default() });
|
||||
assert_eq!(c.differences.len(), 5);
|
||||
assert_eq!(c.omitted, 15);
|
||||
assert_eq!(c.tally(Category::Header).added, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn census_counts_what_a_dxf_holds() {
|
||||
let dxf = " 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nLAYER\n 0\nLAYER\n 2\n0\n 0\nLAYER\n 2\nA\n 0\nENDTAB\n 0\nENDSEC\n\
|
||||
0\nSECTION\n 2\nBLOCKS\n 0\nBLOCK\n 2\n*Model_Space\n 0\nENDBLK\n 0\nBLOCK\n 2\n*Paper_Space\n 0\nLINE\n 0\nENDBLK\n\
|
||||
0\nBLOCK\n 2\nB1\n 0\nCIRCLE\n 0\nPOLYLINE\n 0\nVERTEX\n 0\nSEQEND\n 0\nENDBLK\n 0\nENDSEC\n\
|
||||
0\nSECTION\n 2\nENTITIES\n 0\nLINE\n 0\nINSERT\n 0\nATTRIB\n 0\nSEQEND\n 0\nENDSEC\n\
|
||||
0\nSECTION\n 2\nOBJECTS\n 0\nDICTIONARY\n 0\nXRECORD\n 0\nXRECORD\n 0\nENDSEC\n 0\nEOF\n";
|
||||
let c = census(dxf.as_bytes()).unwrap();
|
||||
assert_eq!(c.entities.get("LINE"), Some(&2));
|
||||
assert_eq!(c.entities.get("INSERT"), Some(&1));
|
||||
assert!(!c.entities.contains_key("ATTRIB") && !c.entities.contains_key("SEQEND"));
|
||||
assert_eq!(c.block_entities.get("CIRCLE"), Some(&1));
|
||||
assert_eq!(c.block_entities.get("POLYLINE"), Some(&1));
|
||||
assert_eq!(c.blocks, 1);
|
||||
assert_eq!(c.tables.get("LAYER"), Some(&2));
|
||||
assert_eq!(c.objects.get("XRECORD"), Some(&2));
|
||||
let _ = census(b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_census_reports_entities_the_reader_left_out() {
|
||||
let d = drawing();
|
||||
let mut file = census_of(&d);
|
||||
*file.entities.entry("LINE".into()).or_default() += 2;
|
||||
*file.entities.entry("WEIRD".into()).or_default() += 1;
|
||||
let c = compare_census(&file, &d, &Options::default());
|
||||
let lost = only(&c, Category::Read, Change::Lost);
|
||||
assert_eq!(c.tally(Category::Read).lost, 3, "{:#?}", c.differences);
|
||||
assert!(lost.iter().any(|x| x.place == "spaces LINE" && x.detail == "LINE: 5 in the file, 3 read"), "{lost:#?}");
|
||||
assert!(compare_census(&census_of(&d), &d, &Options::default()).is_empty());
|
||||
}
|
||||
|
||||
/// The round trips of a synthetic drawing through CADCraft's own DXF and DWG writers: whatever
|
||||
/// they lose shows in the comparison. Plain geometry, layers, xdata and unmodelled objects must
|
||||
/// come back from DXF.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn dxf_round_trip_of_plain_geometry_is_lossless() {
|
||||
use corpus::{Format, round_trip};
|
||||
let mut d = drawing();
|
||||
// What the comparator should see in a round trip: the drawing as read from its own DXF.
|
||||
d = crate::read(crate::write(&d, "a.dxf").unwrap().as_slice(), "a.dxf").unwrap();
|
||||
let r = round_trip(&d, Format::Dxf, Format::Dxf, &Options::default(), None);
|
||||
assert!(r.ok(), "{r:#?}");
|
||||
let core = [Category::Count, Category::Geometry, Category::Table, Category::Xdata, Category::Object, Category::Opaque];
|
||||
for cat in core {
|
||||
assert_eq!(r.comparison.tally(cat), Tally::default(), "{cat:?}: {:#?}", r.comparison.differences);
|
||||
}
|
||||
let dwg = round_trip(&d, Format::Dxf, Format::Dwg, &Options::default(), None);
|
||||
assert!(dwg.save_error.is_none() && dwg.reopen_error.is_none(), "{dwg:#?}");
|
||||
assert!(dwg.saved_bytes > 0);
|
||||
// Through DWG, only the custom-class entity is lost (gap FF1: acadrust drops other programs'
|
||||
// entities without DWG data of their own) — and the comparator says so.
|
||||
let lost: Vec<&str> = dwg.comparison.differences.iter().filter(|d| d.category == Category::Count).map(|d| d.place.as_str()).collect();
|
||||
assert_eq!(lost, ["Model ACME_WIDGET"], "{:#?}", dwg.comparison.differences);
|
||||
assert_eq!(dwg.comparison.tally(Category::Geometry), Tally::default(), "{:#?}", dwg.comparison.differences);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn hostile_files_are_reported_not_crashed() {
|
||||
use corpus::{CorpusOptions, check_bytes};
|
||||
let opts = CorpusOptions::default();
|
||||
for (bytes, name) in [
|
||||
(&b""[..], "empty.dxf"),
|
||||
(b"AC1032 not really a dwg file at all", "fake.dwg"),
|
||||
(b" 0\nSECTION\n 2\nENTITIES\n 0\nLINE\n 10\nnan\n 20\n1e400\n 0\nENDSEC\n 0\nEOF\n", "odd.dxf"),
|
||||
(&[0xFFu8; 64][..], "noise.dxf"),
|
||||
] {
|
||||
let f = check_bytes(bytes, name, &opts);
|
||||
assert_eq!(f.path, name);
|
||||
if f.opened() {
|
||||
assert!(!f.routes.is_empty());
|
||||
} else {
|
||||
assert!(f.routes.is_empty() && f.open_error.as_deref().is_some_and(|e| !e.is_empty()), "{f:#?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn report_renders_as_markdown_and_json() {
|
||||
use corpus::{CorpusOptions, CorpusReport, check_bytes};
|
||||
let d = drawing();
|
||||
let dxf = crate::write(&d, "a.dxf").unwrap();
|
||||
let opts = CorpusOptions::default();
|
||||
let report = CorpusReport {
|
||||
corpus: "corpus".into(),
|
||||
generated: "2026-10-21 09:30 UTC".into(),
|
||||
cadcraft_version: "0".into(),
|
||||
abs_tol: 1e-6,
|
||||
rel_tol: 1e-9,
|
||||
files: vec![check_bytes(&dxf, "synthetic/a.dxf", &opts), check_bytes(b"AC1032 broken", "dwg/broken.dwg", &opts)],
|
||||
};
|
||||
let md = report.to_markdown();
|
||||
assert!(md.starts_with("# CADCraft round-trip report"), "{md}");
|
||||
assert!(md.contains("| `synthetic/a.dxf` | DXF AC1032 |"), "{md}");
|
||||
assert!(md.contains("dxf → dxf") && md.contains("dxf → dwg"), "{md}");
|
||||
assert!(md.contains("| `dwg/broken.dwg` | DWG AC1032 |") && md.contains("**failed**"), "{md}");
|
||||
assert!(md.contains("## By category"));
|
||||
let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
|
||||
assert_eq!(json["files"][0]["path"], "synthetic/a.dxf");
|
||||
assert_eq!(json["files"][0]["routes"][0]["route"], "dxf → dxf");
|
||||
assert!(json["files"][1]["openError"].as_str().is_some_and(|e| !e.is_empty()), "{json}");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_folder_it_cannot_read_is_reported_not_skipped() {
|
||||
let w = corpus::walk(std::path::Path::new("no/such/corpus/folder"), &[]);
|
||||
assert!(w.files.is_empty());
|
||||
assert_eq!(w.unreadable.len(), 1, "{:?}", w.unreadable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_files_walks_folders_and_skips_the_report() {
|
||||
use corpus::corpus_files;
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-corpus-walk-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
for p in ["dxf/a.DXF", "dwg/b.dwg", "dwg/sub/c.dwg", "notes.txt", "report/a.roundtrip.dxf", ".git/x.dxf"] {
|
||||
let path = dir.join(p);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, b"x").unwrap();
|
||||
}
|
||||
let files = corpus_files(&dir, &[dir.join("report")]);
|
||||
let rel: Vec<String> = files.iter().map(|f| corpus::relative(f, &dir)).collect();
|
||||
assert_eq!(rel, ["dwg/b.dwg", "dwg/sub/c.dwg", "dxf/a.DXF"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The real-file corpus: set `CADCRAFT_CORPUS_DIR` to a folder of DXF/DWG files (a checkout of
|
||||
/// `storytold/cadcraft-corpus`) to open, save and reopen each one and write `report.md` and
|
||||
/// `report.json` to `CADCRAFT_CORPUS_REPORT` (default: the corpus folder's `report`). Skipped
|
||||
/// when the variable is unset, so CI passes without the corpus. It reports, it doesn't fail on
|
||||
/// differences; set `CADCRAFT_CORPUS_STRICT=1` to fail when a file doesn't open, save or reopen,
|
||||
/// or when a writer leaves records out of a saved file ([`corpus::CorpusReport::strict_failures`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn corpus_round_trip_report() {
|
||||
let Some(dir) = std::env::var_os("CADCRAFT_CORPUS_DIR").map(std::path::PathBuf::from) else {
|
||||
eprintln!("CADCRAFT_CORPUS_DIR is not set: corpus round trip skipped");
|
||||
return;
|
||||
};
|
||||
let out = std::env::var_os("CADCRAFT_CORPUS_REPORT").map_or_else(|| dir.join("report"), std::path::PathBuf::from);
|
||||
let files = corpus::corpus_files(&dir, std::slice::from_ref(&out));
|
||||
let report = corpus::run(&dir, &files, &corpus::CorpusOptions::default(), &mut |i, n, rel| eprintln!("[{}/{n}] {rel}", i + 1));
|
||||
let (md, json) = report.write(&out).unwrap();
|
||||
eprintln!("corpus report: {} and {}", md.display(), json.display());
|
||||
if std::env::var("CADCRAFT_CORPUS_STRICT").is_ok_and(|v| v == "1") {
|
||||
let failed = report.strict_failures();
|
||||
assert!(failed.is_empty(), "corpus failures:\n{}", failed.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A type the DWG writer can't write is reported as lost on save, per type: in the route's
|
||||
/// `not_written`, on the lost entity rows, in the Markdown and as a strict failure. A type it
|
||||
/// writes (OLE2FRAME, kept by CADCraft without modelling it) is none of these.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn types_the_dwg_writer_cannot_write_are_reported_lost() {
|
||||
use corpus::{CorpusReport, FileReport, Format, round_trip};
|
||||
let unknown = |t: &str, tags: Vec<RawTag>| EntityKind::Unknown(Unknown { dxf_type: t.into(), tags });
|
||||
let mut d = Drawing::new_metric();
|
||||
let widget = vec![tag(100, "AcDbEntity"), tag(100, "AcmeWidget"), tag(10, "1.0"), tag(20, "2.0"), tag(30, "0.0")];
|
||||
d.add(&Space::Model, Common::default(), unknown("ACME_WIDGET", widget.clone())).unwrap();
|
||||
d.add(&Space::Model, Common::default(), unknown("ACME_WIDGET", widget)).unwrap();
|
||||
// Its data has no DWG wrapper, which is where a DWG keeps the corners: the reader's defaults.
|
||||
let ole = ["100", "AcDbEntity", "8", "0", "100", "AcDbOle2Frame", "70", "2", "10", "1.0", "20", "1.0", "30", "0.0", "11", "0.0", "21", "0.0"]
|
||||
.chunks(2)
|
||||
.map(|g| tag(g[0].parse().unwrap(), g[1]))
|
||||
.chain(
|
||||
["31", "0.0", "71", "2", "72", "0", "73", "0", "90", "4", "310", "C0FFEE00", "1", "OLE"]
|
||||
.chunks(2)
|
||||
.map(|g| tag(g[0].parse().unwrap(), g[1])),
|
||||
)
|
||||
.collect();
|
||||
d.add(&Space::Model, Common::default(), unknown("OLE2FRAME", ole)).unwrap();
|
||||
let d = crate::read(&crate::write(&d, "a.dxf").unwrap(), "a.dxf").unwrap();
|
||||
|
||||
let r = round_trip(&d, Format::Dxf, Format::Dwg, &Options::default(), None);
|
||||
assert!(r.ok(), "{r:#?}");
|
||||
assert_eq!(r.not_written, [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
|
||||
let lost: Vec<&Difference> = r.comparison.differences.iter().filter(|x| x.category == Category::Count && x.change == Change::Lost).collect();
|
||||
assert_eq!(lost.len(), 1, "{lost:#?}");
|
||||
assert!(lost[0].place.ends_with("ACME_WIDGET") && lost[0].detail.contains("not written: the DWG writer can't write ACME_WIDGET"), "{lost:#?}");
|
||||
// The OLE2FRAME is written (only the CLASS record AutoCAD's files have is added).
|
||||
assert!(
|
||||
!r.comparison.differences.iter().any(|x| x.category != Category::Class && x.place.contains("OLE2FRAME")),
|
||||
"{:#?}",
|
||||
r.comparison.differences
|
||||
);
|
||||
assert!(!r.lossless());
|
||||
|
||||
let report =
|
||||
CorpusReport { files: vec![FileReport { path: "a.dxf".into(), routes: vec![r], ..FileReport::default() }], ..CorpusReport::default() };
|
||||
assert_eq!(report.strict_failures(), ["a.dxf (dxf → dwg): lost on save, not written: 2 ACME_WIDGET"]);
|
||||
let md = report.to_markdown();
|
||||
assert!(md.contains("- **Lost on save** (records a writer can't write): 2 ACME_WIDGET (dxf → dwg)"), "{md}");
|
||||
assert!(md.contains("| dxf → dwg | ok | ok | **2** |"), "{md}");
|
||||
assert!(md.contains("- **Lost on save**, not written (the writer can't write these types): 2 ACME_WIDGET"), "{md}");
|
||||
|
||||
// DXF keeps everything: nothing lost on save, nothing to fail on.
|
||||
let r = round_trip(&d, Format::Dxf, Format::Dxf, &Options::default(), None);
|
||||
assert!(r.not_written.is_empty());
|
||||
let report =
|
||||
CorpusReport { files: vec![FileReport { path: "a.dxf".into(), routes: vec![r], ..FileReport::default() }], ..CorpusReport::default() };
|
||||
assert!(report.strict_failures().is_empty());
|
||||
assert!(report.to_markdown().contains("- Lost on save (records a writer can't write): none"));
|
||||
}
|
||||
@@ -96,6 +96,8 @@ pub fn export_list(list: &cadcraft_render::DisplayList, window: Option<Bounds2>)
|
||||
}
|
||||
s.push_str(r#""/>"#);
|
||||
}
|
||||
// Raster images are not written to SVG yet: their frame is.
|
||||
Kind::Image { .. } => {}
|
||||
Kind::Point => {
|
||||
if let Some(q) = pts.first() {
|
||||
let _ = write!(s, r#"<circle cx="{:.dp$}" cy="{:.dp$}" r="{:.dp$}" fill="{c}"{op}/>"#, tx(q.x), ty(q.y), stroke);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user