mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Squash of PR #562 by @vangel76. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9fd1b8988d
commit
d0dd5cfe16
14 files changed
+561
-7
No files matched your search
@@ -106,7 +106,7 @@ fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf"])
|
||||
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf", "dwt"])
|
||||
.add_filter(i18n::t("All files"), &["*"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -125,8 +125,9 @@ fn services() -> Services {
|
||||
/// filter's extension, and macOS accepts only listed extensions, replacing any other with the
|
||||
/// first one. DWG must be listed for a `.dwg` name to survive, and a drawing opened from DWG
|
||||
/// then defaults to DWG; other names keep DXF first.
|
||||
fn filters_for_save(name: &str) -> [(&'static str, &'static str); 5] {
|
||||
let mut filters = [("Drawing (DXF)", "dxf"), ("Drawing (DWG)", "dwg"), ("SVG", "svg"), ("PNG", "png"), ("PDF", "pdf")];
|
||||
fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] {
|
||||
let mut filters =
|
||||
[("Drawing (DXF)", "dxf"), ("Drawing (DWG)", "dwg"), ("Drawing Template (DWT)", "dwt"), ("SVG", "svg"), ("PNG", "png"), ("PDF", "pdf")];
|
||||
let lower = name.to_ascii_lowercase();
|
||||
filters.sort_by_key(|(_, ext)| !lower.ends_with(&format!(".{ext}")));
|
||||
filters
|
||||
@@ -327,6 +328,7 @@ mod tests {
|
||||
assert_eq!(first("Drawing1.dxf"), "dxf");
|
||||
assert_eq!(first("Drawing1"), "dxf");
|
||||
assert_eq!(first("plan.pdf"), "pdf");
|
||||
assert_eq!(first("office.dwt"), "dwt");
|
||||
for name in ["a.dwg", "b.dxf", "c"] {
|
||||
let exts: Vec<_> = filters_for_save(name).iter().map(|(_, e)| *e).collect();
|
||||
assert!(exts.contains(&"dwg") && exts.contains(&"dxf"), "{name}");
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
//! registered by the host (desktop/CLI) via [`crate::cmd::file::set_io`]. On the web, where there is
|
||||
//! no file system, saved files reach the user through the host's [`crate::cmd::file::set_deliver`].
|
||||
|
||||
mod pgp;
|
||||
mod template;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use cadcraft_doc::Drawing;
|
||||
@@ -11,6 +14,9 @@ use serde_json::{Value, json};
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub(crate) use pgp::{specs as alias_specs, user_alias};
|
||||
pub use template::{qnew_template, set_qnew_template};
|
||||
|
||||
/// Plot a space to PDF bytes with JSON options (`{paper?, landscape?, fit?, lineweights?, …}`).
|
||||
pub type PlotHook = fn(&Drawing, &cadcraft_doc::Space, &Value) -> std::result::Result<Vec<u8>, String>;
|
||||
|
||||
@@ -123,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.menu(&["File", "New Drawing..."])
|
||||
.key("Cmd+N")
|
||||
.alias(&["qnew"])
|
||||
.params("{metric?: bool}")
|
||||
.params("{metric?: bool, template?: path to a .dwt/.dxf template (\".\" for none; default QNEWTEMPLATE)}")
|
||||
.enabled(always)
|
||||
.noundo(),
|
||||
CommandSpec::new("open", "Open...", run_open)
|
||||
@@ -148,6 +154,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(r) = template::run_new(s, p)? {
|
||||
return Ok(r);
|
||||
}
|
||||
let i = s.new_drawing(bool_or(p, "metric", false));
|
||||
Ok(json!({ "index": i, "title": s.docs.get(i).map(|d| d.title.clone()) }))
|
||||
}
|
||||
@@ -361,13 +370,14 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap<String, usize>) -> Op
|
||||
/// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG
|
||||
/// bridge drops types it doesn't know); DXF writes them back as read.
|
||||
pub fn drops_unknown(path: &str) -> bool {
|
||||
std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("dwg"))
|
||||
// Templates (.dwt) are written as DWG too.
|
||||
std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("dwg") || e.eq_ignore_ascii_case("dwt"))
|
||||
}
|
||||
|
||||
/// Whether `path` is saved as a drawing (DXF, DWG) rather than exported as an image.
|
||||
fn is_drawing_file(path: &str) -> bool {
|
||||
let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
matches!(ext.as_str(), "dxf" | "dwg" | "")
|
||||
matches!(ext.as_str(), "dxf" | "dwg" | "dwt" | "")
|
||||
}
|
||||
|
||||
/// Say on the command line what the file couldn't keep, and list it in the result's `notSaved`.
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! The user's command alias file (the publicly documented `.pgp` format: `ALIAS, *COMMAND` lines,
|
||||
//! `;` comments): ALIASEDIT and REINIT.
|
||||
//!
|
||||
//! A loaded file adds aliases or overrides the built-in ones for the rest of the session, for
|
||||
//! every drawing; command names themselves always win. Lines that define external commands
|
||||
//! (programs the file would have the application start) are never run: they are skipped with a
|
||||
//! note. CADCraft ships no alias file; the aliases built into the command catalog stay the
|
||||
//! defaults.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::helpers::support_text;
|
||||
use super::*;
|
||||
|
||||
/// Most user aliases held.
|
||||
pub const MAX_ALIASES: usize = 10_000;
|
||||
/// Longest alias name.
|
||||
const MAX_ALIAS_CHARS: usize = 64;
|
||||
/// Longest line read.
|
||||
const MAX_LINE_CHARS: usize = 4096;
|
||||
/// Most warnings reported.
|
||||
const MAX_WARNINGS: usize = 100;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Aliases {
|
||||
/// Lower-case alias → command id.
|
||||
map: BTreeMap<String, String>,
|
||||
/// The file last loaded (REINIT reads it again).
|
||||
file: Option<String>,
|
||||
}
|
||||
|
||||
fn store() -> MutexGuard<'static, Aliases> {
|
||||
static USER: OnceLock<Mutex<Aliases>> = OnceLock::new();
|
||||
USER.get_or_init(|| Mutex::new(Aliases::default())).lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The command a user alias stands for (`name` in lower case).
|
||||
pub(crate) fn user_alias(name: &str) -> Option<String> {
|
||||
store().map.get(name).cloned()
|
||||
}
|
||||
|
||||
/// The command id `target` names (`*LINE`, `_.line`, `-LAYER`…), if there is such a command.
|
||||
fn command_id(target: &str) -> Option<String> {
|
||||
let t = target.trim().trim_start_matches('*').trim().trim_start_matches(['_', '.']).to_ascii_lowercase();
|
||||
find_command(&t).map(|c| c.id.to_string())
|
||||
}
|
||||
|
||||
/// Whether `a` can be typed as an alias: one word of printable characters, not a command name.
|
||||
fn alias_name(a: &str) -> std::result::Result<String, String> {
|
||||
let a = a.trim();
|
||||
if a.is_empty() || a.chars().count() > MAX_ALIAS_CHARS || a.chars().any(|c| c.is_whitespace() || c.is_control() || c == '\'' || c == '{') {
|
||||
return Err(format!("`{a}` can't be an alias"));
|
||||
}
|
||||
let l = a.to_ascii_lowercase();
|
||||
if find_command(&l).is_some() {
|
||||
return Err(format!("{a} is a command name; it stays the command"));
|
||||
}
|
||||
Ok(l)
|
||||
}
|
||||
|
||||
/// The aliases of a `.pgp` file's text, and its problems.
|
||||
fn parse(text: &str) -> (Vec<(String, String)>, Vec<String>) {
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut warn = |no: usize, msg: String| {
|
||||
if warnings.len() < MAX_WARNINGS {
|
||||
warnings.push(format!("line {no}: {msg}"));
|
||||
}
|
||||
};
|
||||
for (i, line) in text.trim_start_matches('\u{feff}').lines().enumerate() {
|
||||
let no = i.saturating_add(1);
|
||||
let line = line.split(';').next().unwrap_or("").trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if line.chars().count() > MAX_LINE_CHARS {
|
||||
warn(no, "line too long; skipped".into());
|
||||
continue;
|
||||
}
|
||||
let Some((alias, target)) = line.split_once(',') else {
|
||||
warn(no, format!("`{line}` is not ALIAS, *COMMAND; skipped"));
|
||||
continue;
|
||||
};
|
||||
if !target.trim_start().starts_with('*') {
|
||||
warn(no, format!("{} is an external command; CADCraft doesn't run programs from alias files", alias.trim()));
|
||||
continue;
|
||||
}
|
||||
let name = match alias_name(alias) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
warn(no, format!("{e}; skipped"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(id) = command_id(target) else {
|
||||
warn(no, format!("{}: unknown command {}; skipped", alias.trim(), target.trim()));
|
||||
continue;
|
||||
};
|
||||
if out.len() >= MAX_ALIASES {
|
||||
warn(no, format!("more than {MAX_ALIASES} aliases; the rest are skipped"));
|
||||
break;
|
||||
}
|
||||
// A later line for the same alias wins, as it would when typed into the file twice.
|
||||
out.retain(|(a, _)| *a != name);
|
||||
out.push((name, id));
|
||||
}
|
||||
(out, warnings)
|
||||
}
|
||||
|
||||
fn listing(warnings: &[String], added: usize) -> Value {
|
||||
let st = store();
|
||||
json!({ "aliases": st.map, "file": st.file, "loaded": added, "warnings": warnings })
|
||||
}
|
||||
|
||||
/// Replace the user aliases with a file's (`path` is remembered for REINIT).
|
||||
fn load(text: &str, path: Option<String>) -> (usize, Vec<String>) {
|
||||
let (aliases, warnings) = parse(text);
|
||||
let mut st = store();
|
||||
st.map = aliases.into_iter().collect();
|
||||
if path.is_some() {
|
||||
st.file = path;
|
||||
}
|
||||
(st.map.len(), warnings)
|
||||
}
|
||||
|
||||
/// ALIASEDIT `{file? | text?, set?: {alias: command}, remove?: [alias], clear?: bool}`: load an
|
||||
/// alias file (replacing the user aliases), clear them, or add and remove single aliases.
|
||||
fn run_aliasedit(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut warnings = Vec::new();
|
||||
let mut added = 0;
|
||||
if bool_or(p, "clear", false) {
|
||||
let mut st = store();
|
||||
st.map.clear();
|
||||
st.file = None;
|
||||
}
|
||||
if let Some((text, source)) = support_text("aliasedit", p, "file", "text")? {
|
||||
let path = p.get("file").and_then(Value::as_str).map(|_| source);
|
||||
(added, warnings) = load(&text, path);
|
||||
}
|
||||
match p.get("set") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Object(m)) => {
|
||||
for (alias, target) in m {
|
||||
let name = alias_name(alias).map_err(|e| bad("aliasedit", e))?;
|
||||
let target = target.as_str().ok_or_else(|| bad("aliasedit", format!("{alias}: the command must be a name")))?;
|
||||
let id = command_id(target).ok_or_else(|| bad("aliasedit", format!("{alias}: unknown command {target}")))?;
|
||||
let mut st = store();
|
||||
if !st.map.contains_key(&name) && st.map.len() >= MAX_ALIASES {
|
||||
return Err(bad("aliasedit", format!("more than {MAX_ALIASES} aliases")));
|
||||
}
|
||||
st.map.insert(name, id);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(bad("aliasedit", "`set` is an object {alias: command}")),
|
||||
}
|
||||
match p.get("remove") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Array(a)) => {
|
||||
let mut st = store();
|
||||
for v in a {
|
||||
if let Some(n) = v.as_str() {
|
||||
st.map.remove(&n.trim().to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(bad("aliasedit", "`remove` is a list of aliases")),
|
||||
}
|
||||
Ok(listing(&warnings, added))
|
||||
}
|
||||
|
||||
/// REINIT `{file? | text?}`: read the alias file again (the one last loaded, or the given one).
|
||||
fn run_reinit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some((text, source)) = support_text("reinit", p, "file", "text")? {
|
||||
let path = p.get("file").and_then(Value::as_str).map(|_| source);
|
||||
let (n, warnings) = load(&text, path);
|
||||
s.echo(format!("{n} command alias(es) loaded."));
|
||||
return Ok(listing(&warnings, n));
|
||||
}
|
||||
let Some(path) = store().file.clone() else {
|
||||
s.echo("No command alias file is loaded; ALIASEDIT {file} loads one.");
|
||||
return Ok(listing(&[], 0));
|
||||
};
|
||||
let (text, _) = support_text("reinit", &json!({ "file": path }), "file", "text")?.ok_or_else(|| bad("reinit", "no alias file"))?;
|
||||
let (n, warnings) = load(&text, Some(path.clone()));
|
||||
s.echo(format!("{n} command alias(es) loaded from {path}."));
|
||||
Ok(listing(&warnings, n))
|
||||
}
|
||||
|
||||
pub(crate) fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("aliasedit", "Command Aliases", run_aliasedit)
|
||||
.params("{file?: path to a .pgp alias file | text?: its contents, set?: {alias: command}, remove?: [alias], clear?: bool} → {aliases, file, loaded, warnings}")
|
||||
.enabled(always)
|
||||
.noundo(),
|
||||
CommandSpec::new("reinit", "Reinitialize", run_reinit).params("{file? | text?} (default: the alias file loaded last)").enabled(always).noundo(),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Drawing templates: NEW / QNEW from a template file (`.dwt`, which is DWG content, or a `.dxf`),
|
||||
//! and the default template QNEW uses (the QNEWTEMPLATE setting).
|
||||
//!
|
||||
//! A new drawing from a template is the template's content (layers, styles, layouts, objects…)
|
||||
//! as an untitled drawing: saving asks for a name, so the template file is never overwritten by
|
||||
//! accident. CADCraft ships no template; templates are the user's files.
|
||||
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
|
||||
|
||||
use cadcraft_doc::Drawing;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The default template (profile setting, process-wide like FONTALT): empty = none.
|
||||
fn default_template() -> MutexGuard<'static, String> {
|
||||
static QNEW: OnceLock<Mutex<String>> = OnceLock::new();
|
||||
QNEW.get_or_init(|| Mutex::new(String::new())).lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// QNEWTEMPLATE: the template NEW/QNEW start from when no template is named ("" = none).
|
||||
pub fn qnew_template() -> String {
|
||||
default_template().clone()
|
||||
}
|
||||
|
||||
/// Set QNEWTEMPLATE; "" or "." clears it. The file is read when a drawing is made from it.
|
||||
pub fn set_qnew_template(path: &str) {
|
||||
let p = path.trim();
|
||||
*default_template() = if p == "." { String::new() } else { p.to_string() };
|
||||
}
|
||||
|
||||
/// The template a NEW call starts from: `template` ("" or "." for none), else the default
|
||||
/// template unless `metric` asks for a blank drawing in given units.
|
||||
fn template_for(p: &Value) -> Result<Option<String>> {
|
||||
match p.get("template") {
|
||||
Some(Value::String(t)) => Ok(Some(t.trim().to_string()).filter(|t| !t.is_empty() && t != ".")),
|
||||
Some(Value::Null) | None if p.get("metric").is_some_and(|m| !m.is_null()) => Ok(None),
|
||||
Some(Value::Null) | None => Ok(Some(qnew_template()).filter(|t| !t.is_empty())),
|
||||
Some(_) => Err(bad("new", "`template` must be a file path")),
|
||||
}
|
||||
}
|
||||
|
||||
/// NEW from a template, or `None` when the call names none (a blank drawing then).
|
||||
pub(super) fn run_new(s: &mut Session, p: &Value) -> Result<Option<Value>> {
|
||||
let Some(path) = template_for(p)? else { return Ok(None) };
|
||||
let d = read_template(&path)?;
|
||||
s.untitled_counter = s.untitled_counter.saturating_add(1);
|
||||
let title = format!("Drawing{}", s.untitled_counter);
|
||||
let i = s.open_drawing(d, &title, None);
|
||||
// An empty template shows its drawing limits.
|
||||
if let Some(st) = s.docs.get_mut(i)
|
||||
&& st.doc.extents(&cadcraft_doc::Space::Model).is_empty()
|
||||
&& let (Some(lo), Some(hi)) = (st.doc.header.point("LIMMIN"), st.doc.header.point("LIMMAX"))
|
||||
&& lo.x.is_finite()
|
||||
&& lo.y.is_finite()
|
||||
&& hi.x.is_finite()
|
||||
&& hi.y.is_finite()
|
||||
&& hi.x > lo.x
|
||||
&& 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) });
|
||||
}
|
||||
Ok(Some(json!({ "index": i, "title": title, "template": path })))
|
||||
}
|
||||
|
||||
/// Read a template drawing through the io hooks (DWG content for `.dwt`, or DXF).
|
||||
fn read_template(path: &str) -> Result<Drawing> {
|
||||
let hooks = io().ok_or_else(|| bad("new", "file formats are not available in this build"))?;
|
||||
let bytes = read_bytes(path)?;
|
||||
(hooks.read)(&bytes, path).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_bytes(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_bytes(path: &str) -> Result<Vec<u8>> {
|
||||
Err(bad("new", format!("template {path}: paths are not available on the web")))
|
||||
}
|
||||
@@ -166,6 +166,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
SPECS.get_or_init(|| {
|
||||
let mut v = Vec::new();
|
||||
v.extend(file::specs());
|
||||
v.extend(file::alias_specs());
|
||||
v.extend(audit::specs());
|
||||
v.extend(edit::specs());
|
||||
v.extend(qselect::specs());
|
||||
@@ -205,11 +206,15 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
}
|
||||
|
||||
/// Resolve a typed name or alias (AutoCAD-style short names such as `L`, `C`, `PL`) to an id.
|
||||
/// Command names win; the user's aliases (ALIASEDIT, REINIT) come before the built-in ones.
|
||||
pub fn resolve_alias(name: &str) -> String {
|
||||
let l = name.to_ascii_lowercase();
|
||||
if find_command(&l).is_some() {
|
||||
return l;
|
||||
}
|
||||
if let Some(id) = file::user_alias(&l) {
|
||||
return id;
|
||||
}
|
||||
command_specs().iter().find(|c| c.aliases.iter().any(|a| a.eq_ignore_ascii_case(&l))).map(|c| c.id.to_string()).unwrap_or(l)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const SESSION_VARS: &[&str] = &[
|
||||
"POLARDIST",
|
||||
"SNAPTYPE",
|
||||
"AUTOSNAP",
|
||||
"QNEWTEMPLATE",
|
||||
"GRIDMODE",
|
||||
"SNAPMODE",
|
||||
"SNAPUNIT",
|
||||
@@ -106,6 +107,8 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
"CMDNAMES" => json!(s.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default()),
|
||||
"DWGNAME" => json!(s.state().map(|d| d.title.clone()).unwrap_or_default()),
|
||||
"DBMOD" => json!(s.state().map(|d| i32::from(d.is_dirty())).unwrap_or(0)),
|
||||
// The template NEW/QNEW start from (a profile setting, like FONTALT; "" = none).
|
||||
"QNEWTEMPLATE" => json!(crate::cmd::file::qnew_template()),
|
||||
"CTAB" => json!(
|
||||
s.state()
|
||||
.map(|d| match &d.space {
|
||||
@@ -208,6 +211,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
st.polarmode = bits & 8 != 0;
|
||||
st.otrack = bits & 16 != 0;
|
||||
}
|
||||
"QNEWTEMPLATE" => crate::cmd::file::set_qnew_template(v.as_str().ok_or_else(bad)?),
|
||||
"POLARANG" => st.polarang = as_f64(v).filter(|a| *a > 0.0).ok_or_else(bad)?.to_radians(),
|
||||
"GRIDMODE" => st.gridmode = as_bool(v).ok_or_else(bad)?,
|
||||
"SNAPMODE" => st.snapmode = as_bool(v).ok_or_else(bad)?,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! The user's command alias file (#552): ALIASEDIT loads a `.pgp`-style file (`ALIAS, *COMMAND`)
|
||||
//! whose aliases add to or override the built-in ones; REINIT reads it again. The aliases are
|
||||
//! process-wide, so these tests run one at a time.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::resolve_alias;
|
||||
use serde_json::json;
|
||||
|
||||
static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
|
||||
|
||||
const PGP: &str = "\
|
||||
; my own aliases
|
||||
ZZC, *CIRCLE
|
||||
L, *PLINE
|
||||
ZZL, *_.LINE
|
||||
ZZDL, *-LINETYPE
|
||||
NOTEPAD, START NOTEPAD, 1,*File to edit: ,
|
||||
ZZBAD, *NOSUCHCOMMAND
|
||||
LINE, *CIRCLE
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn an_alias_file_adds_and_overrides_aliases() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = Session::new();
|
||||
assert_eq!(resolve_alias("l"), "line");
|
||||
let r = s.execute("aliasedit", &json!({ "text": PGP })).unwrap();
|
||||
assert_eq!(r["aliases"], json!({ "l": "pline", "zzc": "circle", "zzdl": "-linetype", "zzl": "line" }));
|
||||
let warnings = r["warnings"].to_string();
|
||||
for what in ["NOTEPAD", "NOSUCHCOMMAND", "LINE is a command name"] {
|
||||
assert!(warnings.contains(what), "no warning about {what}: {warnings}");
|
||||
}
|
||||
assert_eq!(resolve_alias("ZZC"), "circle");
|
||||
assert_eq!(resolve_alias("l"), "pline", "the file overrides a built-in alias");
|
||||
assert_eq!(resolve_alias("line"), "line", "command names win");
|
||||
assert_eq!(resolve_alias("c"), "circle", "built-in aliases the file doesn't mention stay");
|
||||
|
||||
// Typed at the command line like any alias.
|
||||
s.cmdline("zzc 0,0 3").unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.iter().count(), 1);
|
||||
|
||||
// Single aliases, then forgetting them all.
|
||||
s.execute("aliasedit", &json!({ "set": { "zzq": "qsave" }, "remove": ["zzl"] })).unwrap();
|
||||
assert_eq!(resolve_alias("zzq"), "qsave");
|
||||
assert_eq!(resolve_alias("zzl"), "zzl");
|
||||
assert!(s.execute("aliasedit", &json!({ "set": { "zzx": "nosuchcommand" } })).is_err());
|
||||
assert!(s.execute("aliasedit", &json!({ "set": { "two words": "line" } })).is_err());
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
assert_eq!(resolve_alias("l"), "line");
|
||||
assert_eq!(resolve_alias("zzc"), "zzc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinit_reads_the_alias_file_again() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aliases-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("mine.pgp").to_string_lossy().into_owned();
|
||||
std::fs::write(&file, "ZZR, *RECTANG\n").unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("aliasedit", &json!({ "file": file })).unwrap();
|
||||
assert_eq!(resolve_alias("zzr"), "rectang");
|
||||
|
||||
std::fs::write(&file, "ZZR, *CIRCLE\n").unwrap();
|
||||
s.cmdline("reinit").unwrap();
|
||||
assert_eq!(resolve_alias("zzr"), "circle");
|
||||
assert!(s.log.iter().any(|l| l.contains("1 command alias")), "{:?}", s.log);
|
||||
let r = s.execute("reinit", &json!({ "text": "ZZS, *SCALE\n" })).unwrap();
|
||||
assert_eq!(r["aliases"], json!({ "zzs": "scale" }));
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_alias_files_never_panic() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = Session::new();
|
||||
let long = format!("{},*LINE\n", "A".repeat(5000));
|
||||
let many: String = (0..12_000).map(|i| format!("ZZ{i},*LINE\n")).collect();
|
||||
let huge = "x".repeat(9 << 20);
|
||||
for text in [long.as_str(), many.as_str(), ",,,,\n*\n;\n\u{0},*\n\u{feff}", ", *LINE\n'X, *LINE\n{X, *LINE\n"] {
|
||||
let r = s.execute("aliasedit", &json!({ "text": text })).unwrap();
|
||||
assert!(r["aliases"].as_object().unwrap().len() <= 10_000);
|
||||
}
|
||||
assert!(s.execute("aliasedit", &json!({ "text": huge })).is_err());
|
||||
for p in [json!({ "set": [1] }), json!({ "remove": "x" }), json!({ "file": 3 }), json!({ "set": { "zz": 3 } })] {
|
||||
assert!(s.execute("aliasedit", &p).is_err());
|
||||
}
|
||||
assert!(s.execute("reinit", &json!({ "file": "/no/such/file.pgp" })).is_err());
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
}
|
||||
@@ -51,3 +51,10 @@ fn save_lists_the_entities_the_file_does_not_keep() {
|
||||
let r = s.execute("document.bytes", &json!({})).unwrap();
|
||||
assert!(r.get("notSaved").is_none() && s.log.len() == n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_report_what_dwg_does_not_keep() {
|
||||
use cadcraft_engine::cmd::file::drops_unknown;
|
||||
assert!(drops_unknown("a.dwg") && drops_unknown("B.DWT"));
|
||||
assert!(!drops_unknown("a.dxf"));
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Drawing templates (#552): SAVEAS to a .dwt, NEW/QNEW from a template file, and the default
|
||||
//! template (QNEWTEMPLATE). The io hooks here keep drawings in memory: a "file" holds the number
|
||||
//! of the drawing written, so the test sees exactly what the engine hands the file formats.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::Drawing;
|
||||
use serde_json::json;
|
||||
|
||||
static FILES: Mutex<Vec<(String, Drawing)>> = Mutex::new(Vec::new());
|
||||
|
||||
fn write(d: &Drawing, name: &str) -> Result<Vec<u8>, String> {
|
||||
let mut files = FILES.lock().unwrap();
|
||||
files.push((name.to_string(), d.clone()));
|
||||
Ok(format!("TEST {}", files.len() - 1).into_bytes())
|
||||
}
|
||||
|
||||
fn read(bytes: &[u8], _name: &str) -> Result<Drawing, String> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let i: usize = text.strip_prefix("TEST ").and_then(|n| n.trim().parse().ok()).ok_or("not a drawing")?;
|
||||
FILES.lock().unwrap().get(i).map(|f| f.1.clone()).ok_or_else(|| "no such drawing".into())
|
||||
}
|
||||
|
||||
fn hooks() {
|
||||
set_io(IoHooks { read, write, plot: None });
|
||||
}
|
||||
|
||||
fn path(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-templates-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// A drawing with an office layer and a title block circle, saved as a template.
|
||||
fn office_template(s: &mut Session, name: &str) -> String {
|
||||
s.execute("layer.new", &json!({ "name": "A-WALL", "color": 3 })).unwrap();
|
||||
s.execute("circle", &json!({ "center": [5, 5], "radius": 2 })).unwrap();
|
||||
let p = path(name);
|
||||
s.execute("saveas", &json!({ "path": p })).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saveas_dwt_then_new_from_the_template() {
|
||||
hooks();
|
||||
let mut s = Session::new();
|
||||
let dwt = office_template(&mut s, "office.dwt");
|
||||
// A template is saved like a drawing: the format sees the .dwt name, the drawing is saved.
|
||||
assert!(FILES.lock().unwrap().iter().any(|(n, _)| n.ends_with("office.dwt")));
|
||||
let st = s.state().unwrap();
|
||||
assert!(!st.is_dirty());
|
||||
assert_eq!(st.title, "office.dwt");
|
||||
|
||||
let r = s.execute("new", &json!({ "template": dwt })).unwrap();
|
||||
let st = s.state().unwrap();
|
||||
assert_eq!(r["title"], json!(st.title));
|
||||
assert!(st.title.starts_with("Drawing"), "{}", st.title);
|
||||
assert!(st.path.is_none(), "an untitled drawing: saving asks for a name");
|
||||
assert!(!st.is_dirty());
|
||||
assert!(st.doc.layers.iter().any(|l| l.name == "A-WALL"));
|
||||
assert_eq!(st.doc.model.iter().count(), 1);
|
||||
assert!(s.execute("qsave", &json!({})).is_err(), "QSAVE needs a name: the template stays as it is");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qnewtemplate_is_the_default_for_new_and_qnew() {
|
||||
hooks();
|
||||
let mut s = Session::new();
|
||||
let dwt = office_template(&mut s, "default.dwt");
|
||||
let docs = s.docs.len();
|
||||
|
||||
s.execute("setvar", &json!({ "name": "QNEWTEMPLATE", "value": dwt })).unwrap();
|
||||
assert_eq!(s.execute("getvar", &json!({ "name": "qnewtemplate" })).unwrap()["value"], json!(dwt));
|
||||
s.cmdline("qnew").unwrap();
|
||||
assert_eq!(s.docs.len(), docs + 1);
|
||||
assert!(s.doc().unwrap().layers.iter().any(|l| l.name == "A-WALL"), "QNEW starts from the default template");
|
||||
s.execute("new", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().layers.iter().any(|l| l.name == "A-WALL"));
|
||||
|
||||
// Units asked for, or "." for no template: a blank drawing.
|
||||
s.execute("new", &json!({ "metric": true })).unwrap();
|
||||
assert!(s.doc().unwrap().layers.iter().all(|l| l.name != "A-WALL"));
|
||||
assert_eq!(s.doc().unwrap().header.i64("MEASUREMENT", 0), 1);
|
||||
s.execute("new", &json!({ "template": "." })).unwrap();
|
||||
assert!(s.doc().unwrap().layers.iter().all(|l| l.name != "A-WALL"));
|
||||
|
||||
s.execute("setvar", &json!({ "name": "QNEWTEMPLATE", "value": "." })).unwrap();
|
||||
assert_eq!(s.execute("getvar", &json!({ "name": "QNEWTEMPLATE" })).unwrap()["value"], json!(""));
|
||||
s.execute("new", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().layers.iter().all(|l| l.name != "A-WALL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_that_cannot_be_read_makes_no_drawing() {
|
||||
hooks();
|
||||
let mut s = Session::new();
|
||||
let docs = s.docs.len();
|
||||
let err = s.execute("new", &json!({ "template": "/no/such/folder/office.dwt" })).unwrap_err().to_string();
|
||||
assert!(err.contains("office.dwt"), "{err}");
|
||||
let junk = path("junk.dwt");
|
||||
std::fs::write(&junk, b"not a drawing at all").unwrap();
|
||||
assert!(s.execute("new", &json!({ "template": junk })).is_err());
|
||||
assert!(s.execute("new", &json!({ "template": 42 })).is_err());
|
||||
assert_eq!(s.docs.len(), docs);
|
||||
}
|
||||
@@ -103,6 +103,10 @@ pub fn write_with(d: &Drawing, name: &str, opts: &SaveOptions) -> Result<Vec<u8>
|
||||
/// extents. Formats that are not images (DXF, DWG) ignore the window. The window is checked with
|
||||
/// [`check_window`].
|
||||
pub fn write_framed(d: &Drawing, name: &str, window: Option<Bounds2>) -> Result<Vec<u8>> {
|
||||
// A drawing template (.dwt) is DWG content.
|
||||
if ext(name) == "dwt" {
|
||||
return write_framed(d, &format!("{name}.dwg"), None);
|
||||
}
|
||||
if let Some(w) = &window
|
||||
&& is_image(name)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Drawing templates (#552): a `.dwt` is written as DWG content and read back, and a template
|
||||
//! named `.dwt` that holds DXF text reads too.
|
||||
|
||||
use cadcraft_doc::{Drawing, Layer};
|
||||
use cadcraft_io::{read, write};
|
||||
|
||||
fn office() -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
d.layers.push(Layer::new("A-WALL"));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dwt_is_written_as_dwg_and_read_back() {
|
||||
let bytes = write(&office(), "office.dwt").unwrap();
|
||||
assert!(bytes.starts_with(b"AC10"), "a template is DWG content");
|
||||
let back = read(&bytes, "office.dwt").unwrap();
|
||||
assert!(back.layers.iter().any(|l| l.name == "A-WALL"));
|
||||
assert_eq!(back.header.i64("MEASUREMENT", 0), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dxf_template_reads_under_either_name() {
|
||||
let dxf = write(&office(), "office.dxf").unwrap();
|
||||
for name in ["office.dxf", "office.dwt"] {
|
||||
assert!(read(&dxf, name).unwrap().layers.iter().any(|l| l.name == "A-WALL"), "{name}");
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,7 @@
|
||||
Color 9 Колір 9
|
||||
Color Index Індекс кольору
|
||||
Color... Колір...
|
||||
Command Aliases Псевдоніми команд
|
||||
Command History Історія команд
|
||||
Clear Command History Очистити історію команд
|
||||
Command Line Командний рядок
|
||||
@@ -240,6 +241,7 @@
|
||||
Drawing (DWG) Креслення (DWG)
|
||||
Drawing (DXF) Креслення (DXF)
|
||||
Drawing Limits Межі креслення
|
||||
Drawing Template (DWT) Шаблон креслення (DWT)
|
||||
Drawing Utilities Утиліти креслення
|
||||
Drawings (DWG, DXF) Креслення (DWG, DXF)
|
||||
Dynamic Динамічно
|
||||
@@ -533,6 +535,7 @@
|
||||
New Layout Новий аркуш
|
||||
New VP Freeze Заморозити в нових ВЕ
|
||||
New Viewports... Нові видові екрани...
|
||||
New from Template... Створити із шаблону...
|
||||
New layout Новий аркуш
|
||||
New state name Назва нового стану
|
||||
No Ні
|
||||
@@ -694,6 +697,7 @@
|
||||
Regen All Регенерувати все
|
||||
Region Область
|
||||
Region/Mass Properties Властивості області/маси
|
||||
Reinitialize Переініціалізувати
|
||||
Relative Відносні
|
||||
Relative to last segment Відносно останнього сегмента
|
||||
Remove Leader Видалити виноску
|
||||
|
||||
|
@@ -13,6 +13,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
|
||||
("ui.dock", "Arrange Panel", &[], None),
|
||||
("ui.dock.reset", "Reset Panel Layout", &["Window", "Reset Panel Layout"], None),
|
||||
("ui.open", "Open...", &[], Some("Cmd+O")),
|
||||
("ui.newtemplate", "New from Template...", &["File", "New from Template..."], None),
|
||||
("ui.saveas", "Save As...", &[], None),
|
||||
("ui.sample", "Open Sample Drawing", &["Help", "Open Sample Drawing"], None),
|
||||
("ui.toggle.toolsets", "Tool Sets", &["Window", "Tool Sets"], Some("Cmd+3")),
|
||||
@@ -111,6 +112,13 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
// Pick a template (.dwt or .dxf) and start a new drawing from it.
|
||||
"ui.newtemplate" => {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
return Some(app.session.execute("new", &json!({ "template": p })).map_err(|e| e.to_string()));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
// RECOVER from the menu or typed: pick the file. Without a picker the engine asks for the
|
||||
// file name on the command line.
|
||||
"recover" if no_path && app.services.pick_open.is_some() => {
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ Detail: [file-format-parity.md](file-format-parity.md).
|
||||
| FF6 | **[alpha blocker]** DWG throughput: a 10 MB DWG took ~2 min to fail (#57) | High for real projects | 6–10 |
|
||||
| FF7 | Table record and object handles from other programs' files are renumbered on the first save (entity handles are kept; `*D` blocks no longer duplicate and once reopened, CADCraft's own files keep every handle across saves, #495) | Low | 4–6 |
|
||||
| FF8 | SHX fonts (shape and big fonts) not read | Medium-high (text metrics differ) | 8–12 |
|
||||
| FF9 | Templates (DWT), user `.lin`/`.pat` files, `.ctb`/`.stb`, `.pc3`, `.pgp` | Medium | 20–30 |
|
||||
| FF9 | `.pc3` plotter configuration files; linetype shape (SHX) elements are skipped (templates and `.dwt`, user `.lin`/`.pat` and `.pgp` alias files landed, #552; `.ctb`/`.stb`, #410) | Low-medium | 5–10 |
|
||||
| FF10 | PDF: text as real fonts, layers (OCG), multi-sheet; PDF import and underlays; raster image attach | Medium | 30–50 |
|
||||
| FF11 | DWF/DWFx, DGN, WMF, EPS, ACIS/STL and other exchange formats | Low-medium | 50–100 |
|
||||
| FF12 | DWG in the web build | Medium | 6–10 |
|
||||
|
||||
Reference in new issue
Block a user