mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 02:46:51 -06:00
Compare commits
65
Commits
7bf5905ac9
...
63394e872d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63394e872d | ||
|
|
e59195edb8 | ||
|
|
5e39af933f | ||
|
|
c66f6f45b8 | ||
|
|
b062030a03 | ||
|
|
7aa952b8da | ||
|
|
5cfe1a4149 | ||
|
|
9f2060d674 | ||
|
|
9fc626a33b | ||
|
|
fef32761af | ||
|
|
9562d951df | ||
|
|
f05fa9cec3 | ||
|
|
73e7486dc9 | ||
|
|
fde62a39ab | ||
|
|
7db656fdc7 | ||
|
|
9d9e4c3b0d | ||
|
|
c92bb5cdad | ||
|
|
1c35d7f8c1 | ||
|
|
a94fc0898b | ||
|
|
a0dced6d45 | ||
|
|
532c4ccc1c | ||
|
|
88a73b7618 | ||
|
|
0fc8e5241c | ||
|
|
cff2eb7262 | ||
|
|
ab6bff5e4e | ||
|
|
1a76e10c20 | ||
|
|
941ea341bc | ||
|
|
71882cf5e6 | ||
|
|
64c35cadff | ||
|
|
63dec85749 | ||
|
|
1c3c25035c | ||
|
|
2bc3b02ea1 | ||
|
|
6842b8774b | ||
|
|
78c1653f50 | ||
|
|
fda82da3df | ||
|
|
2c59eb19c5 | ||
|
|
0299a3eaa0 | ||
|
|
1c7d3f0fae | ||
|
|
4f282b30cc | ||
|
|
b9d59c64ab | ||
|
|
4c327cccd0 | ||
|
|
587d1dcd69 | ||
|
|
e04056f714 | ||
|
|
25faf34d28 | ||
|
|
dadd1f227f | ||
|
|
1428174159 | ||
|
|
f686a595e6 | ||
|
|
7d2fbd959a | ||
|
|
829909274a | ||
|
|
f8f13c3f4e | ||
|
|
f119610287 | ||
|
|
620e6a664c | ||
|
|
8dd8635e10 | ||
|
|
4251f1e253 | ||
|
|
9a32aa5613 | ||
|
|
6ee6e652fd | ||
|
|
76f7cea83f | ||
|
|
47e978b58e | ||
|
|
a68d0a458b | ||
|
|
60265a1eee | ||
|
|
20b0c9c11a | ||
|
|
b517e0749d | ||
|
|
5c911ad824 | ||
|
|
15147260c1 | ||
|
|
bbbf4465c4 |
@@ -141,7 +141,7 @@ offset 0.25 then pick the circle and a side
|
||||
zoom e zoom to extents
|
||||
```
|
||||
|
||||
The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://trunkrs.dev)).
|
||||
The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://github.com/trunk-rs/trunk)).
|
||||
|
||||
### Fonts
|
||||
|
||||
@@ -219,6 +219,10 @@ cadcraft-cli convert drawing.dxf drawing.svg
|
||||
cadcraft-cli convert drawing.dwg detail.png --window 0,0,20000,15000
|
||||
cadcraft-cli run drawing.dwg --cmd 'zoom {"mode":"window","p1":[0,0],"p2":[20000,15000]}' --export detail.png
|
||||
cadcraft-cli run drawing.dwg --cmd 'select {"window":[[0,0],[20000,15000]]}' --cmd 'zoom {"mode":"object"}' --export sel.png
|
||||
|
||||
# Act on what an earlier --cmd made: {last} is the last handle it returned.
|
||||
cadcraft-cli run --metric --cmd 'layout.set {"name":"Layout1"}' \
|
||||
--cmd 'mview {"p1":[20,20],"p2":[200,150]}' --cmd 'viewport.set {"handle":"{last}","scale":1}'
|
||||
```
|
||||
|
||||
How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the model-space extents.
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and
|
||||
plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a
|
||||
@@ -119,6 +119,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
|
||||
| Date | Entry |
|
||||
|---|---|
|
||||
| 2026-10-21 | DXF save keeps each proxy object's and proxy entity's class: its class number (group 91) is renumbered to the rewritten CLASSES instead of naming another class, so AutoCAD's visual styles in an R14 file no longer come back as SCALE or MLEADERSTYLE (#1812) |
|
||||
| 2026-10-14 | HATCHEDIT adds boundaries from internal points or closed objects and removes selected loops in the dialog, command line and JSON command; associative links and undo are covered by tests. |
|
||||
| 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) |
|
||||
| 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) |
|
||||
@@ -135,6 +136,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-21 | trivial | Progress log: proxy objects keep their class through a save (#1812) |
|
||||
| 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove |
|
||||
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
|
||||
| 2026-10-12 | trivial | Progress log: HATCHEDIT numeric validation (#724) |
|
||||
|
||||
@@ -28,7 +28,7 @@ macro_rules! outln {
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use cadcraft_engine::doc::Space;
|
||||
use cadcraft_engine::doc::{Handle, Space};
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_engine::{Session, View};
|
||||
use serde_json::{Value, json};
|
||||
@@ -73,7 +73,15 @@ image framing (png 2400x1600, svg, pdf; always model space):
|
||||
scaled to fit the image without distortion.
|
||||
--view extents always fit the drawing extents.
|
||||
--view current the current view (as last set by zoom/pan/view.set; a freshly
|
||||
opened drawing's view is its extents plus a 10% margin).";
|
||||
opened drawing's view is its extents plus a 10% margin).
|
||||
|
||||
--cmd chaining (run):
|
||||
A --cmd parameter value of exactly \"{last}\" is replaced with the last object handle an
|
||||
earlier --cmd in the same run returned, so a command can act on what the previous one made:
|
||||
--cmd 'mview {\"p1\":[20,20],\"p2\":[200,150]}' prints {\"viewports\":[\"100\"]}
|
||||
--cmd 'viewport.set {\"handle\":\"{last}\",\"scale\":1}' changes viewport 100
|
||||
It works in any parameter, at any depth (e.g. \"handles\":[\"{last}\"]); every other string is
|
||||
passed unchanged. With no earlier returned handle the run fails and says so.";
|
||||
|
||||
/// What part of model space an image export shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -314,6 +322,133 @@ fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<Stri
|
||||
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
|
||||
}
|
||||
|
||||
/// A `--cmd` parameter value of exactly `{last}` is filled in with the last object handle an
|
||||
/// earlier `--cmd` in the same run returned: `mview` answers `{"viewports":["100"]}`, so
|
||||
/// `viewport.set {"handle":"{last}","scale":1}` changes viewport 100. Any other string is passed
|
||||
/// through unchanged, and `--script` text is never rewritten.
|
||||
const LAST_HANDLE: &str = "{last}";
|
||||
|
||||
/// How deep [`result_handles`] walks a command result (results are the engine's own JSON).
|
||||
const RESULT_DEPTH: usize = 16;
|
||||
|
||||
/// Result keys whose values are object references: the engine's result convention for handles.
|
||||
/// A handle can be a string (`handle`, `viewport`, `insert`…) or a list of them (`handles`,
|
||||
/// `viewports`, `members`…). Every other field may hold a user-defined name, a message or a
|
||||
/// count, and is never read as a handle — a group named `100` is a name, not object `100`.
|
||||
fn handle_field(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"handle"
|
||||
| "handles"
|
||||
| "viewport"
|
||||
| "viewports"
|
||||
| "boundary"
|
||||
| "clip"
|
||||
| "insert"
|
||||
| "members"
|
||||
| "source"
|
||||
| "objects"
|
||||
| "entities"
|
||||
| "selection"
|
||||
| "parts"
|
||||
| "refs"
|
||||
| "matches"
|
||||
| "line"
|
||||
| "arc"
|
||||
| "blocks"
|
||||
)
|
||||
}
|
||||
|
||||
/// Collect the handles of `v`, a value under a handle field: a handle string, an array of them,
|
||||
/// or objects that carry handle fields of their own (`entities`, `parts`).
|
||||
fn collect_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
|
||||
if depth > RESULT_DEPTH {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::String(t) => {
|
||||
if let Some(h) = Handle::parse_hex(t)
|
||||
&& s.doc().is_ok_and(|d| d.entity(h).is_some())
|
||||
{
|
||||
out.push(h);
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter().for_each(|x| collect_result_handles(s, x, depth + 1, out)),
|
||||
Value::Object(m) => {
|
||||
for (k, x) in m {
|
||||
if handle_field(k) {
|
||||
collect_result_handles(s, x, depth + 1, out);
|
||||
} else {
|
||||
find_result_handles(s, x, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find handle fields nested in containers that are not handles themselves (`groups` in
|
||||
/// `groups.list`, whose members are handles): strings here are names, never references.
|
||||
fn find_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
|
||||
if depth > RESULT_DEPTH {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Array(a) => a.iter().for_each(|x| find_result_handles(s, x, depth + 1, out)),
|
||||
Value::Object(m) => {
|
||||
for (k, x) in m {
|
||||
if handle_field(k) {
|
||||
collect_result_handles(s, x, depth + 1, out);
|
||||
} else {
|
||||
find_result_handles(s, x, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The object handles a command result mentions, in the order it lists them. A handle-only result
|
||||
/// (a handle string or an array of them) counts as it is; otherwise only handle fields do.
|
||||
fn result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
|
||||
match v {
|
||||
Value::String(_) => collect_result_handles(s, v, depth, out),
|
||||
Value::Array(a) if a.iter().all(Value::is_string) => collect_result_handles(s, v, depth, out),
|
||||
Value::Array(_) | Value::Object(_) => find_result_handles(s, v, depth, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The last handle `r` returned: what a later `{last}` stands for.
|
||||
fn returned_handle(s: &Session, r: &Value) -> Option<Handle> {
|
||||
let mut hs = Vec::new();
|
||||
result_handles(s, r, 0, &mut hs);
|
||||
hs.last().copied()
|
||||
}
|
||||
|
||||
/// The active drawing's identity ([`cadcraft_engine::DocState::uid`]): a handle is only meaningful
|
||||
/// in the drawing it came from, and `new`, `open`, `document.switch` and `close` can make another
|
||||
/// drawing current.
|
||||
fn active_drawing(s: &Session) -> Option<u64> {
|
||||
s.docs.get(s.active).map(|st| st.uid)
|
||||
}
|
||||
|
||||
/// Every `{last}` in `p`, at any depth, replaced by `h`. With no handle to fill in it is an
|
||||
/// error, never a token reaching the command as a (bad) handle.
|
||||
fn fill_last(p: &Value, h: Option<Handle>) -> Result<Value, String> {
|
||||
match p {
|
||||
Value::String(t) if t.as_str() == LAST_HANDLE => match h {
|
||||
Some(h) => Ok(Value::String(h.hex())),
|
||||
None => Err(format!("`{LAST_HANDLE}` needs an earlier --cmd in this run to have returned a handle")),
|
||||
},
|
||||
Value::Array(a) => a.iter().map(|x| fill_last(x, h)).collect::<Result<Vec<_>, _>>().map(Value::Array),
|
||||
Value::Object(m) => {
|
||||
m.iter().map(|(k, x)| Ok((k.clone(), fill_last(x, h)?))).collect::<Result<serde_json::Map<String, Value>, _>>().map(Value::Object)
|
||||
}
|
||||
other => Ok(other.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut s = Session::empty();
|
||||
let mut it = args.iter();
|
||||
@@ -349,13 +484,31 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
check_framed_output(framing, exp.as_deref())?;
|
||||
let before = model_views(&s);
|
||||
let mut last_handle: Option<Handle> = None;
|
||||
for (kind, body) in steps {
|
||||
// A handle belongs to the drawing that returned it: if this step makes another drawing
|
||||
// current (`new`, `open`, `document.switch`, `close`, or a script running one of them),
|
||||
// the remembered handle is stale. A handle the step itself returns is taken afterwards,
|
||||
// so it is a handle of the drawing it made current.
|
||||
let drawing = active_drawing(&s);
|
||||
match kind.as_str() {
|
||||
"script" => s.script(&body).map_err(|e| e.to_string())?,
|
||||
"script" => {
|
||||
s.script(&body).map_err(|e| e.to_string())?;
|
||||
if active_drawing(&s) != drawing {
|
||||
last_handle = None;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let (id, json) = body.split_once(' ').unwrap_or((&body, "{}"));
|
||||
let params: Value = serde_json::from_str(json).map_err(|e| format!("--cmd {id}: {e}"))?;
|
||||
let params = fill_last(¶ms, last_handle).map_err(|e| format!("--cmd {id}: {e}"))?;
|
||||
let r = s.execute(id, ¶ms).map_err(|e| e.to_string())?;
|
||||
if active_drawing(&s) != drawing {
|
||||
last_handle = None;
|
||||
}
|
||||
if let Some(h) = returned_handle(&s, &r) {
|
||||
last_handle = Some(h);
|
||||
}
|
||||
if !r.is_null() {
|
||||
outln!("{r}");
|
||||
}
|
||||
@@ -780,6 +933,40 @@ mod tests {
|
||||
assert!(check_framed_output(Framing::Auto, Some("a.dxf")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_placeholder_takes_the_previous_handle() {
|
||||
let mut s = sample_session();
|
||||
let r = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let h = returned_handle(&s, &r).expect("LINE returns its handle");
|
||||
assert_eq!(fill_last(&json!({"handle": "{last}"}), Some(h)), Ok(json!({"handle": h.hex()})));
|
||||
// Nested, as in a selection list.
|
||||
assert_eq!(fill_last(&json!({"handles": ["{last}", "1"]}), Some(h)), Ok(json!({"handles": [h.hex(), "1"]})));
|
||||
// Only the exact token: a name or a sentence that merely contains it stays as written.
|
||||
for p in [json!("last"), json!("{last} "), json!({"name": "last"})] {
|
||||
assert_eq!(fill_last(&p, Some(h)), Ok(p.clone()));
|
||||
}
|
||||
// Nothing returned yet: an error, not a token passed to the command as a bad handle.
|
||||
assert!(fill_last(&json!({"handle": "{last}"}), None).unwrap_err().contains("{last}"));
|
||||
}
|
||||
|
||||
/// Handles are read from a result by looking the strings up in the drawing: counts and names
|
||||
/// are not mistaken for handles, and `mview`'s `viewports` works like `handle`/`handles`.
|
||||
#[test]
|
||||
fn returned_handle_reads_only_real_handles() {
|
||||
let mut s = Session::empty();
|
||||
s.new_drawing(false);
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
let r = s.execute("mview", &json!({"p1": [20, 20], "p2": [200, 150]})).unwrap();
|
||||
let h = returned_handle(&s, &r).expect("mview returns its viewport handle");
|
||||
assert!(r["viewports"].as_array().is_some_and(|a| a.iter().any(|v| v.as_str() == Some(h.hex().as_str()))), "{r}");
|
||||
assert_eq!(returned_handle(&s, &json!({"changed": 3, "name": "A1"})), None);
|
||||
// The most recent of several.
|
||||
let a = s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
|
||||
let b = s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap();
|
||||
let (ha, hb) = (returned_handle(&s, &a).unwrap(), returned_handle(&s, &b).unwrap());
|
||||
assert_eq!(returned_handle(&s, &json!({"handles": [ha.hex(), hb.hex()]})), Some(hb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_framing_is_extents() {
|
||||
// convert: nothing ran, always the extents.
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! A `--cmd` can act on a handle an earlier `--cmd` in the same run returned (#1663).
|
||||
//!
|
||||
//! `mview` answers `{"viewports":["100"]}`, but a following `viewport.set` could not use that
|
||||
//! handle: it had to be guessed, selected by point, or activated with `mspace`. A `--cmd` parameter
|
||||
//! value of exactly `{last}` is now filled in with the last handle an earlier `--cmd` returned.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli"))
|
||||
.args(args)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.output()
|
||||
.expect("run cadcraft-cli")
|
||||
}
|
||||
|
||||
/// The issue's sequence: `viewport.set` straight after `mview`, addressing the new viewport by the
|
||||
/// handle the runner remembers.
|
||||
#[test]
|
||||
fn viewport_set_after_mview_uses_the_returned_handle() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"layout.set {"name":"Layout1"}"#,
|
||||
"--cmd",
|
||||
r#"mview {"p1":[20,20],"p2":[200,150]}"#,
|
||||
"--cmd",
|
||||
r#"viewport.set {"handle":"{last}","scale":1}"#,
|
||||
]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
|
||||
let mview = stdout.lines().find(|l| l.contains("\"viewports\"")).unwrap_or_else(|| panic!("no mview result in {stdout:?}"));
|
||||
let created: serde_json::Value = serde_json::from_str(mview).expect("mview result is JSON");
|
||||
let handle = created["viewports"].as_array().and_then(|a| a.first()).and_then(|h| h.as_str()).expect("a viewport handle");
|
||||
assert!(handle.eq_ignore_ascii_case("100"), "{mview}");
|
||||
let set = stdout.lines().find(|l| l.contains("\"viewHeight\"")).unwrap_or_else(|| panic!("no viewport.set result in {stdout:?}"));
|
||||
let vp: serde_json::Value = serde_json::from_str(set).expect("viewport.set result is JSON");
|
||||
assert_eq!(vp[0]["handle"].as_str(), Some(handle), "viewport.set changed the viewport `{handle}`: {set}");
|
||||
assert_eq!(vp[0]["scale"].as_f64(), Some(1.0), "{set}");
|
||||
}
|
||||
|
||||
/// `{last}` with no handle to fill in is a clear error, not the token reaching the command.
|
||||
#[test]
|
||||
fn last_without_an_earlier_handle_is_an_error() {
|
||||
let out = run(&["run", "--metric", "--cmd", r#"viewport.set {"handle":"{last}","scale":1}"#]);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "stdout: {:?}\nstderr: {stderr}", String::from_utf8_lossy(&out.stdout));
|
||||
assert!(stderr.contains("{last}"), "the error names the placeholder: {stderr}");
|
||||
assert!(!stderr.contains("panicked"), "{stderr}");
|
||||
}
|
||||
|
||||
/// The last JSON value a run printed: command results are one JSON value a line (`null` is not
|
||||
/// printed).
|
||||
fn last_json(stdout: &str) -> serde_json::Value {
|
||||
let line = stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| {
|
||||
let t = l.trim_start();
|
||||
t.starts_with('{') || t.starts_with('[')
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no JSON result in {stdout:?}"));
|
||||
serde_json::from_str(line).expect("command result is JSON")
|
||||
}
|
||||
|
||||
/// `{last}` was not replaced: the error names the placeholder, and the run fails cleanly.
|
||||
fn assert_needs_an_earlier_handle(out: &Output) {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "stdout: {:?}\nstderr: {stderr}", String::from_utf8_lossy(&out.stdout));
|
||||
assert!(stderr.contains("{last}"), "the error names the placeholder: {stderr}");
|
||||
assert!(!stderr.contains("panicked"), "{stderr}");
|
||||
}
|
||||
|
||||
/// A user-defined name that happens to look like a handle is not a returned handle. `group`
|
||||
/// answers `{"name":"100","members":1}`: `100` is the first line's handle, but the name is not an
|
||||
/// object reference and must not replace the last handle the `line` command returned (`101`).
|
||||
#[test]
|
||||
fn a_group_name_that_looks_like_a_handle_is_not_last() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,10],[10,10]]}"#,
|
||||
"--cmd",
|
||||
r#"group {"name":"100","handles":["101"]}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
"--cmd",
|
||||
r#"entities {"type":"LINE"}"#,
|
||||
]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
|
||||
let r = last_json(&stdout);
|
||||
assert_eq!(r["count"].as_u64(), Some(1), "one line is left: {stdout}");
|
||||
let left: Vec<&str> = r["entities"].as_array().into_iter().flatten().filter_map(|e| e["handle"].as_str()).collect();
|
||||
assert_eq!(left, ["100"], "the group name 100 did not replace the handle 101 the line command returned: {stdout}");
|
||||
}
|
||||
|
||||
/// `new` makes another drawing current: the handle an earlier `--cmd` returned in the old drawing
|
||||
/// is not a handle of the new one.
|
||||
#[test]
|
||||
fn last_is_cleared_when_new_switches_drawings() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--cmd",
|
||||
r#"new {"metric":true}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
]);
|
||||
assert_needs_an_earlier_handle(&out);
|
||||
}
|
||||
|
||||
/// `open` makes the opened file the current drawing: a handle from the drawing before it is stale.
|
||||
#[test]
|
||||
fn last_is_cleared_when_open_switches_drawings() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-last-open-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let dxf = dir.join("opened.dxf");
|
||||
let sample = run(&["sample", "bracket", dxf.to_str().expect("temp path")]);
|
||||
assert!(sample.status.success(), "sample failed: {}", String::from_utf8_lossy(&sample.stderr));
|
||||
let open = format!(r#"open {{"path":{}}}"#, serde_json::to_string(dxf.to_str().expect("temp path")).expect("path JSON"));
|
||||
let out = run(&["run", "--metric", "--cmd", r#"line {"points":[[0,0],[10,0]]}"#, "--cmd", &open, "--cmd", r#"erase {"handles":["{last}"]}"#]);
|
||||
assert_needs_an_earlier_handle(&out);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `document.switch` changes the current drawing without adding one: a handle remembered from the
|
||||
/// drawing left behind must not be used in the one switched to.
|
||||
#[test]
|
||||
fn last_is_cleared_when_document_switch_changes_drawings() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--cmd",
|
||||
r#"new {"metric":true}"#,
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,10],[10,10]]}"#,
|
||||
"--cmd",
|
||||
r#"document.switch {"index":0}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
]);
|
||||
assert_needs_an_earlier_handle(&out);
|
||||
}
|
||||
|
||||
/// A handle a command returns after a switch belongs to the drawing now current, so it is kept.
|
||||
#[test]
|
||||
fn a_handle_returned_after_a_switch_is_last() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--cmd",
|
||||
r#"new {"metric":true}"#,
|
||||
"--cmd",
|
||||
r#"document.switch {"index":0}"#,
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,10],[10,10]]}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
"--cmd",
|
||||
r#"entities {"type":"LINE"}"#,
|
||||
]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
|
||||
let r = last_json(&stdout);
|
||||
assert_eq!(r["count"].as_u64(), Some(1), "one line is left: {stdout}");
|
||||
let left: Vec<&str> = r["entities"].as_array().into_iter().flatten().filter_map(|e| e["handle"].as_str()).collect();
|
||||
assert_eq!(left, ["100"], "the line created after the switch was erased: {stdout}");
|
||||
}
|
||||
|
||||
/// A script can change the current drawing too (`new {"metric":true}` at a script line), so the
|
||||
/// remembered handle is cleared after a script that changed it.
|
||||
#[test]
|
||||
fn last_is_cleared_by_a_script_that_changes_drawings() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--script",
|
||||
r#"new {"metric":true}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
]);
|
||||
assert_needs_an_earlier_handle(&out);
|
||||
}
|
||||
|
||||
/// Only the exact token is a placeholder: ordinary JSON strings are left alone.
|
||||
#[test]
|
||||
fn ordinary_values_are_not_placeholders() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-last-placeholder-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let out_dxf = dir.join("text.dxf");
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"layout.set {"name":"Layout1"}"#,
|
||||
"--cmd",
|
||||
r#"mview {"p1":[20,20],"p2":[200,150]}"#,
|
||||
"--cmd",
|
||||
r#"text {"at":[0,0],"height":2,"text":"last and {last}"}"#,
|
||||
"--save",
|
||||
out_dxf.to_str().expect("temp path"),
|
||||
]);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "exit {:?}\nstderr: {stderr}", out.status);
|
||||
let saved = std::fs::read_to_string(&out_dxf).expect("saved DXF");
|
||||
assert!(saved.contains("last and {last}"), "the text is stored literally");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -24,16 +24,29 @@ mod web {
|
||||
const LOADING_ID: &str = "cadcraft_loading";
|
||||
|
||||
/// The app, the files File > Open… and drag and drop read for it in the background, the
|
||||
/// page embedding it (`?host=parent`), and the browser's `prefers-reduced-motion` (it stays
|
||||
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change).
|
||||
struct Shell(CadApp, crate::upload::Inbox, Option<(crate::host::Bridge, crate::host::DirtyReport)>, Option<web_sys::MediaQueryList>);
|
||||
/// page embedding it (`?host=parent`), the browser's `prefers-reduced-motion` (it stays
|
||||
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change),
|
||||
/// and the document title last written to the page.
|
||||
struct Shell(
|
||||
CadApp,
|
||||
crate::upload::Inbox,
|
||||
Option<(crate::host::Bridge, crate::host::DirtyReport)>,
|
||||
Option<web_sys::MediaQueryList>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
impl eframe::App for Shell {
|
||||
/// Runs one frame: takes in files dropped on or picked for the page, drains the background
|
||||
/// uploads, follows the browser's reduced-motion setting, runs the app, writes the browser
|
||||
/// document title when it changed (so the tab and an embedding page name the open
|
||||
/// drawing), and reports unsaved changes to that page.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.1.take_dropped(ctx);
|
||||
self.1.drain(&mut self.0);
|
||||
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
|
||||
self.0.logic(ctx);
|
||||
let drawing = self.0.session.state().ok();
|
||||
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
|
||||
if let Some((bridge, reported)) = &self.2 {
|
||||
bridge.report_dirty(&self.0, reported);
|
||||
}
|
||||
@@ -58,6 +71,16 @@ mod web {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the browser's document title, which names the tab and is what an embedding page
|
||||
/// (`?host=parent`) reads. eframe's web runner ignores [`egui::ViewportCommand::Title`], so
|
||||
/// the title has to reach the DOM directly.
|
||||
fn set_page_title(title: &str) {
|
||||
match web_sys::window().and_then(|window| window.document()) {
|
||||
Some(document) => document.set_title(title),
|
||||
None => log::warn!("cannot set the page title to {title:?}: there is no document"),
|
||||
}
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
@@ -67,9 +90,11 @@ mod web {
|
||||
}
|
||||
|
||||
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
|
||||
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name.
|
||||
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name. There is
|
||||
/// no file system either, so a dropped file is opened from its contents and APPLOAD says to
|
||||
/// drop a `.lsp` file rather than asking for a name it cannot read.
|
||||
fn services() -> Services {
|
||||
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), ..Services::default() }
|
||||
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), no_file_system: true, ..Services::default() }
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
@@ -152,7 +177,7 @@ mod web {
|
||||
(bridge, crate::host::DirtyReport::default())
|
||||
});
|
||||
let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
|
||||
Ok(Box::new(Shell(app, inbox, host, reduced_motion)))
|
||||
Ok(Box::new(Shell(app, inbox, host, reduced_motion, None)))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -177,6 +202,44 @@ fn wants_cpu(query: &str) -> bool {
|
||||
query.trim_start_matches('?').split('&').any(|p| p == "cpu" || p.starts_with("cpu="))
|
||||
}
|
||||
|
||||
/// The browser tab title: the open drawing's file name, a bullet once it has unsaved changes,
|
||||
/// and the app name (`drawing.dwg • — CADCraft`), or just the app name with no drawing. An
|
||||
/// embedding page (`?host=parent`) reads it to tell which drawing opened and whether it is dirty.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn document_title(name: Option<&str>, dirty: bool) -> String {
|
||||
match name.filter(|n| !n.trim().is_empty()) {
|
||||
Some(name) => format!("{name}{} — CADCraft", if dirty { " •" } else { "" }),
|
||||
None => "CADCraft".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The title to write this frame, or `None` while the page already shows it: the browser needs
|
||||
/// the document title only when it changes, not on every frame.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn title_to_send(sent: &mut Option<String>, name: Option<&str>, dirty: bool) -> Option<String> {
|
||||
let title = document_title(name, dirty);
|
||||
if sent.as_deref() == Some(title.as_str()) {
|
||||
return None;
|
||||
}
|
||||
*sent = Some(title.clone());
|
||||
Some(title)
|
||||
}
|
||||
|
||||
/// Writes a changed title through `write` (on the web, the browser's document title) and reports
|
||||
/// whether it wrote one. Split from [`title_to_send`] so the change decision and the write are
|
||||
/// both testable: eframe's web backend ignores `egui::ViewportCommand::Title`, so a title that is
|
||||
/// computed but not written leaves the tab on `CADCraft`.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn push_title(sent: &mut Option<String>, name: Option<&str>, dirty: bool, write: impl FnOnce(&str)) -> bool {
|
||||
match title_to_send(sent, name, dirty) {
|
||||
Some(title) => {
|
||||
write(&title);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod upload;
|
||||
|
||||
@@ -192,8 +255,10 @@ fn main() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::wants_cpu;
|
||||
use super::{document_title, push_title, title_to_send, wants_cpu};
|
||||
|
||||
/// Guards the `?cpu` URL flag: only a whole `cpu` query parameter (bare or with a value)
|
||||
/// selects the CPU canvas, so `?sample` and `?gpucpu` stay on the GPU path.
|
||||
#[test]
|
||||
fn cpu_flag_is_detected() {
|
||||
assert!(wants_cpu("?cpu"));
|
||||
@@ -204,4 +269,52 @@ mod tests {
|
||||
assert!(!wants_cpu("?webgl&sample"));
|
||||
assert!(!wants_cpu("?gpucpu"));
|
||||
}
|
||||
|
||||
/// Guards the tab title text: a drawing contributes its name and, when dirty, a trailing
|
||||
/// bullet; a blank or missing name leaves the app name alone and never shows a bullet.
|
||||
#[test]
|
||||
fn viewport_title_names_the_document_and_marks_unsaved_changes() {
|
||||
assert_eq!(document_title(Some("drawing.dwg"), false), "drawing.dwg — CADCraft");
|
||||
assert_eq!(document_title(Some("drawing.dwg"), true), "drawing.dwg • — CADCraft");
|
||||
assert_eq!(document_title(Some("Drawing1"), false), "Drawing1 — CADCraft");
|
||||
assert_eq!(document_title(None, false), "CADCraft");
|
||||
assert_eq!(document_title(None, true), "CADCraft");
|
||||
assert_eq!(document_title(Some(" "), true), "CADCraft");
|
||||
}
|
||||
|
||||
/// Guards the change signal: a title is sent on open, rename, dirty change and close, but
|
||||
/// never twice in a row, so an idle tab is not repainted by a repeated title command.
|
||||
#[test]
|
||||
fn viewport_title_follows_open_rename_and_dirty_changes_only() {
|
||||
let mut sent = None;
|
||||
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), false).as_deref(), Some("drawing.dwg — CADCraft"));
|
||||
// The page already has it: send nothing.
|
||||
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), false), None);
|
||||
// Unsaved changes add the bullet.
|
||||
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), true).as_deref(), Some("drawing.dwg • — CADCraft"));
|
||||
// A rename shows the new name.
|
||||
assert_eq!(title_to_send(&mut sent, Some("plan.dwg"), true).as_deref(), Some("plan.dwg • — CADCraft"));
|
||||
// Saving clears it again.
|
||||
assert_eq!(title_to_send(&mut sent, Some("plan.dwg"), false).as_deref(), Some("plan.dwg — CADCraft"));
|
||||
// Closing the last drawing leaves the app name only.
|
||||
assert_eq!(title_to_send(&mut sent, None, false).as_deref(), Some("CADCraft"));
|
||||
assert_eq!(title_to_send(&mut sent, None, false), None);
|
||||
}
|
||||
|
||||
/// Guards the title write: every changed title reaches the writer (on the web, the browser
|
||||
/// document title) exactly once, and an unchanged one is not written again. Without the write
|
||||
/// the tab and an embedding page stay on `CADCraft` however the drawing changes.
|
||||
#[test]
|
||||
fn changed_title_reaches_the_document_title_writer_once() {
|
||||
let mut sent = None;
|
||||
let mut written: Vec<String> = Vec::new();
|
||||
assert!(push_title(&mut sent, Some("drawing.dwg"), false, |title| written.push(title.to_owned())));
|
||||
// Unchanged: nothing is written again.
|
||||
assert!(!push_title(&mut sent, Some("drawing.dwg"), false, |title| written.push(title.to_owned())));
|
||||
// Unsaved changes are written.
|
||||
assert!(push_title(&mut sent, Some("drawing.dwg"), true, |title| written.push(title.to_owned())));
|
||||
// Closing the drawing writes the app name.
|
||||
assert!(push_title(&mut sent, None, false, |title| written.push(title.to_owned())));
|
||||
assert_eq!(written, ["drawing.dwg — CADCraft", "drawing.dwg • — CADCraft", "CADCraft"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,13 @@ fn services() -> Services {
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
|
||||
pick_lisp: Some(Box::new(|dir: &str| {
|
||||
let dialog = rfd::FileDialog::new().add_filter(i18n::t("AutoLISP (LSP)"), &["lsp"]).add_filter(i18n::t("All files"), &["*"]);
|
||||
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
|
||||
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
no_file_system: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +364,9 @@ fn main() -> eframe::Result {
|
||||
app.open_path(f);
|
||||
}
|
||||
if app.session.docs.is_empty() {
|
||||
app.session.new_drawing(false);
|
||||
// In the units the profile asks for (MEASUREINIT, restored above).
|
||||
let metric = app.session.settings.measureinit;
|
||||
app.session.new_drawing(metric);
|
||||
}
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
|
||||
|
||||
@@ -107,6 +107,23 @@ impl Filter {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
fn update_drift(&mut self, complete: bool, now: Instant, bias: &mut [f64; 6]) {
|
||||
if !complete {
|
||||
return;
|
||||
}
|
||||
if self.raw.iter().zip(*bias).all(|(raw, b)| (*raw - b).abs() <= FULL_SCALE * DEADZONE) {
|
||||
let since = *self.idle_since.get_or_insert(now);
|
||||
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
|
||||
for (b, raw) in bias.iter_mut().zip(self.raw) {
|
||||
*b += (raw - *b) * 0.01;
|
||||
}
|
||||
self.bias = Some(*bias);
|
||||
}
|
||||
} else {
|
||||
self.idle_since = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, values: [i16; 6], mask: u8, now: Instant) -> Motion {
|
||||
for (index, seen) in self.seen.iter_mut().enumerate() {
|
||||
if mask & (1 << index) != 0 {
|
||||
@@ -128,19 +145,7 @@ impl Filter {
|
||||
}
|
||||
let Some(mut bias) = self.bias else { return Motion::default() };
|
||||
// Follow small resting drift only after two seconds entirely inside the deadzone.
|
||||
if complete {
|
||||
if self.raw.iter().zip(bias).all(|(raw, bias)| (*raw - bias).abs() <= FULL_SCALE * DEADZONE) {
|
||||
let since = *self.idle_since.get_or_insert(now);
|
||||
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
|
||||
for (bias, raw) in bias.iter_mut().zip(self.raw) {
|
||||
*bias += (raw - *bias) * 0.01;
|
||||
}
|
||||
self.bias = Some(bias);
|
||||
}
|
||||
} else {
|
||||
self.idle_since = None;
|
||||
}
|
||||
}
|
||||
self.update_drift(complete, now, &mut bias);
|
||||
let mut axes = [0.0; 6];
|
||||
for ((axis, raw), bias) in axes.iter_mut().zip(self.raw).zip(bias) {
|
||||
let value = ((raw - bias) / FULL_SCALE).clamp(-1.0, 1.0);
|
||||
@@ -156,3 +161,77 @@ impl Filter {
|
||||
Motion { pan: [tx, ty], zoom: (tz + rz).clamp(-1.0, 1.0) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decode_reports() {
|
||||
assert_eq!(decode(&[]), None);
|
||||
assert_eq!(decode(&[3]), None);
|
||||
assert_eq!(decode(&[3, 5]), Some(Report::Buttons(5)));
|
||||
let values = [1_i16, -2, 3, 4, -5, 6];
|
||||
let mut bytes = vec![1_u8];
|
||||
for v in values {
|
||||
bytes.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
assert_eq!(decode(&bytes), Some(Report::Axes { values, mask: 3 }));
|
||||
bytes[0] = 2;
|
||||
assert_eq!(decode(&bytes), Some(Report::Axes { values: [0, 0, 0, 1, -2, 3], mask: 2 }));
|
||||
assert_eq!(decode(&[2, 1, 2, 3]), None, "too short for a rotation report");
|
||||
}
|
||||
|
||||
/// Sixteen complete reports at rest, spread over more than CALIBRATION_TIME, all stamped
|
||||
/// on a made-up point in time big enough that they sit in the past of later updates.
|
||||
fn calibrated(t0: Instant) -> Filter {
|
||||
let mut f = Filter::default();
|
||||
for i in 0..17 {
|
||||
let at = t0 + Duration::from_millis(50 * i);
|
||||
assert_eq!(f.update([0; 6], 1, at), Motion::default());
|
||||
assert_eq!(f.update([0; 6], 2, at), Motion::default());
|
||||
}
|
||||
assert!(f.calibrated());
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axes_deflect_and_expire() {
|
||||
let t0 = Instant::now();
|
||||
let mut f = calibrated(t0);
|
||||
let at = t0 + Duration::from_secs(2);
|
||||
let ms = Duration::from_millis;
|
||||
let m = f.update([500, 500, 0, 0, 0, 0], 1, at);
|
||||
assert!((m.pan[0] - 1.0).abs() < 1e-9);
|
||||
assert!((m.pan[1] - 1.0).abs() < 1e-9);
|
||||
assert_eq!(m.zoom, 0.0, "no rotation input");
|
||||
// A fresh rotation report drives the zoom.
|
||||
let m = f.update([0, 0, 0, 0, 0, 500], 2, at + ms(100));
|
||||
assert!((m.zoom - 1.0).abs() < 1e-9);
|
||||
// The rotation axes went quiet for over 250 ms while translations still arrive.
|
||||
let m = f.update([500, 500, 0, 0, 0, 0], 1, at + ms(400));
|
||||
assert_eq!(m.zoom, 0.0);
|
||||
// A fresh rotation report puts the zoom back.
|
||||
let m = f.update([500, 500, 0, 0, 0, 500], 2, at + ms(500));
|
||||
assert!((m.zoom - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drift_tracks_rest_after_two_idle_seconds() {
|
||||
// Five axes units of drift sit inside the deadzone (FULL_SCALE * DEADZONE = 20).
|
||||
let mut f = Filter { raw: [5.0; 6], bias: Some([0.0; 6]), ..Filter::default() };
|
||||
let t0 = Instant::now();
|
||||
let mut b = [0.0; 6];
|
||||
f.update_drift(true, t0, &mut b); // starts the idle window: no shift yet
|
||||
assert_eq!(b, [0.0; 6]);
|
||||
f.update_drift(true, t0 + Duration::from_secs(3), &mut b);
|
||||
assert!((b[0] - 0.05).abs() < 1e-12, "bias follows resting drift");
|
||||
assert_eq!(f.bias, Some(b));
|
||||
// Motion outside the deadzone resets the idle window.
|
||||
f.raw = [100.0; 6];
|
||||
f.update_drift(true, t0 + Duration::from_secs(4), &mut b);
|
||||
assert_eq!(f.idle_since, None);
|
||||
f.update_drift(true, t0 + Duration::from_secs(5), &mut b);
|
||||
assert!((b[0] - 0.05).abs() < 1e-12, "no shift before two seconds at rest");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,3 +73,17 @@ sync_github_name = false
|
||||
|
||||
[people.gurparvez]
|
||||
real_name = "Gurparvez"
|
||||
|
||||
[people.nicholasgower]
|
||||
sync_github_name = true
|
||||
|
||||
[people.ffishman]
|
||||
real_name = "Rodrigo Gressler"
|
||||
display_name = "Rodrigo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.aussieboer61]
|
||||
real_name = "Richard Sauer"
|
||||
display_name = "Richard Sauer"
|
||||
sync_github_name = false
|
||||
|
||||
|
||||
+43
-8
@@ -21,7 +21,8 @@ impl Rgb {
|
||||
format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
|
||||
}
|
||||
pub fn parse_hex(s: &str) -> Option<Rgb> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix('#').unwrap_or(s);
|
||||
if s.len() != 6 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
@@ -110,9 +111,9 @@ impl Color {
|
||||
_ => {}
|
||||
}
|
||||
if let Some(rest) = t.strip_prefix("color ") {
|
||||
return rest.trim().parse::<u8>().ok().filter(|i| *i >= 1).map(Color::Index);
|
||||
return parse_digits::<u8>(rest.trim()).filter(|i| *i >= 1).map(Color::Index);
|
||||
}
|
||||
if let Ok(i) = t.parse::<u16>() {
|
||||
if let Some(i) = parse_digits::<u16>(&t) {
|
||||
return match i {
|
||||
0 => Some(Color::ByBlock),
|
||||
256 => Some(Color::ByLayer),
|
||||
@@ -123,14 +124,22 @@ impl Color {
|
||||
if t.starts_with('#') {
|
||||
return Rgb::parse_hex(&t).map(Color::True);
|
||||
}
|
||||
let parts: Vec<_> = t.split(',').map(|p| p.trim().parse::<u8>()).collect();
|
||||
if let [Ok(r), Ok(g), Ok(b)] = parts.as_slice() {
|
||||
let parts: Vec<_> = t.split(',').map(|p| parse_digits::<u8>(p.trim())).collect();
|
||||
if let [Some(r), Some(g), Some(b)] = parts.as_slice() {
|
||||
return Some(Color::True(Rgb(*r, *g, *b)));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an unsigned integer made of ASCII digits only (no sign, unlike `str::parse`).
|
||||
fn parse_digits<T: std::str::FromStr>(s: &str) -> Option<T> {
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
|
||||
/// The RGB of ACI `i` (0 and out-of-range map to white/7).
|
||||
pub fn aci_rgb(i: u8) -> Rgb {
|
||||
PALETTE.get(usize::from(i)).copied().unwrap_or(Rgb(255, 255, 255))
|
||||
@@ -156,7 +165,7 @@ fn build_palette() -> [Rgb; 256] {
|
||||
for (slot, c) in p.iter_mut().zip(base) {
|
||||
*slot = c;
|
||||
}
|
||||
let values = [255.0, 165.0, 127.0, 76.0, 38.0];
|
||||
let values = [255.0, 204.0, 153.0, 127.0, 76.0];
|
||||
for i in 10..250usize {
|
||||
let hue = (i - 10) / 10; // 0..24, 15° each
|
||||
let sub = i % 10;
|
||||
@@ -233,13 +242,39 @@ mod tests {
|
||||
assert_eq!(Rgb::parse_hex("#012345"), Some(Rgb(1, 35, 69)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_plus_sign() {
|
||||
assert_eq!(Color::parse("+5"), None);
|
||||
assert_eq!(Color::parse(" +256"), None);
|
||||
assert_eq!(Color::parse("color +5"), None);
|
||||
assert_eq!(Color::parse("+1,+2,+3"), None);
|
||||
assert_eq!(Color::parse("1,+2,3"), None);
|
||||
assert_eq!(Color::parse("5"), Some(Color::Index(5)));
|
||||
assert_eq!(Color::parse("256"), Some(Color::ByLayer));
|
||||
assert_eq!(Color::parse("color 5"), Some(Color::Index(5)));
|
||||
assert_eq!(Color::parse("1, 2, 3"), Some(Color::True(Rgb(1, 2, 3))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hex_rejects_repeated_hash() {
|
||||
assert_eq!(Rgb::parse_hex("##ff0000"), None);
|
||||
assert_eq!(Rgb::parse_hex("###ff0000"), None);
|
||||
assert_eq!(Color::parse("##ff0000"), None);
|
||||
assert_eq!(Rgb::parse_hex("#ff0000"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(Rgb::parse_hex("ff0000"), Some(Rgb(255, 0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_known_entries() {
|
||||
assert_eq!(aci_rgb(1), Rgb(255, 0, 0));
|
||||
assert_eq!(aci_rgb(10), Rgb(255, 0, 0));
|
||||
assert_eq!(aci_rgb(11), Rgb(255, 127, 127));
|
||||
assert_eq!(aci_rgb(12), Rgb(165, 0, 0));
|
||||
assert_eq!(aci_rgb(13), Rgb(165, 82, 82));
|
||||
assert_eq!(aci_rgb(12), Rgb(204, 0, 0));
|
||||
assert_eq!(aci_rgb(13), Rgb(204, 102, 102));
|
||||
assert_eq!(aci_rgb(14), Rgb(153, 0, 0));
|
||||
assert_eq!(aci_rgb(15), Rgb(153, 76, 76));
|
||||
assert_eq!(aci_rgb(16), Rgb(127, 0, 0));
|
||||
assert_eq!(aci_rgb(18), Rgb(76, 0, 0));
|
||||
assert_eq!(aci_rgb(20), Rgb(255, 63, 0));
|
||||
assert_eq!(aci_rgb(30), Rgb(255, 127, 0));
|
||||
assert_eq!(aci_rgb(50), Rgb(255, 255, 0));
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
/// Longest expression accepted (characters).
|
||||
pub const MAX_LEN: usize = 1024;
|
||||
/// Deepest nesting (parentheses, unary minus) and parameter reference chain.
|
||||
/// Deepest syntax tree and combined expression/parameter evaluation path.
|
||||
pub const MAX_DEPTH: usize = 64;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
@@ -49,7 +49,7 @@ struct Parser<'a> {
|
||||
|
||||
impl Parser<'_> {
|
||||
fn ws(&mut self) {
|
||||
while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) {
|
||||
while self.s.get(self.i).is_some_and(u8::is_ascii_whitespace) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,20 @@ pub fn parse(text: &str) -> Result<Expr, ExprError> {
|
||||
if p.peek().is_some() {
|
||||
return Err(ExprError::Syntax(format!("unexpected `{}`", text.get(p.i..).unwrap_or("").trim())));
|
||||
}
|
||||
let mut pending = vec![(&e, 1)];
|
||||
while let Some((node, depth)) = pending.pop() {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(ExprError::TooDeep);
|
||||
}
|
||||
match node {
|
||||
Expr::Neg(a) => pending.push((a, depth + 1)),
|
||||
Expr::Bin(_, a, b) => {
|
||||
pending.push((a, depth + 1));
|
||||
pending.push((b, depth + 1));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
@@ -224,12 +238,19 @@ pub fn rename(text: &str, old: &str, new: &str) -> String {
|
||||
|
||||
/// Evaluate with a variable lookup.
|
||||
pub fn eval_with(e: &Expr, lookup: &mut dyn FnMut(&str) -> Result<f64, ExprError>) -> Result<f64, ExprError> {
|
||||
eval_at_depth(e, &mut |n, _| lookup(n), 0)
|
||||
}
|
||||
|
||||
fn eval_at_depth(e: &Expr, lookup: &mut dyn FnMut(&str, usize) -> Result<f64, ExprError>, depth: usize) -> Result<f64, ExprError> {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err(ExprError::TooDeep);
|
||||
}
|
||||
let v = match e {
|
||||
Expr::Num(v) => *v,
|
||||
Expr::Var(n) => lookup(n)?,
|
||||
Expr::Neg(a) => -eval_with(a, lookup)?,
|
||||
Expr::Var(n) => lookup(n, depth + 1)?,
|
||||
Expr::Neg(a) => -eval_at_depth(a, lookup, depth + 1)?,
|
||||
Expr::Bin(op, a, b) => {
|
||||
let (x, y) = (eval_with(a, lookup)?, eval_with(b, lookup)?);
|
||||
let (x, y) = (eval_at_depth(a, lookup, depth + 1)?, eval_at_depth(b, lookup, depth + 1)?);
|
||||
match op {
|
||||
'+' => x + y,
|
||||
'-' => x - y,
|
||||
@@ -273,9 +294,12 @@ impl Env {
|
||||
/// Value of a named parameter.
|
||||
pub fn value(&mut self, name: &str) -> Result<f64, ExprError> {
|
||||
let mut stack = Vec::new();
|
||||
self.value_inner(name, &mut stack)
|
||||
self.value_inner(name, &mut stack, 0)
|
||||
}
|
||||
fn value_inner(&mut self, name: &str, stack: &mut Vec<String>) -> Result<f64, ExprError> {
|
||||
fn value_inner(&mut self, name: &str, stack: &mut Vec<String>, depth: usize) -> Result<f64, ExprError> {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err(ExprError::TooDeep);
|
||||
}
|
||||
if let Some(v) = self.cache.get(name) {
|
||||
return Ok(*v);
|
||||
}
|
||||
@@ -288,7 +312,7 @@ impl Env {
|
||||
let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?;
|
||||
let e = parse(&text)?;
|
||||
stack.push(name.to_string());
|
||||
let r = eval_with(&e, &mut |n| self.value_inner(n, stack));
|
||||
let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth);
|
||||
stack.pop();
|
||||
let v = r?;
|
||||
self.cache.insert(name.to_string(), v);
|
||||
@@ -298,7 +322,7 @@ impl Env {
|
||||
pub fn eval(&mut self, text: &str) -> Result<f64, ExprError> {
|
||||
let e = parse(text)?;
|
||||
let mut stack = Vec::new();
|
||||
eval_with(&e, &mut |n| self.value_inner(n, &mut stack))
|
||||
eval_at_depth(&e, &mut |n, depth| self.value_inner(n, &mut stack, depth), 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +375,31 @@ mod tests {
|
||||
assert!(chain.value("p0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expression_and_parameter_depth_share_a_limit_on_a_small_stack() {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(1024 * 1024)
|
||||
.spawn(|| {
|
||||
for op in ["+", "-", "*", "/"] {
|
||||
let within = vec!["1"; MAX_DEPTH].join(op);
|
||||
assert!(eval_const(&within).is_ok());
|
||||
assert_eq!(parse(&format!("{within}{op}1")), Err(ExprError::TooDeep));
|
||||
}
|
||||
let mut env = Env::new();
|
||||
for i in 0..MAX_DEPTH {
|
||||
env.insert(&format!("p{i}"), &format!("p{}+{}", i + 1, vec!["0"; 30].join("+")));
|
||||
}
|
||||
env.insert(&format!("p{MAX_DEPTH}"), "1");
|
||||
assert_eq!(env.value("p0"), Err(ExprError::TooDeep));
|
||||
assert_eq!(env.eval("p0+1"), Err(ExprError::TooDeep));
|
||||
env.insert("safe", "2*3");
|
||||
assert_eq!(env.eval("safe+1"), Ok(7.0));
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_replaces_whole_names_only() {
|
||||
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
|
||||
|
||||
@@ -208,7 +208,7 @@ impl Default for ParametricSettings {
|
||||
angle_tolerance: 1.0,
|
||||
auto_types: ["Coincident", "Collinear", "Parallel", "Perpendicular", "Horizontal", "Vertical", "Tangent", "Concentric", "Equal"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
bars_visible: true,
|
||||
bar_exceptions: Vec::new(),
|
||||
|
||||
@@ -66,6 +66,10 @@ pub struct Foreign {
|
||||
pub records: BTreeMap<ForeignKey, ForeignExtra>,
|
||||
/// The roles of the file handles the kept data refers to, for objects CADCraft rewrites.
|
||||
pub targets: BTreeMap<Handle, ForeignKey>,
|
||||
/// The upper-case names of the file's classes in its CLASSES order, kept when the file has
|
||||
/// proxy objects or entities: a proxy's class number (group 91) is 500 plus its class's place
|
||||
/// here, and saving renumbers it to the written CLASSES (#1812).
|
||||
pub class_order: Vec<String>,
|
||||
/// The file's data store section (ACDSDATA, AutoCAD 2013 and later), as read: its groups
|
||||
/// after the section name. It holds the ACIS geometry of 3D solids, regions, bodies and
|
||||
/// surfaces, which from 2013 on those entities don't carry themselves: each `ACDSRECORD`
|
||||
@@ -81,6 +85,7 @@ impl Foreign {
|
||||
&& self.records.is_empty()
|
||||
&& self.classes.is_empty()
|
||||
&& self.targets.is_empty()
|
||||
&& self.class_order.is_empty()
|
||||
&& self.data_store.is_empty()
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,9 @@ pub struct Drawing {
|
||||
pub blocks: BTreeMap<String, std::sync::Arc<Block>>,
|
||||
pub model: EntityStore,
|
||||
pub layouts: Vec<Layout>,
|
||||
/// The layout a file marks active (its block is `*Paper_Space`, the others `*Paper_SpaceN`),
|
||||
/// written active again. `None`, or a layout that no longer exists: the first by tab order.
|
||||
pub active_layout: Option<String>,
|
||||
pub views: Vec<NamedView>,
|
||||
/// Model space viewport configurations (VPORTS): the tiles shown and named ones.
|
||||
pub vports: Vec<VportConfig>,
|
||||
@@ -171,6 +174,7 @@ impl Drawing {
|
||||
blocks: BTreeMap::new(),
|
||||
model: EntityStore::new(),
|
||||
layouts: vec![Layout::new("Layout1", 1), Layout::new("Layout2", 2)],
|
||||
active_layout: None,
|
||||
views: Vec::new(),
|
||||
vports: Vec::new(),
|
||||
ucss: Vec::new(),
|
||||
@@ -298,6 +302,18 @@ impl Drawing {
|
||||
true
|
||||
}
|
||||
|
||||
/// The layouts in the order their paper space blocks are named in a file: the active one
|
||||
/// (`*Paper_Space`) first, then the others by tab order (`*Paper_Space0`, `*Paper_Space1`, …).
|
||||
pub fn paper_space_layouts(&self) -> Vec<&Layout> {
|
||||
let mut ls: Vec<&Layout> = self.layouts.iter().collect();
|
||||
ls.sort_by_key(|l| l.tab_order);
|
||||
if let Some(i) = self.active_layout.as_ref().and_then(|a| ls.iter().position(|l| l.name.eq_ignore_ascii_case(a))) {
|
||||
let active = ls.remove(i);
|
||||
ls.insert(0, active);
|
||||
}
|
||||
ls
|
||||
}
|
||||
|
||||
pub fn space(&self, space: &Space) -> Option<&EntityStore> {
|
||||
match space {
|
||||
Space::Model => Some(&self.model),
|
||||
|
||||
@@ -857,7 +857,7 @@ pub struct VportConfig {
|
||||
|
||||
impl VportConfig {
|
||||
/// The name of the configuration shown.
|
||||
pub const ACTIVE: &'static str = "*Active";
|
||||
pub const ACTIVE: &str = "*Active";
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.name.eq_ignore_ascii_case(Self::ACTIVE)
|
||||
}
|
||||
|
||||
@@ -109,9 +109,9 @@ impl Default for CellStyle {
|
||||
}
|
||||
|
||||
impl CellStyle {
|
||||
pub const TITLE: &'static str = "Title";
|
||||
pub const HEADER: &'static str = "Header";
|
||||
pub const DATA: &'static str = "Data";
|
||||
pub const TITLE: &str = "Title";
|
||||
pub const HEADER: &str = "Header";
|
||||
pub const DATA: &str = "Data";
|
||||
|
||||
/// Whether `name` is one of the three styles every table style has.
|
||||
pub fn is_builtin(name: &str) -> bool {
|
||||
@@ -190,7 +190,7 @@ impl TableStyle {
|
||||
|
||||
/// The names of its cell styles: Title, Header, Data, then the custom ones.
|
||||
pub fn cell_style_names(&self) -> Vec<String> {
|
||||
let mut out: Vec<String> = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].iter().map(|s| s.to_string()).collect();
|
||||
let mut out: Vec<String> = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].iter().map(ToString::to_string).collect();
|
||||
out.extend(self.cell_styles.iter().filter(|c| !CellStyle::is_builtin(&c.name)).map(|c| c.name.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -541,12 +541,80 @@ mod native {
|
||||
dimension_flags(&mut doc);
|
||||
drop_unwritable(&mut doc);
|
||||
current_dimstyle(&mut doc);
|
||||
add_variable_dictionary(&mut doc);
|
||||
current_text_style(&mut doc);
|
||||
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}"))
|
||||
}
|
||||
|
||||
/// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the
|
||||
/// ACDBVARIABLEDICTIONARY; dictionary keys are case-insensitive).
|
||||
const VARIABLE_DICTIONARY: &str = "AcDbVariableDictionary";
|
||||
|
||||
/// A style or scale name, or `default` when the drawing has none.
|
||||
fn name_or_default(name: &str, default: &str) -> String {
|
||||
let name = name.trim();
|
||||
if name.is_empty() { default.to_string() } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// A drawing variable's value as text, or `default` when the header holds a value the
|
||||
/// variable doesn't accept (a hostile file can put any integer in it).
|
||||
fn variable_value(value: i16, valid: &[i16], default: i16) -> String {
|
||||
if valid.contains(&value) { value.to_string() } else { default.to_string() }
|
||||
}
|
||||
|
||||
/// Add the drawing variables dictionary AutoCAD always writes into the named object
|
||||
/// dictionary, with the variables it keeps there, when the drawing has none (#1813).
|
||||
/// Vectric Aspire 10.5 reads a 2004 (AC1018) DWG without it as empty ("No data was imported
|
||||
/// from the DXF / DWG file"), though it reads the same drawing as DXF — which has no such
|
||||
/// dictionary either, as CADCraft's and ezdxf's writers don't make one. AutoCAD's AC1032 DWGs
|
||||
/// read there without it, so newer output is left as it was, and a dictionary the drawing
|
||||
/// does have keeps its own entries and values.
|
||||
fn add_variable_dictionary(doc: &mut acadrust::CadDocument) {
|
||||
use acadrust::objects::{Dictionary, DictionaryVariable, ObjectType};
|
||||
if doc.version >= acadrust::DxfVersion::AC1032 {
|
||||
return;
|
||||
}
|
||||
let root_handle = doc.header.named_objects_dict_handle;
|
||||
match doc.objects.get(&root_handle) {
|
||||
Some(ObjectType::Dictionary(root)) => {
|
||||
if root.get(VARIABLE_DICTIONARY).is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
// The variables AutoCAD writes for a drawing with default styles and a 1:1 annotation
|
||||
// scale. DIMASSOC and HIDETEXT keep the drawing's own header values; LAYEREVAL and
|
||||
// LAYERNOTIFY have no DXF header variable.
|
||||
let variables = [
|
||||
("CMLEADERSTYLE", name_or_default(&doc.header.current_mleader_style_name, "Standard")),
|
||||
("CTABLESTYLE", name_or_default(&doc.header.current_table_style_name, "Standard")),
|
||||
("CANNOSCALE", name_or_default(&doc.header.current_annotation_scale, "1:1")),
|
||||
("DIMASSOC", variable_value(doc.header.dimension_associativity, &[0, 1, 2], 2)),
|
||||
("HIDETEXT", variable_value(doc.header.hide_text, &[0, 1], 1)),
|
||||
("LAYEREVAL", "0".to_string()),
|
||||
("LAYERNOTIFY", "0".to_string()),
|
||||
];
|
||||
let dictionary_handle = doc.allocate_handle();
|
||||
let mut dictionary = Dictionary::new();
|
||||
dictionary.handle = dictionary_handle;
|
||||
dictionary.owner = root_handle;
|
||||
for (name, value) in variables {
|
||||
let handle = doc.allocate_handle();
|
||||
let mut variable = DictionaryVariable::new(name, &value);
|
||||
variable.handle = handle;
|
||||
variable.owner_handle = dictionary_handle;
|
||||
dictionary.add_entry(name, handle);
|
||||
doc.objects.insert(handle, ObjectType::DictionaryVariable(variable));
|
||||
}
|
||||
doc.objects.insert(dictionary_handle, ObjectType::Dictionary(dictionary));
|
||||
if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_handle) {
|
||||
root.add_entry(VARIABLE_DICTIONARY, dictionary_handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Entities of a layout other than the one in `*Paper_Space` are written owned by their block
|
||||
/// (entity mode 0, with its handle). acadrust writes every paper-space entity with mode 1,
|
||||
/// "paper space", which AutoCAD reads as the `*Paper_Space` block's: everything drawn on a
|
||||
|
||||
@@ -302,7 +302,7 @@ mod native {
|
||||
/// files of stopped apps that have no files left. Only an autosave file is deleted: a path
|
||||
/// whose name doesn't parse as one ([`parse_file_name`]) is left alone.
|
||||
pub fn discard(path: &str) {
|
||||
let ours = Path::new(path).file_name().and_then(|n| n.to_str()).is_some_and(|n| parse_file_name(n).is_some());
|
||||
let ours = Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).is_some_and(|n| parse_file_name(n).is_some());
|
||||
if !ours {
|
||||
log::warn!("autosave: not deleting {path}: not an autosave file");
|
||||
return;
|
||||
@@ -334,10 +334,22 @@ mod native {
|
||||
|
||||
type WriteHook = fn(&Drawing, &str) -> std::result::Result<Vec<u8>, String>;
|
||||
|
||||
/// Create the autosave folder `dir` and any missing parents. On Unix the folders made here are
|
||||
/// owner-only (`0o700`): autosave files are unsaved drawings, and with the default mode other
|
||||
/// accounts on the machine could read them. A folder that already exists keeps its mode, since
|
||||
/// SAVEFILEPATH can be any folder the user picked.
|
||||
fn create_folder(dir: &Path) -> std::io::Result<()> {
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
builder.create(dir)
|
||||
}
|
||||
|
||||
fn write_file(write: WriteHook, doc: &Drawing, path: &str) -> std::result::Result<(), String> {
|
||||
let bytes = write(doc, "autosave.dxf")?;
|
||||
if let Some(dir) = Path::new(path).parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
create_folder(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
// 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).
|
||||
@@ -434,7 +446,7 @@ mod native {
|
||||
}
|
||||
self.last_beat = Some(now);
|
||||
let dir = Path::new(dir);
|
||||
let r = std::fs::create_dir_all(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
|
||||
let r = create_folder(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
|
||||
if let Err(e) = r {
|
||||
log::warn!("autosave: lock file in {}: {e}", dir.display());
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ fn with_assoc(k: EntityKind, assoc: Vec<DimAssoc>) -> EntityKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Angular dimension between two lines, attached to them (vertex = their intersection).
|
||||
/// Two-line angular dimension (type 2) between two lines, attached to them.
|
||||
fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option<EntityKind> {
|
||||
let d = s.doc().ok()?;
|
||||
let line = |h: Handle| match &d.entity(h)?.kind {
|
||||
@@ -200,13 +200,18 @@ fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option<E
|
||||
};
|
||||
let (p1, s1) = pick(a1, a2, AssocSnap::Start, AssocSnap::End);
|
||||
let (p2, s2) = pick(b1, b2, AssocSnap::Start, AssocSnap::End);
|
||||
let k = dim(s, DimKind::Angular3P, at, p1, p2, v, Vec2::ZERO, "");
|
||||
// A two-line angular dimension (type 2): line 1 is p13 -> p14, line 2 is defpt -> p15, p16 is on the arc.
|
||||
// The picked leg end goes last in each pair, so the renderer measures between p14 and p15.
|
||||
let (o1, t1) = if s1 == AssocSnap::Start { (a2, AssocSnap::End) } else { (a1, AssocSnap::Start) };
|
||||
let (o2, t2) = if s2 == AssocSnap::Start { (b2, AssocSnap::End) } else { (b1, AssocSnap::Start) };
|
||||
let k = dim(s, DimKind::Angular, o2, o1, p1, p2, at, "");
|
||||
let mut assoc = Vec::new();
|
||||
if assoc_enabled(s) {
|
||||
assoc = vec![
|
||||
DimAssoc { point: "p13".into(), handle: h1, snap: s1 },
|
||||
DimAssoc { point: "p14".into(), handle: h2, snap: s2 },
|
||||
DimAssoc { point: "p15".into(), handle: h1, snap: AssocSnap::Intersection { other: h2 } },
|
||||
DimAssoc { point: "p13".into(), handle: h1, snap: t1 },
|
||||
DimAssoc { point: "p14".into(), handle: h1, snap: s1 },
|
||||
DimAssoc { point: "defpt".into(), handle: h2, snap: t2 },
|
||||
DimAssoc { point: "p15".into(), handle: h2, snap: s2 },
|
||||
];
|
||||
}
|
||||
Some(with_assoc(k, assoc))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use cadcraft_doc::{AssocSnap, DimKind, Dimension, EntityKind, Handle};
|
||||
use cadcraft_doc::{DimKind, Dimension, EntityKind, Handle};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -136,7 +136,9 @@ fn angular_between_lines_follows_rotation() {
|
||||
let b = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
|
||||
let d = h(&s.execute("dimangular", &json!({ "lines": [a.hex(), b.hex()], "at": [3, 3] })).unwrap());
|
||||
let dm = dim(&s, d);
|
||||
assert!(dm.assoc.iter().any(|x| matches!(x.snap, AssocSnap::Intersection { .. })));
|
||||
// Two picked lines make a two-line angular dimension (type 2), tied to both lines at all four points.
|
||||
assert_eq!(dm.kind, DimKind::Angular);
|
||||
assert_eq!(dm.assoc.len(), 4);
|
||||
assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dm).value, "90°");
|
||||
// Swing the second line to 45°.
|
||||
s.execute("properties.set", &json!({ "handles": [b.hex()], "end": [10, 10] })).unwrap();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Block Editor.
|
||||
|
||||
pub(crate) mod bedit;
|
||||
mod purge;
|
||||
pub(in crate::cmd) mod purge;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn valid_block_name(n: &str) -> bool {
|
||||
pub(crate) fn valid_block_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ fn referenced_block(e: &Entity) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Whether any of `ents` refers to block `name`, directly or through nested block references.
|
||||
fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool {
|
||||
pub(crate) fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool {
|
||||
let mut todo: Vec<String> = ents.iter().filter_map(referenced_block).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some(b) = todo.pop() {
|
||||
|
||||
@@ -149,6 +149,7 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) {
|
||||
merge(&mut d.dim_styles, &src.dim_styles, |t| &t.name);
|
||||
merge(&mut d.mleader_styles, &src.mleader_styles, |t| &t.name);
|
||||
merge(&mut d.table_styles, &src.table_styles, |t| &t.name);
|
||||
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
|
||||
@@ -255,4 +256,17 @@ mod tests {
|
||||
assert_eq!(file_ref(&s, "C:\\parts\\bolt.dxf").map(|f| f.name), Some("bolt".into()));
|
||||
assert!(file_ref(&s, "BOLT").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_file_brings_its_mline_styles() {
|
||||
let mut src = Drawing::default();
|
||||
src.mline_styles.push(cadcraft_doc::MLineStyle { name: "WALL".into(), ..Default::default() });
|
||||
let mut d = Drawing::default();
|
||||
merge_tables(&mut d, &src);
|
||||
assert!(d.mline_styles.iter().any(|m| m.name == "WALL"));
|
||||
// A style this drawing already has keeps its own definition.
|
||||
let before = d.mline_styles.len();
|
||||
merge_tables(&mut d, &src);
|
||||
assert_eq!(d.mline_styles.len(), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@ use super::super::qselect::wildcard;
|
||||
use super::super::{bad, find_command, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// The named objects PURGE can remove. `USAGE` runs the same scan.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
pub(in crate::cmd) enum Kind {
|
||||
Blocks,
|
||||
Dimstyles,
|
||||
Groups,
|
||||
@@ -117,14 +118,35 @@ fn entities(d: &Drawing) -> impl Iterator<Item = &std::sync::Arc<cadcraft_doc::E
|
||||
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).chain(d.blocks.values().flat_map(|b| b.entities.iter())).take(5_000_000)
|
||||
}
|
||||
|
||||
/// Upper-case names of blocks reachable from model space, the layouts and dimensions.
|
||||
fn used_blocks(d: &Drawing) -> HashSet<String> {
|
||||
/// The arrowhead block names a dimension's overrides set.
|
||||
fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String> + '_ {
|
||||
dm.overrides
|
||||
.iter()
|
||||
.filter(|(k, _)| cadcraft_doc::DimStyle::field_name(k).is_some_and(|f| f.starts_with("arrowBlock") || f == "leaderArrowBlock"))
|
||||
.filter_map(|(_, v)| v.as_str().map(|n| n.trim().to_string()))
|
||||
.filter(|n| !n.is_empty())
|
||||
}
|
||||
|
||||
/// Upper-case names of blocks reachable from model space, the layouts and dimensions, and the
|
||||
/// arrowhead blocks dimension styles and dimension overrides name.
|
||||
pub(in crate::cmd) fn used_blocks(d: &Drawing) -> HashSet<String> {
|
||||
let mut used = HashSet::new();
|
||||
let mut frontier: Vec<String> = Vec::new();
|
||||
for st in &d.dim_styles {
|
||||
frontier.extend(
|
||||
[&st.arrow_block, &st.arrow_block1, &st.arrow_block2, &st.leader_arrow_block]
|
||||
.into_iter()
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|n| !n.is_empty()),
|
||||
);
|
||||
}
|
||||
for e in d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())) {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => frontier.push(i.block.clone()),
|
||||
EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()),
|
||||
EntityKind::Dimension(dm) => {
|
||||
frontier.extend(dm.block.clone());
|
||||
frontier.extend(override_arrows(dm));
|
||||
}
|
||||
EntityKind::MLeader(m) => frontier.extend(m.block.as_ref().map(|b| b.block.clone())),
|
||||
_ => {}
|
||||
}
|
||||
@@ -139,7 +161,10 @@ fn used_blocks(d: &Drawing) -> HashSet<String> {
|
||||
for e in b.entities.iter() {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => frontier.push(i.block.clone()),
|
||||
EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()),
|
||||
EntityKind::Dimension(dm) => {
|
||||
frontier.extend(dm.block.clone());
|
||||
frontier.extend(override_arrows(dm));
|
||||
}
|
||||
EntityKind::MLeader(m) => frontier.extend(m.block.as_ref().map(|b| b.block.clone())),
|
||||
_ => {}
|
||||
}
|
||||
@@ -153,8 +178,9 @@ fn is(name: &str, list: &[&str]) -> bool {
|
||||
list.iter().any(|n| n.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Names of the unused objects of a named kind, in table order.
|
||||
fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
|
||||
/// Names of the unused objects of a named kind, in table order. This is PURGE's scan; `USAGE`
|
||||
/// reads it too, without purging anything.
|
||||
pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
|
||||
let lower = |it: &mut dyn Iterator<Item = String>| it.map(|n| n.to_ascii_lowercase()).collect::<HashSet<String>>();
|
||||
match kind {
|
||||
Kind::Blocks => {
|
||||
|
||||
@@ -11,6 +11,8 @@ use super::pline_opts::{Outcome, PlineAsk};
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, snap};
|
||||
|
||||
/// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt
|
||||
/// machine each command starts.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("line", "Line", run_line)
|
||||
@@ -36,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("rectang", "Rectangle", super::draw2::run_rectang2)
|
||||
.menu(&["Draw", "Rectangle"])
|
||||
.alias(&["rec", "rectangle"])
|
||||
.params("{p1, p2 | dimensions: [l, w] (p2 picks the quadrant) | area + length|breadth, rotation? (deg), fillet?, chamfer?: [d1, d2], width?, elevation?, thickness?}")
|
||||
.params("{p1, p2 | dimensions: [l, w] (p2 picks the quadrant) | area + length|breadth, rotation? (deg), fillet?, chamfer?: [d1, d2], width?, elevation?, thickness?, center?: bool (p1 is the centre, not a corner; the size spans it evenly)}")
|
||||
.interactive(|_| Ok(Box::new(super::draw2::RectM2::default()))),
|
||||
CommandSpec::new("polygon", "Polygon", run_polygon)
|
||||
.menu(&["Draw", "Polygon"])
|
||||
|
||||
@@ -1581,8 +1581,12 @@ fn rect_kind(first: Vec2, w: f64, h: f64, rot: f64, fillet: f64, chamfer: (f64,
|
||||
k
|
||||
}
|
||||
|
||||
/// Draws a rectangle from the JSON form: `p1` is a corner, or the centre when `center` is
|
||||
/// true, and the size comes from `p2`, `dimensions` or `area` plus `length`/`breadth`.
|
||||
pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point_req("rectang", p, "p1")?;
|
||||
// Centre mode: `p1` is the rectangle's centre, so the rectangle spans it evenly.
|
||||
let centered = bool_or(p, "center", false);
|
||||
let rot = f64_or(p, "rotation", 0.0).to_radians();
|
||||
let ch = p
|
||||
.get("chamfer")
|
||||
@@ -1618,12 +1622,14 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
let b = point_req("rectang", p, "p2")?;
|
||||
let loc = (b - a).rotate(-rot);
|
||||
(loc.x, loc.y)
|
||||
// In centre mode the picked point is one corner, mirrored across the centre.
|
||||
if centered { (2.0 * loc.x, 2.0 * loc.y) } else { (loc.x, loc.y) }
|
||||
};
|
||||
if w.abs() < 1e-12 || h.abs() < 1e-12 || !w.is_finite() || !h.is_finite() {
|
||||
return Err(bad("rectang", "rectangle has no area"));
|
||||
}
|
||||
let k = rect_kind(a, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
|
||||
let first = if centered { a - Vec2::new(w / 2.0, h / 2.0).rotate(rot) } else { a };
|
||||
let k = rect_kind(first, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
|
||||
let hd = s.add_entity(k)?;
|
||||
let th = f64_or(p, "thickness", 0.0);
|
||||
if th != 0.0 {
|
||||
@@ -1654,6 +1660,8 @@ enum RAsk {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RectM2 {
|
||||
first: Option<Vec2>,
|
||||
/// The first point is the rectangle's centre (the `Center` option), not a corner.
|
||||
center: bool,
|
||||
fillet: f64,
|
||||
chamfer: (f64, f64),
|
||||
width: f64,
|
||||
@@ -1666,23 +1674,37 @@ pub(crate) struct RectM2 {
|
||||
}
|
||||
|
||||
impl RectM2 {
|
||||
/// Adds the rectangle of full size `w` × `h` with the collected options, drawing it from
|
||||
/// the centre when centre mode is on, then applies the requested thickness.
|
||||
fn make(&self, s: &mut Session, w: f64, h: f64) -> Result<Step> {
|
||||
let Some(a) = self.first else { return Ok(Step::Cancel) };
|
||||
if w.abs() < 1e-12 || h.abs() < 1e-12 {
|
||||
return Err(other("The rectangle has no area."));
|
||||
}
|
||||
let hd = s.add_entity(rect_kind(a, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?;
|
||||
let first = self.origin(w, h).unwrap_or(a);
|
||||
let hd = s.add_entity(rect_kind(first, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?;
|
||||
if self.thickness != 0.0 {
|
||||
let t = self.thickness;
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
/// The rectangle's local origin (the corner `rect_kind` draws from) for a size: the first
|
||||
/// point itself as a corner, or the first point less half the size around the centre.
|
||||
fn origin(&self, w: f64, h: f64) -> Option<Vec2> {
|
||||
let a = self.first?;
|
||||
Some(if self.center { a - Vec2::new(w / 2.0, h / 2.0).rotate(self.rotation) } else { a })
|
||||
}
|
||||
/// The rectangle's signed full size implied by the picked point `p`: explicit `dims` only
|
||||
/// take their quadrant from `p`, otherwise `p` is a corner, doubled about the centre in
|
||||
/// centre mode.
|
||||
fn other_corner(&self, p: Vec2) -> Option<(f64, f64)> {
|
||||
let a = self.first?;
|
||||
let loc = (p - a).rotate(-self.rotation);
|
||||
Some(match self.dims {
|
||||
Some((l, w)) => (if loc.x < 0.0 { -l } else { l }, if loc.y < 0.0 { -w } else { w }),
|
||||
// In centre mode the picked point is one corner, mirrored across the centre.
|
||||
None if self.center => (2.0 * loc.x, 2.0 * loc.y),
|
||||
None => (loc.x, loc.y),
|
||||
})
|
||||
}
|
||||
@@ -1692,6 +1714,8 @@ impl Interactive for RectM2 {
|
||||
fn name(&self) -> &'static str {
|
||||
"RECTANG"
|
||||
}
|
||||
/// Asks for the pending option's value, or for the first and second points; in centre mode
|
||||
/// the first prompt asks for the centre instead of a corner.
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
let num = |m: &str, d: f64| Prompt::new(m, Accept::NUMBER).default(format!("{d:.4}"));
|
||||
if let Some(a) = self.asking {
|
||||
@@ -1717,10 +1741,15 @@ impl Interactive for RectM2 {
|
||||
};
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Chamfer", "Elevation", "Fillet", "Thickness", "Width"]),
|
||||
None => {
|
||||
let msg = if self.center { "Specify center point for rectangle" } else { "Specify first corner point" };
|
||||
Prompt::new(msg, Accept::POINT).kw(&["Chamfer", "CEnter", "Elevation", "Fillet", "Thickness", "Width"])
|
||||
}
|
||||
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).corner(p),
|
||||
}
|
||||
}
|
||||
/// Consumes one input: option keywords (`Center` only switches the first point's meaning
|
||||
/// to the centre and asks nothing), option values, and the points that size the rectangle.
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(a) = self.asking {
|
||||
let val = |i: &Input, default: f64| -> Result<f64> {
|
||||
@@ -1794,6 +1823,11 @@ impl Interactive for RectM2 {
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) => {
|
||||
// `Center` only switches the first point's meaning; it asks nothing.
|
||||
if k == "CEnter" {
|
||||
self.center = true;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.asking = Some(match k.as_str() {
|
||||
"Chamfer" => RAsk::Chamfer1,
|
||||
"Elevation" => RAsk::Elevation,
|
||||
@@ -1820,12 +1854,14 @@ impl Interactive for RectM2 {
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
/// The rubber-band rectangle for the cursor at `c`, mapped through the same size-and-origin
|
||||
/// rules as the committed entity so centre mode previews where it will land.
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if self.asking.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
match (self.first, self.other_corner(c)) {
|
||||
(Some(a), Some((w, h))) if w.abs() > 1e-12 && h.abs() > 1e-12 => {
|
||||
match self.other_corner(c).and_then(|(w, h)| self.origin(w, h).map(|a| (a, w, h))) {
|
||||
Some((a, w, h)) if w.abs() > 1e-12 && h.abs() > 1e-12 => {
|
||||
vec![rect_kind(a, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation)]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
|
||||
@@ -566,6 +566,7 @@ fn xline_options_command_line() {
|
||||
|
||||
// ---------------- RECTANG ----------------
|
||||
|
||||
/// The corners of the rectangle just added, so a test can assert its bounds.
|
||||
fn rect_pts(s: &Session) -> Vec<Vec2> {
|
||||
match last(s) {
|
||||
EntityKind::LwPolyline(p) => p.vertices.iter().map(|v| v.p).collect(),
|
||||
@@ -622,6 +623,111 @@ fn rectang_options_command_line() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Guards the JSON `center: true` form for every sizing route: a picked corner mirrored about
|
||||
/// the centre, full-size `dimensions`, full `area`, rotation about the centre, that the default
|
||||
/// corner mode is untouched, and that centre mode without a size is rejected.
|
||||
#[test]
|
||||
fn rectang_center_mode_json() {
|
||||
let mut s = Session::new();
|
||||
// The first point is the centre; the second is a corner mirrored about it.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16]})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Dimensions are the rectangle's full size, still centred on the first point.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12]})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// A second point only picks the quadrant.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12], "p2": [0, 0]})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// The area is the full area, centred too.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "area": 96, "length": 8})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Rotation is about the centre.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12], "rotation": 90})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 4.0, 6.0) && near(b.max, 16.0, 14.0), "{b:?}");
|
||||
// The default corner mode is unchanged.
|
||||
s.execute("rectang", &json!({"p1": [10, 10], "p2": [14, 16]})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 10.0, 10.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Centre mode still needs a size.
|
||||
assert!(s.execute("rectang", &json!({"center": true, "p1": [10, 10]})).is_err());
|
||||
}
|
||||
|
||||
/// Guards that fillet, chamfer, width and thickness still apply in centre mode, and that an
|
||||
/// oversized fillet falls back to sharp corners without moving the rectangle off centre.
|
||||
#[test]
|
||||
fn rectang_center_mode_honours_options() {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "fillet": 1, "width": 0.25, "thickness": 2})).unwrap();
|
||||
match &s.doc().unwrap().model.last().unwrap().kind {
|
||||
EntityKind::LwPolyline(p) => assert!(p.vertices.len() == 8 && p.const_width == 0.25),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 2.0);
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Chamfer, still centred.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "chamfer": [1, 1]})).unwrap();
|
||||
assert_eq!(rect_pts(&s).len(), 8);
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// A fillet too large for the rectangle falls back to sharp corners, still centred.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "fillet": 5})).unwrap();
|
||||
assert_eq!(rect_pts(&s).len(), 4);
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
}
|
||||
|
||||
/// Guards the `Center` keyword at the command line, before or after other options, with
|
||||
/// dimensions and area still centred, the prompt wording and centred rubber band, and that the
|
||||
/// default corner mode is unchanged.
|
||||
#[test]
|
||||
fn rectang_center_mode_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("rectang ce 10,10 14,16").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Options before the centre point, and the centre picked after them.
|
||||
s.cmdline("rectang center f 1 w 0.1 10,10 14,16").unwrap();
|
||||
match &s.doc().unwrap().model.last().unwrap().kind {
|
||||
EntityKind::LwPolyline(p) => assert!(p.vertices.len() == 8 && p.const_width == 0.1),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Dimensions after the centre: the pick only chooses the quadrant.
|
||||
s.cmdline("rectang ce 10,10 d 8 12 14,16").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Area too: the rectangle of that area is centred on the first point.
|
||||
s.cmdline("rectang ce 10,10 a 96 l 8").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// The rubber band is centred while the second point is picked.
|
||||
s.start("rectang").unwrap();
|
||||
s.cmdline("ce").unwrap();
|
||||
assert!(s.prompt_text().contains("Specify center point for rectangle"), "{}", s.prompt_text());
|
||||
s.cmdline("10,10").unwrap();
|
||||
let pv = s.preview(Vec2::new(14.0, 16.0));
|
||||
let pb = match pv.first().map(|e| &e.kind) {
|
||||
Some(EntityKind::LwPolyline(p)) => cadcraft_geom::Bounds2::from_points(p.vertices.iter().map(|v| v.p).collect::<Vec<_>>()),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
assert!(near(pb.min, 6.0, 4.0) && near(pb.max, 14.0, 16.0), "{pb:?}");
|
||||
s.cancel();
|
||||
// The default corner mode is unchanged.
|
||||
s.cmdline("rectang 10,10 14,16").unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 10.0, 10.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
}
|
||||
|
||||
// ---------------- 3DPOLY / MLINE / HELIX / SPLINE CV ----------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -85,11 +85,25 @@ fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (path, bytes);
|
||||
Err(bad(cmd, "paths are not available on the web; omit `path` to get the file as base64 `data`"))
|
||||
deliver_file(cmd, path, bytes, super::file::deliver())
|
||||
}
|
||||
}
|
||||
|
||||
/// No file system on the web: hand the bytes to the host's delivery hook (a browser download)
|
||||
/// named after `path`, like SAVE and TABLEEXPORT do. EXPORT and PLOT (PUBLISH, EXPORTPDF) use it.
|
||||
#[cfg(any(test, target_arch = "wasm32"))]
|
||||
pub(super) fn deliver_file(cmd: &str, path: &str, bytes: &[u8], hook: Option<super::file::DeliverHook>) -> Result<()> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let mime = match extension(&name).as_str() {
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"pdf" => "application/pdf",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
let deliver = hook.ok_or_else(|| bad(cmd, "files can't be written in this build; omit `path` to get the file as base64 `data`"))?;
|
||||
deliver(&name, mime, bytes).map_err(|e| bad(cmd, format!("{name}: {e}")))
|
||||
}
|
||||
|
||||
// ---------- EXPORT ----------
|
||||
|
||||
fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -545,6 +559,23 @@ mod tests {
|
||||
use super::super::file::{IoHooks, set_io};
|
||||
use super::*;
|
||||
|
||||
static DELIVERED: std::sync::Mutex<Vec<(String, String, usize)>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn record(name: &str, mime: &str, bytes: &[u8]) -> std::result::Result<(), String> {
|
||||
DELIVERED.lock().map_err(|e| e.to_string())?.push((name.into(), mime.into(), bytes.len()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_export_delivers_a_download_instead_of_writing_a_path() {
|
||||
deliver_file("export", "Drawing1.svg", b"<svg/>", Some(record)).unwrap();
|
||||
deliver_file("export", "dir/Plan.pdf", b"%PDF", Some(record)).unwrap();
|
||||
let got = DELIVERED.lock().unwrap().clone();
|
||||
assert_eq!(got, [("Drawing1.svg".to_string(), "image/svg+xml".to_string(), 6), ("Plan.pdf".to_string(), "application/pdf".to_string(), 4)]);
|
||||
let e = deliver_file("export", "a.svg", b"x", None).unwrap_err().to_string();
|
||||
assert!(e.contains("base64"), "{e}");
|
||||
}
|
||||
|
||||
fn fake_plot(_: &Drawing, space: &Space, opts: &Value) -> std::result::Result<Vec<u8>, String> {
|
||||
// Same output as the layout tests' stand-in: whichever test installs first wins.
|
||||
Ok(format!("%PDF-1.4 fake {space:?} {opts}").into_bytes())
|
||||
|
||||
@@ -163,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.menu(&["File", "New Drawing..."])
|
||||
.key("Cmd+N")
|
||||
.alias(&["qnew"])
|
||||
.params("{metric?: bool, template?: path to a .dwt/.dxf template (\".\" for none; default QNEWTEMPLATE)}")
|
||||
.params("{metric?: bool (default MEASUREINIT), template?: path to a .dwt/.dxf template (\".\" for none; default QNEWTEMPLATE)}")
|
||||
.enabled(always)
|
||||
.noundo(),
|
||||
CommandSpec::new("open", "Open...", run_open)
|
||||
@@ -252,7 +252,8 @@ 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));
|
||||
let metric = s.settings.measureinit;
|
||||
let i = s.new_drawing(bool_or(p, "metric", metric));
|
||||
Ok(json!({ "index": i, "title": s.docs.get(i).map(|d| d.title.clone()) }))
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ pub use pick::{HatchPicks, PickMode};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) and/or handles?: [hex] (closed objects), pattern?: \"ANSI31\", scale?, angle? (degrees), associative? (HPASSOC), separate? (HPSEPARATE: a hatch per point/object), origin?: [x,y] (HPORIGIN) | originMode?: \"current\"|\"bottomLeft\"|\"bottomRight\"|\"topLeft\"|\"topRight\"|\"center\" (HPORIGINMODE), style?: \"normal\"|\"outer\"|\"ignore\" (HPISLANDDETECTION)} — gaps up to HPGAPTOL close").interactive(|s| Ok(Box::new(HatchM::new(s, false)))),
|
||||
CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) and/or handles?: [hex] (closed objects), pattern?: \"ANSI31\" (built in, or defined by patternFile | patternText), patternFile?: path to a .pat file | patternText?: its contents (loads the patterns it defines), scale?, angle? (degrees), associative? (HPASSOC), separate? (HPSEPARATE: a hatch per point/object), origin?: [x,y] (HPORIGIN) | originMode?: \"current\"|\"bottomLeft\"|\"bottomRight\"|\"topLeft\"|\"topRight\"|\"center\" (HPORIGINMODE), style?: \"normal\"|\"outer\"|\"ignore\" (HPISLANDDETECTION)} — gaps up to HPGAPTOL close").interactive(|s| Ok(Box::new(HatchM::new(s, false)))),
|
||||
CommandSpec::new("gradient", "Gradient...", run_gradient).menu(&["Draw", "Gradient..."]).alias(&["gd"]).params("{points? and/or handles?, name?: \"LINEAR\"|\"CYLINDER\"|\"CURVED\"|\"SPHERICAL\"|\"HEMISPHERICAL\" or INV… (colours swapped), color1?, color2?, angle? (degrees), centered? (default true)}").interactive(|s| Ok(Box::new(HatchM::new(s, true)))),
|
||||
CommandSpec::new("boundary", "Boundary...", run_boundary).menu(&["Draw", "Boundary..."]).alias(&["bo", "bpoly"]).params("{points: [[x,y]]}").interactive(|_| Ok(Box::new(BoundaryM { region: false }))),
|
||||
CommandSpec::new("region", "Region", run_region).menu(&["Draw", "Region"]).alias(&["reg"]).params("{handles?}"),
|
||||
@@ -34,12 +34,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The tolerance curves are flattened with for boundary tracing, in a space with extents `ext`.
|
||||
pub(super) fn outline_tol(ext: Bounds2) -> f64 {
|
||||
(ext.width() + ext.height()).max(1e-9) / 20000.0
|
||||
}
|
||||
|
||||
/// Tessellated outlines of every visible curve in the space (hatch boundary candidates), each
|
||||
/// with the handle of the object it came from.
|
||||
fn outlines(d: &Drawing, space: &Space, near: Option<Bounds2>) -> (Vec<Vec<Vec2>>, Vec<Handle>) {
|
||||
let Some(store) = d.space(space) else { return (Vec::new(), Vec::new()) };
|
||||
let ext = d.extents(space);
|
||||
let tol = (ext.width() + ext.height()).max(1e-9) / 20000.0;
|
||||
let tol = outline_tol(ext);
|
||||
let mut out = Vec::new();
|
||||
let mut owners = Vec::new();
|
||||
for e in store.iter() {
|
||||
@@ -113,7 +118,8 @@ fn closed_loop(d: &Drawing, e: &Entity) -> Option<Vec<PolyVertex>> {
|
||||
}
|
||||
|
||||
/// Open curves that can make up an island together (a square drawn as four LINEs, two ARCs
|
||||
/// forming a circle). Closed objects are islands on their own (see `closed_loop`).
|
||||
/// forming a circle, the inner lines of multiline walls around a room). Closed objects are
|
||||
/// islands on their own (see `closed_loop`).
|
||||
fn island_curve(d: &Drawing, e: &Entity) -> bool {
|
||||
matches!(
|
||||
e.kind,
|
||||
@@ -123,6 +129,7 @@ fn island_curve(d: &Drawing, e: &Entity) -> bool {
|
||||
| EntityKind::LwPolyline(_)
|
||||
| EntityKind::Polyline3d(_)
|
||||
| EntityKind::Spline(_)
|
||||
| EntityKind::MLine(_)
|
||||
) && closed_loop(d, e).is_none()
|
||||
}
|
||||
|
||||
@@ -204,6 +211,8 @@ pub(crate) fn loops_at(s: &Session, p: Vec2) -> Result<Vec<HatchLoop>> {
|
||||
let ext = d.extents(&space);
|
||||
let tol = ((ext.width() + ext.height()) * 1e-9).max(1e-9);
|
||||
let (polys, owners) = outlines(d, &space, None);
|
||||
// A line that ends on a flattened curve (an ellipse) can stop short of its chords.
|
||||
let polys = cadcraft_geom::region::snap_ends(&polys, outline_tol(ext), tol);
|
||||
let found = cadcraft_geom::region::boundary_at_gap(&polys, p, tol, gap_tolerance(d))
|
||||
.ok_or_else(|| EngineError::Other("Valid hatch boundary not found.".into()))?;
|
||||
let outer = found.outer;
|
||||
@@ -458,7 +467,7 @@ fn run_hatch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& cadcraft_doc::library::pattern(&up).is_none()
|
||||
&& patfile::user_pattern(&up).is_none()
|
||||
{
|
||||
return Err(bad("hatch", format!("unknown pattern {up}")));
|
||||
return Err(bad("hatch", format!("unknown pattern {up}: not built in; load it with patternFile or patternText (a .pat file)")));
|
||||
}
|
||||
make(s, p, false)
|
||||
}
|
||||
@@ -840,6 +849,16 @@ mod tests {
|
||||
assert!(s.current_prompt().unwrap().picker.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_pattern_error_and_catalog_name_the_pat_file_route() {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
let e = s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "GRAVEL" })).unwrap_err();
|
||||
assert!(e.to_string().contains("patternFile"), "{e}");
|
||||
let spec = specs().into_iter().find(|c| c.id == "hatch").unwrap();
|
||||
assert!(spec.params.contains("patternFile") && spec.params.contains("patternText"), "{}", spec.params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_hatch_refuses_an_undefined_pattern() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -86,9 +86,10 @@ fn refill(before: &Drawing, after: &Drawing, l: &HatchLoop) -> Refill {
|
||||
if nb.is_empty() {
|
||||
return Refill::Open;
|
||||
}
|
||||
let tol = (nb.width() + nb.height()).max(1e-9) / 20000.0;
|
||||
let polys: Vec<Vec<Vec2>> = live.iter().filter_map(|h| after.entity(*h)).flat_map(|e| object_polys(after, e, tol, nb)).collect();
|
||||
let tol = super::outline_tol(nb);
|
||||
let snap = ((nb.width() + nb.height()) * 1e-9).max(1e-9);
|
||||
let polys: Vec<Vec<Vec2>> = live.iter().filter_map(|h| after.entity(*h)).flat_map(|e| object_polys(after, e, tol, nb)).collect();
|
||||
let polys = region::snap_ends(&polys, tol, snap);
|
||||
let gap = gap_tolerance(after);
|
||||
let found = if l.outer {
|
||||
// The face around a point inside the old loop, carried along with the objects.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Inquiry: DIST, ID, LIST, AREA, drawing inspection for agents.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use cadcraft_doc::{Entity, EntityKind, Handle};
|
||||
use cadcraft_geom::{Polyline, Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
@@ -8,9 +10,13 @@ use super::*;
|
||||
use crate::units::{format_angle, format_distance};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod loops;
|
||||
mod regions;
|
||||
mod shapes;
|
||||
mod usage;
|
||||
|
||||
/// The inquiry commands: distances, points, listing, area, time and status, the drawing and
|
||||
/// entity queries an agent reads, SHAPES, USAGE, LOOPS, COUNT and the region commands.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut v = vec![
|
||||
CommandSpec::new("dist", "Distance", run_dist)
|
||||
@@ -45,6 +51,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params("{type?, layer?, selected?: bool (default false; true = current selection in active space), window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
|
||||
.noundo(),
|
||||
CommandSpec::new("shapes", "Analyze Shapes", shapes::run).params(shapes::PARAMS).noundo(),
|
||||
CommandSpec::new("usage", "Analyze Usage", usage::run).params(usage::PARAMS).noundo(),
|
||||
CommandSpec::new("loops", "Implied Loops", loops::run).params(loops::PARAMS).noundo(),
|
||||
CommandSpec::new("texts", "Query Texts", run_texts)
|
||||
.params("{contains?: text (any case), layer?, window?: [[x,y],[x,y]] (by insertion point), limit? (default 200), offset?} → {count (all matches), returned, offset, texts: [{handle, type, layer, text, at, height, rotation (degrees), style, tag? (attributes)}]}: the readable text of TEXT, MTEXT (formatting removed), block attributes and multileaders")
|
||||
.noundo(),
|
||||
@@ -64,15 +72,119 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
v
|
||||
}
|
||||
|
||||
/// Format a distance with the drawing's `LUNITS` and `LUPREC` (decimal units, four places by
|
||||
/// default).
|
||||
fn fmt_d(s: &Session, v: f64) -> String {
|
||||
let h = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
|
||||
format_distance(v, h.0, h.1)
|
||||
}
|
||||
/// Format an angle with the drawing's `AUNITS` and `AUPREC` (decimal degrees, whole degrees by
|
||||
/// default).
|
||||
fn fmt_a(s: &Session, v: f64) -> String {
|
||||
let h = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0));
|
||||
format_angle(v, h.0, h.1)
|
||||
}
|
||||
|
||||
/// Schema of the analysis self-check block (#1440). Fields may be added within a version; a
|
||||
/// rename or a changed meaning bumps it.
|
||||
pub(super) const SELF_CHECK_SCHEMA: u64 = 1;
|
||||
|
||||
/// How far past its bound a value may be before it is implausible: a relative slack for the
|
||||
/// rounding of two ways of measuring the same thing.
|
||||
const IMPLAUSIBLE_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
/// How many objects of one type an analysis skipped, and every distinct reason it gave.
|
||||
#[derive(Default)]
|
||||
struct SkipTally {
|
||||
count: u64,
|
||||
reasons: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// The self-check every analysis result carries (#1440): how many objects it found, how many it
|
||||
/// analysed, what it skipped and why, the caps it hit with their values, and values so
|
||||
/// implausible that they point at a bug rather than at the drawing.
|
||||
#[derive(Default)]
|
||||
pub(super) struct SelfCheck {
|
||||
found: u64,
|
||||
analyzed: u64,
|
||||
skipped: BTreeMap<String, SkipTally>,
|
||||
caps: BTreeMap<String, u64>,
|
||||
implausible: Vec<Value>,
|
||||
}
|
||||
|
||||
impl SelfCheck {
|
||||
/// An empty self-check: nothing found, analysed or skipped yet.
|
||||
pub(super) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Count objects the analysis found (matched its filters).
|
||||
pub(super) fn found(&mut self, n: u64) {
|
||||
self.found = self.found.saturating_add(n);
|
||||
}
|
||||
|
||||
/// Count objects the analysis actually analysed.
|
||||
pub(super) fn analyzed(&mut self, n: u64) {
|
||||
self.analyzed = self.analyzed.saturating_add(n);
|
||||
}
|
||||
|
||||
/// Count `n` objects of `ty` skipped, with the reason.
|
||||
pub(super) fn skip(&mut self, ty: &str, reason: &str, n: u64) {
|
||||
let skip = self.skipped.entry(ty.to_string()).or_default();
|
||||
skip.count = skip.count.saturating_add(n);
|
||||
skip.reasons.insert(reason.to_string());
|
||||
}
|
||||
|
||||
/// Record a cap the analysis hit, with its value (e.g. `limit`, 200).
|
||||
pub(super) fn cap(&mut self, name: &str, value: u64) {
|
||||
self.caps.insert(name.to_string(), value);
|
||||
}
|
||||
|
||||
/// Flag `value` as implausible when it is past `bound`, with the rule that makes it so. A
|
||||
/// non-finite `bound` means no bound applies (an empty drawing has no extents). Values within
|
||||
/// `IMPLAUSIBLE_TOLERANCE` of the bound are not flagged: they are the rounding of two ways
|
||||
/// of measuring the same thing (a border rectangle's shoelace area against the extents'
|
||||
/// `width * height`), not a bug.
|
||||
pub(super) fn implausible(&mut self, handle: Handle, ty: &str, field: &str, value: f64, bound: f64, rule: &str) {
|
||||
if value.is_finite() && bound.is_finite() && value > bound + bound.abs() * IMPLAUSIBLE_TOLERANCE {
|
||||
self.implausible.push(json!({
|
||||
"handle": handle.hex(),
|
||||
"type": ty,
|
||||
"field": field,
|
||||
"value": value,
|
||||
"bound": bound,
|
||||
"reason": rule,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The skip map in the shape analyses have always reported: type → {count, reason}.
|
||||
pub(super) fn skipped_json(&self) -> Value {
|
||||
Value::Object(
|
||||
self.skipped
|
||||
.iter()
|
||||
.map(|(ty, skip)| {
|
||||
let reason = skip.reasons.iter().map(String::as_str).collect::<Vec<_>>().join("; ");
|
||||
(ty.clone(), json!({ "count": skip.count, "reason": reason }))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The self-check as the JSON object every analysis returns: schema, counts, skip map, caps
|
||||
/// and implausible values.
|
||||
pub(super) fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"schema": SELF_CHECK_SCHEMA,
|
||||
"found": self.found,
|
||||
"analyzed": self.analyzed,
|
||||
"skipped": self.skipped_json(),
|
||||
"caps": self.caps,
|
||||
"implausible": self.implausible,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The angle of `d` up from the XY plane.
|
||||
fn angle_from_xy(d: Vec3) -> f64 {
|
||||
d.z.atan2(d.xy().len())
|
||||
@@ -896,3 +1008,28 @@ impl Interactive for AreaM {
|
||||
vec![super::helpers::lwpoly(v, true)]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #1440 review: a value one ulp past its bound (a border rectangle's shoelace area against
|
||||
/// the extents' `width * height`) is rounding, not a bug; a value clearly past it still is.
|
||||
#[test]
|
||||
fn implausible_allows_rounding_but_not_a_real_excess() {
|
||||
let bound: f64 = 100.0;
|
||||
|
||||
let mut check = SelfCheck::new();
|
||||
check.implausible(Handle::default(), "Polyline", "area", bound.next_up(), bound, "past the bound");
|
||||
assert!(check.implausible.is_empty(), "one ulp past the bound is rounding: {}", check.to_json());
|
||||
|
||||
let mut check = SelfCheck::new();
|
||||
check.implausible(Handle::default(), "Polyline", "area", bound * (1.0 + 1e-6), bound, "past the bound");
|
||||
assert_eq!(check.implausible.len(), 1, "a real excess is still flagged: {}", check.to_json());
|
||||
|
||||
// A bound that is not finite means no bound applies: the value is not flagged.
|
||||
let mut check = SelfCheck::new();
|
||||
check.implausible(Handle::default(), "Region", "area", 1e9, f64::INFINITY, "past the bound");
|
||||
assert!(check.implausible.is_empty(), "an infinite bound applies to nothing: {}", check.to_json());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use cadcraft_geom::{Mat3, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use super::SelfCheck;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Merges done while expanding nested references; a hostile file stops here (and says so).
|
||||
@@ -20,12 +21,15 @@ const MAX_HANDLES: usize = 100_000;
|
||||
/// COUNT's JSON parameters.
|
||||
pub(super) const PARAMS: &str = "{block?, object?: handle (count objects like this one), space?: current|model|all|<layout> (default current), \
|
||||
handles?: [...] (count in these objects), nested?: bool (default true), highlight?: bool} → \
|
||||
{blocks: {name: n}, nested: {name: n}, spaces: {space: {name: n}}, total} | {block, count, nested, handles}, plus `message`";
|
||||
{blocks: {name: n}, nested: {name: n}, spaces: {space: {name: n}}, total} | {block, count, nested, handles}, plus `message` and \
|
||||
`selfCheck` (found/analyzed/skipped counts with reasons, and the caps hit: work, handles, blockDepth)";
|
||||
|
||||
/// COUNT's interactive command-line prompt machine.
|
||||
pub(super) fn machine() -> Box<dyn Interactive> {
|
||||
Box::new(CountM)
|
||||
}
|
||||
|
||||
/// COUNTTABLE's interactive command-line prompt machine (it asks for the insertion point).
|
||||
pub(super) fn table_machine() -> Box<dyn Interactive> {
|
||||
Box::new(CountTableM)
|
||||
}
|
||||
@@ -109,11 +113,14 @@ struct Expander<'a> {
|
||||
memo: HashMap<(String, usize), Rc<BTreeMap<String, u64>>>,
|
||||
work: u64,
|
||||
truncated: bool,
|
||||
/// A block at the end of the depth cap still held references.
|
||||
depth_capped: bool,
|
||||
}
|
||||
|
||||
impl<'a> Expander<'a> {
|
||||
/// An expander that has not traversed any block yet.
|
||||
fn new(d: &'a Drawing) -> Self {
|
||||
Expander { d, memo: HashMap::new(), work: 0, truncated: false }
|
||||
Expander { d, memo: HashMap::new(), work: 0, truncated: false, depth_capped: false }
|
||||
}
|
||||
/// The definition's own name for a referenced block (names are case-insensitive).
|
||||
fn canonical(&self, name: &str) -> String {
|
||||
@@ -125,10 +132,17 @@ impl<'a> Expander<'a> {
|
||||
if let Some(m) = self.memo.get(&key) {
|
||||
return m.clone();
|
||||
}
|
||||
if levels == 0 {
|
||||
// More references than MAX_BLOCK_DEPTH covers: say so rather than silently stop.
|
||||
if self.d.block(name).is_some_and(|b| b.entities.iter().any(|e| matches!(&e.kind, EntityKind::Insert(_)))) {
|
||||
self.depth_capped = true;
|
||||
}
|
||||
let out = Rc::new(BTreeMap::new());
|
||||
self.memo.insert(key, out.clone());
|
||||
return out;
|
||||
}
|
||||
let mut out: BTreeMap<String, u64> = BTreeMap::new();
|
||||
if levels > 0
|
||||
&& let Some(b) = self.d.block(name)
|
||||
{
|
||||
if let Some(b) = self.d.block(name) {
|
||||
let b = b.clone();
|
||||
for e in b.entities.iter() {
|
||||
let EntityKind::Insert(i) = &e.kind else { continue };
|
||||
@@ -173,6 +187,14 @@ struct Tally {
|
||||
/// Top-level objects holding `want` (when one block is asked for).
|
||||
handles: Vec<Handle>,
|
||||
truncated: bool,
|
||||
/// Top-level objects scanned, and the block references among them.
|
||||
found: u64,
|
||||
analyzed: u64,
|
||||
/// Top-level objects that are not block references, by type.
|
||||
skipped: BTreeMap<&'static str, u64>,
|
||||
/// More top-level objects held the block than `MAX_HANDLES`.
|
||||
handles_capped: bool,
|
||||
depth_capped: bool,
|
||||
}
|
||||
|
||||
/// Count block references in `scope`; with `want`, also collect the top-level objects holding it.
|
||||
@@ -180,7 +202,13 @@ fn tally(d: &Drawing, scope: &Scope, nested: bool, want: Option<&str>) -> Tally
|
||||
let mut x = Expander::new(d);
|
||||
let mut t = Tally::default();
|
||||
for (space, e) in scope.objects(d) {
|
||||
let EntityKind::Insert(i) = &e.kind else { continue };
|
||||
t.found = t.found.saturating_add(1);
|
||||
let EntityKind::Insert(i) = &e.kind else {
|
||||
let n = t.skipped.entry(e.kind.type_name()).or_default();
|
||||
*n = n.saturating_add(1);
|
||||
continue;
|
||||
};
|
||||
t.analyzed = t.analyzed.saturating_add(1);
|
||||
let name = x.canonical(&i.block);
|
||||
let m = copies(i);
|
||||
let mut holds = want.is_some_and(|w| w.eq_ignore_ascii_case(&name));
|
||||
@@ -199,15 +227,41 @@ fn tally(d: &Drawing, scope: &Scope, nested: bool, want: Option<&str>) -> Tally
|
||||
holds |= want.is_some_and(|w| w.eq_ignore_ascii_case(k));
|
||||
}
|
||||
}
|
||||
if holds && t.handles.len() < MAX_HANDLES {
|
||||
t.handles.push(e.handle);
|
||||
if holds {
|
||||
if t.handles.len() < MAX_HANDLES {
|
||||
t.handles.push(e.handle);
|
||||
} else {
|
||||
t.handles_capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
t.spaces.retain(|_, m| !m.is_empty());
|
||||
t.truncated = x.truncated;
|
||||
t.depth_capped = x.depth_capped;
|
||||
t
|
||||
}
|
||||
|
||||
/// The self-check for a COUNT-style result: what was scanned, counted and skipped, and the caps
|
||||
/// that stopped it.
|
||||
fn self_check(t: &Tally) -> Value {
|
||||
let mut check = SelfCheck::new();
|
||||
check.found(t.found);
|
||||
check.analyzed(t.analyzed);
|
||||
for (ty, n) in &t.skipped {
|
||||
check.skip(ty, "not a block reference", *n);
|
||||
}
|
||||
if t.truncated {
|
||||
check.cap("work", MAX_WORK);
|
||||
}
|
||||
if t.handles_capped {
|
||||
check.cap("handles", u64::try_from(MAX_HANDLES).unwrap_or(u64::MAX));
|
||||
}
|
||||
if t.depth_capped {
|
||||
check.cap("blockDepth", u64::try_from(MAX_BLOCK_DEPTH).unwrap_or(u64::MAX));
|
||||
}
|
||||
check.to_json()
|
||||
}
|
||||
|
||||
/// A translation-free signature of an object's geometry: objects with equal keys are copies of
|
||||
/// each other (moved, not rotated or scaled). Coordinates are rounded to 1e-6.
|
||||
fn shape_key(k: &EntityKind) -> Option<String> {
|
||||
@@ -262,7 +316,15 @@ fn list(t: &Tally, scope: &Scope) -> Value {
|
||||
m
|
||||
};
|
||||
let total = t.blocks.values().fold(0u64, |a, b| a.saturating_add(*b));
|
||||
json!({ "blocks": t.blocks, "nested": t.nested, "spaces": t.spaces, "total": total, "truncated": t.truncated, "message": message })
|
||||
json!({
|
||||
"blocks": t.blocks,
|
||||
"nested": t.nested,
|
||||
"spaces": t.spaces,
|
||||
"total": total,
|
||||
"truncated": t.truncated,
|
||||
"selfCheck": self_check(t),
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
/// Count block `b` (its references, nested ones included) in `scope`.
|
||||
@@ -273,7 +335,15 @@ fn count_block(d: &Drawing, scope: &Scope, nested: bool, b: &str) -> Value {
|
||||
let k = t.nested.get(&name).copied().unwrap_or(0);
|
||||
let wh = scope.label();
|
||||
let message = if k > 0 { format!("Block {name}: {n} in {wh} ({k} nested).") } else { format!("Block {name}: {n} in {wh}.") };
|
||||
json!({ "block": name, "count": n, "nested": k, "handles": hex(&t.handles), "truncated": t.truncated, "message": message })
|
||||
json!({
|
||||
"block": name,
|
||||
"count": n,
|
||||
"nested": k,
|
||||
"handles": hex(&t.handles),
|
||||
"truncated": t.truncated,
|
||||
"selfCheck": self_check(&t),
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
/// Count the objects in `scope` shaped like `h` (a block reference: references of its block).
|
||||
@@ -284,18 +354,35 @@ fn count_like(d: &Drawing, scope: &Scope, nested: bool, h: Handle) -> Result<Val
|
||||
}
|
||||
let ty = e.kind.type_name();
|
||||
let key = shape_key(&e.kind).ok_or_else(|| bad("count", format!("a {ty} can't be counted by shape")))?;
|
||||
let hs: Vec<Handle> = scope
|
||||
.objects(d)
|
||||
.into_iter()
|
||||
.filter(|(_, o)| o.kind.type_name() == ty && shape_key(&o.kind).as_deref() == Some(key.as_str()))
|
||||
.map(|(_, o)| o.handle)
|
||||
.take(MAX_HANDLES)
|
||||
.collect();
|
||||
let mut check = SelfCheck::new();
|
||||
let mut hs: Vec<Handle> = Vec::new();
|
||||
let mut capped = false;
|
||||
for (_, o) in scope.objects(d) {
|
||||
check.found(1);
|
||||
if o.kind.type_name() != ty {
|
||||
check.skip(o.kind.type_name(), "a different type", 1);
|
||||
continue;
|
||||
}
|
||||
check.analyzed(1);
|
||||
if shape_key(&o.kind).as_deref() == Some(key.as_str()) {
|
||||
if hs.len() < MAX_HANDLES {
|
||||
hs.push(o.handle);
|
||||
} else {
|
||||
capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if capped {
|
||||
check.cap("handles", u64::try_from(MAX_HANDLES).unwrap_or(u64::MAX));
|
||||
}
|
||||
let n = hs.len();
|
||||
let message = format!("{ty}: {n} like the selected one in {}.", scope.label());
|
||||
Ok(json!({ "object": ty, "count": n, "handles": hex(&hs), "message": message }))
|
||||
Ok(json!({ "object": ty, "count": n, "handles": hex(&hs), "selfCheck": check.to_json(), "message": message }))
|
||||
}
|
||||
|
||||
/// The body of COUNT and COUNTLIST: list the blocks in scope, count one block, or count the
|
||||
/// objects shaped like a picked one. `default_all` makes an omitted `space` mean every space
|
||||
/// (COUNTLIST) instead of the current one (COUNT).
|
||||
fn run(s: &mut Session, cmd: &str, p: &Value, default_all: bool) -> Result<Value> {
|
||||
let scope = Scope::parse(s, cmd, p, default_all)?;
|
||||
let nested = bool_or(p, "nested", true);
|
||||
@@ -336,6 +423,8 @@ fn table_style(s: &Session) -> cadcraft_doc::TableStyle {
|
||||
d.table_styles.iter().find(|t| t.name.eq_ignore_ascii_case(&cur)).or(d.table_styles.first()).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// COUNTTABLE: insert a table of the block counts in scope at `at`, and report what the tally
|
||||
/// covered (and refused: no block references in scope is an error, not an empty table).
|
||||
pub(super) fn run_counttable(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "counttable";
|
||||
let at = point_req(CMD, p, "at")?;
|
||||
@@ -348,9 +437,15 @@ pub(super) fn run_counttable(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let header = Some(vec!["Name".to_string(), "Count".to_string()]);
|
||||
let table = super::super::table::make_table(&table_style(s), at, cells.len(), 2, None, None, Some("Block Count"), header, &cells);
|
||||
let h = s.add_entity(EntityKind::Table(table))?;
|
||||
Ok(json!({ "handle": h.hex(), "rows": cells.len(), "message": format!("Table of {} block(s) inserted.", cells.len()) }))
|
||||
Ok(json!({
|
||||
"handle": h.hex(),
|
||||
"rows": cells.len(),
|
||||
"selfCheck": self_check(&t),
|
||||
"message": format!("Table of {} block(s) inserted.", cells.len()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Echo a result's `message` at the command line, one line at a time.
|
||||
fn echo_lines(s: &mut Session, r: &Value) {
|
||||
if let Some(m) = r.get("message").and_then(Value::as_str) {
|
||||
for l in m.lines() {
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
//! LOOPS: implied closed loops from loose LINE and ARC chains — perimeter, area and winding for
|
||||
//! each closed loop, the gap and its location for each open chain, and branch junctions (three or
|
||||
//! more ends meeting) reported but not resolved. Read-only.
|
||||
//!
|
||||
//! Boundaries are often drawn as separate LINEs and ARCs rather than one polyline, so an agent
|
||||
//! cannot tell whether they form a closed loop or by how much a "closed" outline is open. The
|
||||
//! endpoint chaining is quadratic in the number of endpoints; past `maxPairs` it reports `capped`
|
||||
//! and analyzes nothing rather than guess from a partial pairing.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::{Arc as GeoArc, EPS, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CMD: &str = "loops";
|
||||
|
||||
/// Endpoint pairs compared before the quadratic chaining gives up (reported as `capped`).
|
||||
const DEFAULT_PAIR_LIMIT: usize = 1_000_000;
|
||||
/// The most pairs a caller may ask for: a bounded amount of work however large the drawing.
|
||||
const MAX_PAIR_LIMIT: usize = 100_000_000;
|
||||
/// Loops returned, and open chains and junctions kept, by default.
|
||||
const DEFAULT_LIMIT: usize = 200;
|
||||
/// The smallest default tolerance, for a drawing at the origin.
|
||||
const MIN_TOLERANCE: f64 = 1e-12;
|
||||
|
||||
pub const PARAMS: &str = "{tolerance?: positive number (default 1e-9 × the extents diagonal), maxPairs? (endpoint pairs compared, default 1000000, at most 100000000), limit? (default 200), offset?} → \
|
||||
{schema: 1, tolerance, pairTests, pairTestsNeeded, pairLimit, capped, count (all closed loops), returned, offset, truncated, \
|
||||
loops: [{members: [handle], perimeter, area, winding: ccw|cw}], \
|
||||
openChains: {count, truncated, chains: [{members, gap, at: [x, y], ends: [[x, y], [x, y]], length}]}, \
|
||||
junctions: {count, truncated, junctions: [{at: [x, y], ends, members}]}, skipped: {<type>: {count, reason}}}: \
|
||||
chains LINE and ARC endpoints that meet within the tolerance; junctions (three or more ends meeting) are reported, not resolved";
|
||||
|
||||
/// Why a LINE or ARC cannot take part in a chain.
|
||||
type Skipped = BTreeMap<&'static str, (usize, BTreeSet<&'static str>)>;
|
||||
|
||||
/// One chainable entity, oriented as drawn (a line a→b, an arc CCW start→end).
|
||||
struct Piece {
|
||||
handle: Handle,
|
||||
start: Vec2,
|
||||
end: Vec2,
|
||||
/// The arc, when the piece is one.
|
||||
arc: Option<GeoArc>,
|
||||
}
|
||||
|
||||
impl Piece {
|
||||
fn at(&self, which: usize) -> Vec2 {
|
||||
if which == 0 { self.start } else { self.end }
|
||||
}
|
||||
fn length(&self) -> f64 {
|
||||
match self.arc {
|
||||
Some(a) => a.radius * a.sweep(),
|
||||
None => self.start.dist(self.end),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity endpoint in the chain graph. Each piece has one at index `2 * piece` and one at
|
||||
/// `2 * piece + 1`.
|
||||
#[derive(Clone, Copy)]
|
||||
struct End {
|
||||
piece: usize,
|
||||
at: Vec2,
|
||||
}
|
||||
|
||||
/// A piece traversed one way round a chain.
|
||||
struct Traversed {
|
||||
start: Vec2,
|
||||
end: Vec2,
|
||||
/// The arc and whether the traversal follows its CCW direction.
|
||||
arc: Option<(GeoArc, bool)>,
|
||||
}
|
||||
|
||||
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
|
||||
let count = |k: &str, default: usize| -> Result<usize> {
|
||||
given(k).map_or(Ok(default), |v| {
|
||||
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
|
||||
})
|
||||
};
|
||||
let limit = count("limit", DEFAULT_LIMIT)?;
|
||||
let offset = count("offset", 0)?;
|
||||
let pair_limit = count("maxPairs", DEFAULT_PAIR_LIMIT)?.min(MAX_PAIR_LIMIT);
|
||||
let tolerance = given("tolerance")
|
||||
.map(|v| v.as_f64().filter(|t| t.is_finite() && *t > 0.0).ok_or_else(|| bad(CMD, "`tolerance` must be a positive number")))
|
||||
.transpose()?;
|
||||
let s: &Session = s;
|
||||
let st = s.state()?;
|
||||
let d = &st.doc;
|
||||
let tol = tolerance.unwrap_or_else(|| {
|
||||
let ext = d.extents(&st.space);
|
||||
if ext.is_empty() {
|
||||
return MIN_TOLERANCE;
|
||||
}
|
||||
let diag = ext.min.dist(ext.max);
|
||||
if diag.is_finite() { (diag * 1e-9).max(MIN_TOLERANCE) } else { MIN_TOLERANCE }
|
||||
});
|
||||
let mut skipped: Skipped = BTreeMap::new();
|
||||
let Some(store) = d.space(&st.space) else {
|
||||
return Ok(empty_report(offset, tol, 0, 0, pair_limit, false, skipped));
|
||||
};
|
||||
let pieces: Vec<Piece> = store.iter().filter_map(|e| chainable(e.handle, &e.kind, &mut skipped)).collect();
|
||||
let mut ends: Vec<End> = Vec::with_capacity(pieces.len().saturating_mul(2));
|
||||
for (i, piece) in pieces.iter().enumerate() {
|
||||
ends.push(End { piece: i, at: piece.start });
|
||||
ends.push(End { piece: i, at: piece.end });
|
||||
}
|
||||
let n = ends.len();
|
||||
let needed = n.saturating_mul(n.saturating_sub(1)) / 2;
|
||||
if needed > pair_limit {
|
||||
return Ok(empty_report(offset, tol, 0, needed, pair_limit, true, skipped));
|
||||
}
|
||||
|
||||
// Endpoints closer than the tolerance meet. Transitive components of three or more ends are
|
||||
// branch junctions; a component of exactly two is a simple joint.
|
||||
let mut parent: Vec<usize> = (0..n).collect();
|
||||
for i in 0..n {
|
||||
let Some(a) = ends.get(i) else { continue };
|
||||
for j in (i + 1)..n {
|
||||
let Some(b) = ends.get(j) else { continue };
|
||||
if a.at.dist(b.at) <= tol {
|
||||
let (ra, rb) = (find(&mut parent, i), find(&mut parent, j));
|
||||
if ra != rb
|
||||
&& let Some(slot) = parent.get_mut(ra)
|
||||
{
|
||||
*slot = rb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
for i in 0..n {
|
||||
let root = find(&mut parent, i);
|
||||
groups.entry(root).or_default().push(i);
|
||||
}
|
||||
|
||||
// Junctions are reported and their pieces left out of the chains: not resolved.
|
||||
let mut excluded = vec![false; pieces.len()];
|
||||
let mut junctions: Vec<Value> = Vec::new();
|
||||
let mut joints: Vec<(usize, usize)> = Vec::new();
|
||||
for group in groups.values() {
|
||||
match group.as_slice() {
|
||||
&[a, b] => joints.push((a, b)),
|
||||
[_, _, ..] => {
|
||||
let scale = group.len() as f64;
|
||||
let mut at = Vec2::ZERO;
|
||||
let mut members: Vec<Handle> = Vec::new();
|
||||
for &e in group {
|
||||
let Some(end) = ends.get(e) else { continue };
|
||||
at += end.at / scale;
|
||||
let Some(piece) = pieces.get(end.piece) else { continue };
|
||||
if !members.contains(&piece.handle) {
|
||||
members.push(piece.handle);
|
||||
}
|
||||
if let Some(slot) = excluded.get_mut(end.piece) {
|
||||
*slot = true;
|
||||
}
|
||||
}
|
||||
junctions.push(json!({
|
||||
"at": [at.x, at.y],
|
||||
"ends": group.len(),
|
||||
"members": members.iter().map(|h| h.hex()).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// A joint links the two ends for the walk. A joint into an excluded piece is not a link, and
|
||||
// a line whose own ends meet within a huge tolerance is a spur, not a zero-area loop.
|
||||
let mut link: Vec<Option<usize>> = vec![None; n];
|
||||
for (a, b) in joints {
|
||||
let (Some(ea), Some(eb)) = (ends.get(a), ends.get(b)) else { continue };
|
||||
if ea.piece == eb.piece && pieces.get(ea.piece).is_some_and(|p| p.arc.is_none()) {
|
||||
continue;
|
||||
}
|
||||
if excluded.get(ea.piece).copied().unwrap_or(true) || excluded.get(eb.piece).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
if let Some(slot) = link.get_mut(a) {
|
||||
*slot = Some(b);
|
||||
}
|
||||
if let Some(slot) = link.get_mut(b) {
|
||||
*slot = Some(a);
|
||||
}
|
||||
}
|
||||
|
||||
let mut visited = vec![false; pieces.len()];
|
||||
let mut loops: Vec<Value> = Vec::new();
|
||||
let mut open: Vec<Value> = Vec::new();
|
||||
// Paths first: a free end marks a chain. What is left with both ends linked is a cycle.
|
||||
open_chains(&pieces, &excluded, &link, &mut visited, &mut open);
|
||||
closed_loops(&pieces, &excluded, &link, &mut visited, &mut loops);
|
||||
|
||||
let total = loops.len();
|
||||
let page: Vec<Value> = loops.into_iter().skip(offset).take(limit).collect();
|
||||
let returned = page.len();
|
||||
let truncated = returned < total.saturating_sub(offset);
|
||||
let open_total = open.len();
|
||||
let open_page: Vec<Value> = open.into_iter().take(limit).collect();
|
||||
let open_truncated = open_page.len() < open_total;
|
||||
let junction_total = junctions.len();
|
||||
let junction_page: Vec<Value> = junctions.into_iter().take(limit).collect();
|
||||
let junction_truncated = junction_page.len() < junction_total;
|
||||
Ok(json!({
|
||||
"schema": 1,
|
||||
"tolerance": tol,
|
||||
"pairTests": needed,
|
||||
"pairTestsNeeded": needed,
|
||||
"pairLimit": pair_limit,
|
||||
"capped": false,
|
||||
"count": total,
|
||||
"returned": returned,
|
||||
"offset": offset,
|
||||
"truncated": truncated,
|
||||
"loops": page,
|
||||
"openChains": { "count": open_total, "truncated": open_truncated, "chains": open_page },
|
||||
"junctions": { "count": junction_total, "truncated": junction_truncated, "junctions": junction_page },
|
||||
"skipped": skipped_json(skipped),
|
||||
}))
|
||||
}
|
||||
|
||||
/// A report with nothing analyzed: an empty drawing, or one past the pair cap.
|
||||
fn empty_report(offset: usize, tol: f64, pair_tests: usize, pair_tests_needed: usize, pair_limit: usize, capped: bool, skipped: Skipped) -> Value {
|
||||
json!({
|
||||
"schema": 1,
|
||||
"tolerance": tol,
|
||||
"pairTests": pair_tests,
|
||||
"pairTestsNeeded": pair_tests_needed,
|
||||
"pairLimit": pair_limit,
|
||||
"capped": capped,
|
||||
"count": 0,
|
||||
"returned": 0,
|
||||
"offset": offset,
|
||||
"truncated": false,
|
||||
"loops": [],
|
||||
"openChains": { "count": 0, "truncated": false, "chains": [] },
|
||||
"junctions": { "count": 0, "truncated": false, "junctions": [] },
|
||||
"skipped": skipped_json(skipped),
|
||||
})
|
||||
}
|
||||
|
||||
fn skipped_json(skipped: Skipped) -> Value {
|
||||
let m: BTreeMap<&str, Value> =
|
||||
skipped.into_iter().map(|(k, (n, why))| (k, json!({ "count": n, "reason": why.into_iter().collect::<Vec<_>>().join("; ") }))).collect();
|
||||
json!(m)
|
||||
}
|
||||
|
||||
fn skip(skipped: &mut Skipped, ty: &'static str, why: &'static str) {
|
||||
let entry = skipped.entry(ty).or_default();
|
||||
entry.0 += 1;
|
||||
entry.1.insert(why);
|
||||
}
|
||||
|
||||
/// The piece for a LINE or ARC, or `None` when the entity is not chainable.
|
||||
fn chainable(handle: Handle, kind: &EntityKind, skipped: &mut Skipped) -> Option<Piece> {
|
||||
match kind {
|
||||
EntityKind::Line(l) => {
|
||||
let (a, b) = (l.a.xy(), l.b.xy());
|
||||
if !a.is_finite() || !b.is_finite() || a.dist(b) <= EPS {
|
||||
skip(skipped, "Line", "zero length");
|
||||
return None;
|
||||
}
|
||||
Some(Piece { handle, start: a, end: b, arc: None })
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
let (c, r) = (a.center.xy(), a.radius.abs());
|
||||
if !c.is_finite() || !a.radius.is_finite() || r <= EPS || !a.start.is_finite() || !a.end.is_finite() {
|
||||
skip(skipped, "Arc", "degenerate");
|
||||
return None;
|
||||
}
|
||||
let arc = GeoArc::new(c, r, a.start, a.end);
|
||||
let (start, end) = (arc.start_point(), arc.end_point());
|
||||
if !start.is_finite() || !end.is_finite() {
|
||||
skip(skipped, "Arc", "degenerate");
|
||||
return None;
|
||||
}
|
||||
Some(Piece { handle, start, end, arc: Some(arc) })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find(parent: &mut [usize], mut x: usize) -> usize {
|
||||
while let Some(&p) = parent.get(x) {
|
||||
if p == x {
|
||||
return x;
|
||||
}
|
||||
let gp = parent.get(p).copied().unwrap_or(p);
|
||||
if let Some(slot) = parent.get_mut(x) {
|
||||
*slot = gp;
|
||||
}
|
||||
x = gp;
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Walk the cycles left after the open paths from a free end, and report their geometry.
|
||||
fn closed_loops(pieces: &[Piece], excluded: &[bool], link: &[Option<usize>], visited: &mut [bool], out: &mut Vec<Value>) {
|
||||
for i in 0..pieces.len() {
|
||||
if visited.get(i).copied().unwrap_or(true) || excluded.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
// Only a piece linked at both ends can be in a cycle.
|
||||
if link.get(i * 2).copied().flatten().is_none() || link.get(i * 2 + 1).copied().flatten().is_none() {
|
||||
continue;
|
||||
}
|
||||
let mut members: Vec<usize> = Vec::new();
|
||||
let mut forward: Vec<bool> = Vec::new();
|
||||
let (mut cur, mut entry) = (i, 0usize);
|
||||
let mut closed = false;
|
||||
loop {
|
||||
if visited.get(cur).copied().unwrap_or(true) || members.len() >= pieces.len() {
|
||||
break;
|
||||
}
|
||||
if let Some(slot) = visited.get_mut(cur) {
|
||||
*slot = true;
|
||||
}
|
||||
members.push(cur);
|
||||
forward.push(entry == 0);
|
||||
let exit = 1 - entry;
|
||||
let Some(q) = link.get(cur * 2 + exit).copied().flatten() else { break };
|
||||
let (next, which) = (q / 2, q % 2);
|
||||
if next == i && which == 0 {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if visited.get(next).copied().unwrap_or(true) {
|
||||
break;
|
||||
}
|
||||
cur = next;
|
||||
entry = which;
|
||||
}
|
||||
if !closed || members.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let segs = traversed(pieces, &members, &forward);
|
||||
let (perimeter, area, signed) = facts(&segs);
|
||||
out.push(json!({
|
||||
"members": members.iter().filter_map(|&m| pieces.get(m)).map(|p| p.handle.hex()).collect::<Vec<_>>(),
|
||||
"perimeter": perimeter,
|
||||
"area": area,
|
||||
"winding": if signed < 0.0 { "cw" } else { "ccw" },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk paths from every free end: an open chain, with the gap between its two ends.
|
||||
fn open_chains(pieces: &[Piece], excluded: &[bool], link: &[Option<usize>], visited: &mut [bool], out: &mut Vec<Value>) {
|
||||
for i in 0..pieces.len() {
|
||||
if visited.get(i).copied().unwrap_or(true) || excluded.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Some(entry0) = [0usize, 1].into_iter().find(|w| link.get(i * 2 + w).copied().flatten().is_none()) else { continue };
|
||||
let mut members: Vec<usize> = Vec::new();
|
||||
let mut forward: Vec<bool> = Vec::new();
|
||||
let (mut cur, mut entry) = (i, entry0);
|
||||
let mut exit = entry0;
|
||||
loop {
|
||||
if visited.get(cur).copied().unwrap_or(true) || members.len() >= pieces.len() {
|
||||
break;
|
||||
}
|
||||
if let Some(slot) = visited.get_mut(cur) {
|
||||
*slot = true;
|
||||
}
|
||||
members.push(cur);
|
||||
forward.push(entry == 0);
|
||||
exit = 1 - entry;
|
||||
let Some(q) = link.get(cur * 2 + exit).copied().flatten() else { break };
|
||||
let (next, which) = (q / 2, q % 2);
|
||||
if visited.get(next).copied().unwrap_or(true) {
|
||||
break;
|
||||
}
|
||||
cur = next;
|
||||
entry = which;
|
||||
}
|
||||
let (Some(first_piece), Some(last_piece)) = (pieces.get(i), members.last().and_then(|&m| pieces.get(m))) else { continue };
|
||||
let (first, last) = (first_piece.at(entry0), last_piece.at(exit));
|
||||
let gap = first.dist(last);
|
||||
let at = first * 0.5 + last * 0.5;
|
||||
let length: f64 = members.iter().filter_map(|&m| pieces.get(m)).map(Piece::length).sum();
|
||||
out.push(json!({
|
||||
"members": members.iter().filter_map(|&m| pieces.get(m)).map(|p| p.handle.hex()).collect::<Vec<_>>(),
|
||||
"gap": gap,
|
||||
"at": [at.x, at.y],
|
||||
"ends": [[first.x, first.y], [last.x, last.y]],
|
||||
"length": length,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn traversed(pieces: &[Piece], members: &[usize], forward: &[bool]) -> Vec<Traversed> {
|
||||
members
|
||||
.iter()
|
||||
.zip(forward)
|
||||
.filter_map(|(&m, &fwd)| {
|
||||
let p = pieces.get(m)?;
|
||||
Some(Traversed { start: if fwd { p.start } else { p.end }, end: if fwd { p.end } else { p.start }, arc: p.arc.map(|a| (a, fwd)) })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Perimeter, unsigned area and signed area (CCW positive, as SHAPES reports winding) of a closed
|
||||
/// chain of lines and arcs. The closing chord between the last end and the first is the chain's gap
|
||||
/// and counts in the perimeter, as an open polyline with matching ends does in SHAPES.
|
||||
fn facts(segs: &[Traversed]) -> (f64, f64, f64) {
|
||||
let Some(first) = segs.first() else { return (0.0, 0.0, 0.0) };
|
||||
let perimeter: f64 = segs.iter().map(seg_len).sum::<f64>() + segs.last().map_or(0.0, |last| last.end.dist(first.start));
|
||||
let signed = signed_area(segs, first.start);
|
||||
(perimeter, signed.abs(), signed)
|
||||
}
|
||||
|
||||
fn seg_len(t: &Traversed) -> f64 {
|
||||
match t.arc {
|
||||
Some((a, _)) => a.radius * a.sweep(),
|
||||
None => t.start.dist(t.end),
|
||||
}
|
||||
}
|
||||
|
||||
/// Green's theorem over the closed path, measured about its first point so a small loop far from
|
||||
/// the origin keeps its area. An arc contributes its exact circular integral, not a tessellation.
|
||||
fn signed_area(segs: &[Traversed], origin: Vec2) -> f64 {
|
||||
let mut twice = 0.0;
|
||||
for t in segs {
|
||||
match t.arc {
|
||||
None => twice += (t.start - origin).cross(t.end - origin),
|
||||
Some((a, forward)) => {
|
||||
let sweep = a.sweep();
|
||||
let (from, to, d) = if forward { (a.start, a.start + sweep, sweep) } else { (a.end, a.end - sweep, -sweep) };
|
||||
let c = a.center - origin;
|
||||
twice += a.radius * a.radius * d + a.radius * (c.x * (to.sin() - from.sin()) - c.y * (to.cos() - from.cos()));
|
||||
}
|
||||
}
|
||||
}
|
||||
twice / 2.0
|
||||
}
|
||||
@@ -12,8 +12,10 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::super::*;
|
||||
use super::SelfCheck;
|
||||
use crate::{EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// The region commands: UNION, SUBTRACT and INTERSECT combine regions, MASSPROP measures them.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("union", "Union", run_union)
|
||||
@@ -33,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.interactive(|_| Ok(Box::new(SelectRun::new("intersect", "INTERSECT")))),
|
||||
CommandSpec::new("massprop", "Region/Mass Properties", run_massprop)
|
||||
.menu(&["Tools", "Inquiry", "Region/Mass Properties"])
|
||||
.params("{handles?} (regions) → properties of each")
|
||||
.params("{handles?} (regions) → properties of each, plus `selfCheck` (found/analyzed/skipped with reasons, caps, implausible values)")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(SelectRun::new("massprop", "MASSPROP")))),
|
||||
]
|
||||
@@ -211,13 +213,16 @@ impl Interactive for SubtractM {
|
||||
fn name(&self) -> &'static str {
|
||||
"SUBTRACT"
|
||||
}
|
||||
/// Tell the user what to select first: the regions to subtract from.
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.echo("Select regions to subtract from ..");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
/// Prompt for the current selection phase (regions to subtract from, then regions to remove).
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
self.sel.prompt()
|
||||
}
|
||||
/// The first selection is the regions to subtract from; the second is subtracted from them.
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
@@ -243,18 +248,38 @@ impl Interactive for SubtractM {
|
||||
}
|
||||
}
|
||||
|
||||
/// MASSPROP: area, perimeter, bounding box, centroid, moments of inertia and principal directions
|
||||
/// for each selected region, plus a self-check of what was measured, skipped and found
|
||||
/// implausible.
|
||||
fn run_massprop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let rs = regions(s, &hs)?;
|
||||
if rs.is_empty() {
|
||||
return Err(other("Select one or more regions."));
|
||||
}
|
||||
let mut check = SelfCheck::new();
|
||||
let d = s.doc()?;
|
||||
let extents = d.extents(&s.space());
|
||||
// No visible extents means no bound to compare an area against (`implausible` skips an
|
||||
// infinite bound).
|
||||
let extents_area = if extents.is_empty() { f64::INFINITY } else { extents.width() * extents.height() };
|
||||
for h in &hs {
|
||||
check.found(1);
|
||||
match d.entity(*h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Region(_)) => check.analyzed(1),
|
||||
Some(kind) => check.skip(kind.type_name(), "not a region", 1),
|
||||
None => check.skip("Missing", "no such object", 1),
|
||||
}
|
||||
}
|
||||
let f = |v: f64| super::fmt_d(s, v);
|
||||
let mut out = Vec::new();
|
||||
let mut lines = Vec::new();
|
||||
for (h, r) in &rs {
|
||||
let m = r.props();
|
||||
let c = m.centroid;
|
||||
let diagonal = if m.bounds.is_empty() { 0.0 } else { m.bounds.min.dist(m.bounds.max) };
|
||||
check.implausible(*h, "Region", "perimeter", m.perimeter, diagonal * 10.0, "perimeter is more than 10 x the bounding-box diagonal");
|
||||
check.implausible(*h, "Region", "area", m.area, extents_area, "area is larger than the drawing extents");
|
||||
// Moments about the centroid, and the principal moments and directions.
|
||||
let [ixc, iyc, ixyc] = m.centroid_moments;
|
||||
let mid = (ixc + iyc) / 2.0;
|
||||
@@ -292,5 +317,5 @@ fn run_massprop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ang.cos(),
|
||||
));
|
||||
}
|
||||
Ok(json!({ "regions": out, "message": lines.join("\n") }))
|
||||
Ok(json!({ "regions": out, "selfCheck": check.to_json(), "message": lines.join("\n") }))
|
||||
}
|
||||
|
||||
@@ -2,26 +2,37 @@
|
||||
//! winding, the first self-intersection and coincident vertices — for agents that read a drawing.
|
||||
//! Read-only.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use cadcraft_doc::{Entity, EntityKind};
|
||||
use cadcraft_geom::{Bounds2, Polyline, Vec2, intersect_segments};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use super::{area_of, object_area};
|
||||
use super::{SelfCheck, area_of, object_area};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CMD: &str = "shapes";
|
||||
|
||||
pub const PARAMS: &str = "{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit? (default 200), offset?, tolerance?: positive number (default 1e-9 × the extents diagonal; near-duplicate vertices and a first vertex equal to the last)} → {schema: 1, count (all closed shapes), returned, offset, truncated, shapes: [{handle, type, layer, perimeter, area (≥ 0), winding: ccw|cw, centroid: [x,y] | null (zero area), selfIntersection: [x,y] | null (the first crossing of two non-adjacent edges; null past 4000 edges), duplicateVertices: [{index, distance} (vertices index and index+1, wrapping)]}], skipped: {<type>: {count, reason}} (the other matches)}: closed polylines, circles, full ellipses and closed splines, arcs and bulges exact";
|
||||
pub const PARAMS: &str = "{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit? (default 200), offset?, tolerance?: positive number (default 1e-9 × the extents diagonal; near-duplicate vertices and a first vertex equal to the last)} → {schema: 1, count (all closed shapes), returned, offset, truncated, shapes: [{handle, type, layer, perimeter, area (≥ 0), winding: ccw|cw, centroid: [x,y] | null (zero area), selfIntersection: [x,y] | null (the first crossing of two non-adjacent edges; null past 4000 edges), duplicateVertices: [{index, distance} (vertices index and index+1, wrapping)]}], skipped: {<type>: {count, reason}} (the other matches), selfCheck: {schema, found, analyzed, skipped: {<type>: {count, reason}}, caps: {<name>: <value>}, implausible: [{handle, type, field, value, bound, reason}]} (what the analysis covered, the caps it hit, and values that point at a bug)}: closed polylines, circles, full ellipses and closed splines, arcs and bulges exact";
|
||||
|
||||
/// The facts SHAPES reports for one closed shape, or why an entity is not one.
|
||||
enum Facts {
|
||||
Shape { perimeter: f64, area: f64, signed: f64, centroid: Option<Vec2>, cross: Option<Vec2>, dups: Vec<(usize, f64)> },
|
||||
Shape {
|
||||
perimeter: f64,
|
||||
area: f64,
|
||||
signed: f64,
|
||||
centroid: Option<Vec2>,
|
||||
cross: Option<Vec2>,
|
||||
/// The shape had more edges than the self-intersection cap checks.
|
||||
cross_capped: bool,
|
||||
dups: Vec<(usize, f64)>,
|
||||
bounds: Bounds2,
|
||||
},
|
||||
Skip(&'static str),
|
||||
}
|
||||
|
||||
/// SHAPES: geometry facts for every closed shape that matches the filters in the current space,
|
||||
/// paged by `offset`/`limit`, with the self-check of what was found, analysed, skipped and
|
||||
/// flagged.
|
||||
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
|
||||
let text = |k: &str| -> Result<Option<String>> {
|
||||
@@ -52,15 +63,29 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let s: &Session = s;
|
||||
let st = s.state()?;
|
||||
let d = &st.doc;
|
||||
let empty = || json!({ "schema": 1, "count": 0, "returned": 0, "offset": offset, "truncated": false, "shapes": [], "skipped": {} });
|
||||
let empty = || {
|
||||
json!({
|
||||
"schema": 1,
|
||||
"count": 0,
|
||||
"returned": 0,
|
||||
"offset": offset,
|
||||
"truncated": false,
|
||||
"shapes": [],
|
||||
"skipped": {},
|
||||
"selfCheck": SelfCheck::new().to_json(),
|
||||
})
|
||||
};
|
||||
let Some(store) = d.space(&st.space) else { return Ok(empty()) };
|
||||
let extents = d.extents(&st.space);
|
||||
// No visible extents means no bound to compare an area against (`implausible` skips an
|
||||
// infinite bound).
|
||||
let extents_area = if extents.is_empty() { f64::INFINITY } else { extents.width() * extents.height() };
|
||||
let tol = tolerance.unwrap_or_else(|| {
|
||||
let ext = d.extents(&st.space);
|
||||
let diag = if ext.is_empty() { 0.0 } else { ext.min.dist(ext.max) };
|
||||
let diag = if extents.is_empty() { 0.0 } else { extents.min.dist(extents.max) };
|
||||
(diag * 1e-9).max(1e-12)
|
||||
});
|
||||
let wtol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0);
|
||||
let mut skipped: BTreeMap<&str, (usize, BTreeSet<&str>)> = BTreeMap::new();
|
||||
let mut check = SelfCheck::new();
|
||||
let mut shapes: Vec<(&Entity, Facts)> = Vec::new();
|
||||
for e in store
|
||||
.iter()
|
||||
@@ -68,31 +93,51 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter(|e| layer.as_ref().is_none_or(|l| e.common.layer.to_ascii_lowercase() == *l))
|
||||
.filter(|e| window.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, wtol)))
|
||||
{
|
||||
check.found(1);
|
||||
match facts(s, e, tol)? {
|
||||
Facts::Skip(why) => {
|
||||
let entry = skipped.entry(e.kind.type_name()).or_default();
|
||||
entry.0 += 1;
|
||||
entry.1.insert(why);
|
||||
Facts::Skip(why) => check.skip(e.kind.type_name(), why, 1),
|
||||
f => {
|
||||
check.analyzed(1);
|
||||
if let Facts::Shape { perimeter, area, cross_capped, bounds, .. } = &f {
|
||||
if *cross_capped {
|
||||
check.cap("crossingSegments", MAX_CROSSING_SEGMENTS as u64);
|
||||
}
|
||||
let diagonal = if bounds.is_empty() { 0.0 } else { bounds.min.dist(bounds.max) };
|
||||
check.implausible(
|
||||
e.handle,
|
||||
e.kind.type_name(),
|
||||
"perimeter",
|
||||
*perimeter,
|
||||
diagonal * 10.0,
|
||||
"perimeter is more than 10 x the bounding-box diagonal",
|
||||
);
|
||||
check.implausible(e.handle, e.kind.type_name(), "area", *area, extents_area, "area is larger than the drawing extents");
|
||||
}
|
||||
shapes.push((e, f));
|
||||
}
|
||||
f => shapes.push((e, f)),
|
||||
}
|
||||
}
|
||||
let page: Vec<Value> = shapes.iter().skip(offset).take(limit).map(|(e, f)| shape_json(e, f)).collect();
|
||||
let skipped: BTreeMap<&str, Value> =
|
||||
skipped.into_iter().map(|(k, (n, why))| (k, json!({ "count": n, "reason": why.into_iter().collect::<Vec<_>>().join("; ") }))).collect();
|
||||
let truncated = page.len() < shapes.len().saturating_sub(offset);
|
||||
if truncated {
|
||||
check.cap("limit", u64::try_from(limit).unwrap_or(u64::MAX));
|
||||
}
|
||||
let skipped = check.skipped_json();
|
||||
Ok(json!({
|
||||
"schema": 1,
|
||||
"count": shapes.len(),
|
||||
"returned": page.len(),
|
||||
"offset": offset,
|
||||
"truncated": page.len() < shapes.len().saturating_sub(offset),
|
||||
"truncated": truncated,
|
||||
"shapes": page,
|
||||
"skipped": skipped,
|
||||
"selfCheck": check.to_json(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// One analysed shape as the JSON object the command reports (null if `f` is not a shape).
|
||||
fn shape_json(e: &Entity, f: &Facts) -> Value {
|
||||
let Facts::Shape { perimeter, area, signed, centroid, cross, dups } = f else { return Value::Null };
|
||||
let Facts::Shape { perimeter, area, signed, centroid, cross, dups, .. } = f else { return Value::Null };
|
||||
json!({
|
||||
"handle": e.handle.hex(),
|
||||
"type": e.kind.type_name(),
|
||||
@@ -106,11 +151,24 @@ fn shape_json(e: &Entity, f: &Facts) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The facts SHAPES reports for one entity, or the reason it is not a closed shape: circles,
|
||||
/// full ellipses, closed or self-closing polylines and splines are measured, arcs and everything
|
||||
/// else are skipped.
|
||||
fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
|
||||
Ok(match &e.kind {
|
||||
EntityKind::Circle(c) => {
|
||||
let (area, perimeter) = object_area(s, e.handle)?;
|
||||
Facts::Shape { perimeter, area, signed: area, centroid: Some(c.center.xy()), cross: None, dups: Vec::new() }
|
||||
let r = Vec2::new(c.radius, c.radius);
|
||||
Facts::Shape {
|
||||
perimeter,
|
||||
area,
|
||||
signed: area,
|
||||
centroid: Some(c.center.xy()),
|
||||
cross: None,
|
||||
cross_capped: false,
|
||||
dups: Vec::new(),
|
||||
bounds: Bounds2::new(c.center.xy() - r, c.center.xy() + r),
|
||||
}
|
||||
}
|
||||
EntityKind::Ellipse(el) => {
|
||||
let geom = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
|
||||
@@ -118,7 +176,16 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
|
||||
return Ok(Facts::Skip("open"));
|
||||
}
|
||||
let (area, perimeter) = object_area(s, e.handle)?;
|
||||
Facts::Shape { perimeter, area, signed: area, centroid: Some(geom.center), cross: None, dups: Vec::new() }
|
||||
Facts::Shape {
|
||||
perimeter,
|
||||
area,
|
||||
signed: area,
|
||||
centroid: Some(geom.center),
|
||||
cross: None,
|
||||
cross_capped: false,
|
||||
dups: Vec::new(),
|
||||
bounds: geom.bounds(),
|
||||
}
|
||||
}
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
let mut vertices = pl.vertices.clone();
|
||||
@@ -145,7 +212,8 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
|
||||
(dist <= tol).then_some((i, dist))
|
||||
})
|
||||
.collect();
|
||||
Facts::Shape { perimeter: g.len(), area: signed.abs(), signed, centroid: g.centroid(), cross: first_crossing(&g), dups }
|
||||
let (cross, cross_capped) = first_crossing(&g);
|
||||
Facts::Shape { perimeter: g.len(), area: signed.abs(), signed, centroid: g.centroid(), cross, cross_capped, dups, bounds: g.bounds() }
|
||||
}
|
||||
EntityKind::Spline(sp) => {
|
||||
let mut pts = sp.tessellate(1e-3);
|
||||
@@ -162,9 +230,11 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
|
||||
if pts.len() < 3 {
|
||||
return Ok(Facts::Skip("degenerate"));
|
||||
}
|
||||
let bounds = Bounds2::from_points(pts.iter().copied());
|
||||
let (area, perimeter) = area_of(&pts);
|
||||
let g = Polyline::from_points(&pts, true);
|
||||
Facts::Shape { perimeter, area, signed: g.area(), centroid: g.centroid(), cross: first_crossing(&g), dups: Vec::new() }
|
||||
let (cross, cross_capped) = first_crossing(&g);
|
||||
Facts::Shape { perimeter, area, signed: g.area(), centroid: g.centroid(), cross, cross_capped, dups: Vec::new(), bounds }
|
||||
}
|
||||
EntityKind::Arc(_) => Facts::Skip("open"),
|
||||
_ => Facts::Skip("not a closed shape"),
|
||||
@@ -174,13 +244,14 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
|
||||
/// Shapes with more edges than this report no selfIntersection.
|
||||
const MAX_CROSSING_SEGMENTS: usize = 4_000;
|
||||
|
||||
/// The first crossing between two non-adjacent edges, in edge order.
|
||||
fn first_crossing(g: &Polyline) -> Option<Vec2> {
|
||||
/// The first crossing between two non-adjacent edges, in edge order, and whether the shape was
|
||||
/// too large to check (the cap applies even if a crossing lies among the first edges).
|
||||
fn first_crossing(g: &Polyline) -> (Option<Vec2>, bool) {
|
||||
let segs = g.segments();
|
||||
let n = segs.len();
|
||||
// The pairwise test is quadratic: past this many edges the answer is null, not a hang.
|
||||
if n > MAX_CROSSING_SEGMENTS {
|
||||
return None;
|
||||
return (None, true);
|
||||
}
|
||||
for (i, a) in segs.iter().enumerate() {
|
||||
for (j, b) in segs.iter().enumerate().skip(i + 2) {
|
||||
@@ -188,9 +259,9 @@ fn first_crossing(g: &Polyline) -> Option<Vec2> {
|
||||
continue;
|
||||
}
|
||||
if let Some(x) = intersect_segments(a, b).first() {
|
||||
return Some(*x);
|
||||
return (Some(*x), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
(None, false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
//! USAGE: a read-only cross-reference of the named objects a drawing uses — for each layer,
|
||||
//! block, text style and linetype, how many objects use it, whether they are only inside block
|
||||
//! definitions, and which are empty or unused. The unused lists are PURGE's scan (see
|
||||
//! `cmd::blocks::purge`); nothing here changes or deletes anything.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::blocks::purge;
|
||||
use super::super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CMD: &str = "usage";
|
||||
/// Names listed per kind in `unused`; the counts are complete unless the scan was cut short
|
||||
/// (`incomplete`).
|
||||
const MAX_NAMES: usize = 5_000;
|
||||
/// The most objects one scan reads, across model space, the layouts and the block definitions. A
|
||||
/// drawing with more is reported as `incomplete` instead of with counts that look complete. This
|
||||
/// is USAGE's own limit; PURGE's scan limit is separate and unchanged.
|
||||
const MAX_SCAN: usize = 5_000_000;
|
||||
|
||||
pub const PARAMS: &str = "{type?: layers|blocks|textstyles|linetypes|all (default all), limit? (default 200), offset?} → \
|
||||
{schema: 1, count (all items of the type), returned, offset, truncated, incomplete (true when the drawing has more \
|
||||
objects than one scan reads, so every count, status and summary is partial), incompleteReason? (why), \
|
||||
items: [{type: layer|block|textStyle|linetype, name, entities (objects in model space and layouts that use it), \
|
||||
blockEntities (objects in block definitions that use it), references (named objects that use it: a layer's linetype, \
|
||||
a text style's dimension and multileader styles), used (for a block: reachable from model space, a layout or a dimension), \
|
||||
insideBlocksOnly (used, but by block definitions only), empty (nothing refers to it), unused (PURGE would offer it), \
|
||||
current? (the current layer, text style or linetype)}], \
|
||||
summary: {<kind>: {total, used, insideBlocksOnly, empty, unused}}, \
|
||||
unused: {<kind>: {count, returned, truncated, names}} (PURGE's scan, in table order)}";
|
||||
|
||||
/// The four kinds of named object USAGE reports.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Category {
|
||||
Layer,
|
||||
Block,
|
||||
TextStyle,
|
||||
Linetype,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
const ALL: [Category; 4] = [Category::Layer, Category::Block, Category::TextStyle, Category::Linetype];
|
||||
|
||||
/// The singular JSON `type` value of this kind (`layer`, `block`, `textStyle`, `linetype`).
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Category::Layer => "layer",
|
||||
Category::Block => "block",
|
||||
Category::TextStyle => "textStyle",
|
||||
Category::Linetype => "linetype",
|
||||
}
|
||||
}
|
||||
/// The JSON key under which this kind's objects are grouped (`layers`, `blocks`,
|
||||
/// `textStyles`, `linetypes`).
|
||||
fn plural(self) -> &'static str {
|
||||
match self {
|
||||
Category::Layer => "layers",
|
||||
Category::Block => "blocks",
|
||||
Category::TextStyle => "textStyles",
|
||||
Category::Linetype => "linetypes",
|
||||
}
|
||||
}
|
||||
/// The capitalised plural noun used in the human-readable message.
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Category::Layer => "Layers",
|
||||
Category::Block => "Blocks",
|
||||
Category::TextStyle => "Text styles",
|
||||
Category::Linetype => "Linetypes",
|
||||
}
|
||||
}
|
||||
/// The PURGE kind whose scan answers whether this kind's objects are unused.
|
||||
fn purge(self) -> purge::Kind {
|
||||
match self {
|
||||
Category::Layer => purge::Kind::Layers,
|
||||
Category::Block => purge::Kind::Blocks,
|
||||
Category::TextStyle => purge::Kind::TextStyles,
|
||||
Category::Linetype => purge::Kind::Linetypes,
|
||||
}
|
||||
}
|
||||
/// `None` for an unknown name, `Some(None)` for all kinds.
|
||||
fn parse(s: &str) -> Option<Option<Category>> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "all" => Some(None),
|
||||
"layer" | "layers" => Some(Some(Category::Layer)),
|
||||
"block" | "blocks" => Some(Some(Category::Block)),
|
||||
"style" | "styles" | "textstyle" | "textstyles" | "text_style" | "text_styles" => Some(Some(Category::TextStyle)),
|
||||
"linetype" | "linetypes" | "ltype" | "ltypes" => Some(Some(Category::Linetype)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects in model space and the layouts (`false`), and in block definitions (`true`), up to
|
||||
/// `limit` — the USAGE scan limit, past which later objects are not read.
|
||||
fn each_entity(d: &Drawing, limit: usize) -> impl Iterator<Item = (&Entity, bool)> {
|
||||
let top = d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).map(|e| (&**e, false));
|
||||
let blocks = d.blocks.values().flat_map(|b| b.entities.iter()).map(|e| (&**e, true));
|
||||
top.chain(blocks).take(limit)
|
||||
}
|
||||
|
||||
/// Objects in model space, the layouts and the block definitions — everything one scan reads.
|
||||
fn entity_total(d: &Drawing) -> usize {
|
||||
d.blocks.values().fold(d.entity_count(), |n, b| n.saturating_add(b.entities.len()))
|
||||
}
|
||||
|
||||
/// A scan that stopped at its limit: the drawing holds `total` objects but only the first
|
||||
/// `scanned` were read, so objects after the limit are missing from the counts, verdicts and
|
||||
/// summary. Present in the report as `incomplete`/`incompleteReason`, never silently.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Incomplete {
|
||||
scanned: usize,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
impl Incomplete {
|
||||
/// Why the report is partial, naming both counts so a caller can judge it.
|
||||
fn reason(self) -> String {
|
||||
format!(
|
||||
"USAGE read the first {} of {} objects (model space, layouts and block definitions); \
|
||||
references after the limit are missing, so counts, statuses and summaries are partial",
|
||||
self.scanned, self.total
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses per lower-case name: (in model space and layouts, in block definitions).
|
||||
type Uses = HashMap<String, (usize, usize)>;
|
||||
|
||||
/// Counts one use of a named object, separately for model space and the layouts (`in_block`
|
||||
/// false) and for block definitions (`in_block` true); names compare case-insensitively.
|
||||
fn bump(map: &mut Uses, name: &str, in_block: bool) {
|
||||
let slot = map.entry(name.to_ascii_lowercase()).or_default();
|
||||
if in_block {
|
||||
slot.1 = slot.1.saturating_add(1);
|
||||
} else {
|
||||
slot.0 = slot.0.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts one named table entry that refers to `name`: a layer's linetype, or a dimension or
|
||||
/// multileader style's text style. Names compare case-insensitively.
|
||||
fn bump_reference(map: &mut HashMap<String, usize>, name: &str) {
|
||||
let slot = map.entry(name.to_ascii_lowercase()).or_default();
|
||||
*slot = slot.saturating_add(1);
|
||||
}
|
||||
|
||||
/// `ByLayer` and `ByBlock` name a value, not the linetype table's entries.
|
||||
fn linetype_value(name: &str) -> bool {
|
||||
name.eq_ignore_ascii_case("ByLayer") || name.eq_ignore_ascii_case("ByBlock")
|
||||
}
|
||||
|
||||
/// Every use of the drawing's layers, blocks, text styles and linetypes, in one pass over the
|
||||
/// objects (plus the block reachability PURGE already computes).
|
||||
#[derive(Default)]
|
||||
struct Scan {
|
||||
layers: Uses,
|
||||
blocks: Uses,
|
||||
styles: Uses,
|
||||
linetypes: Uses,
|
||||
/// Dimension and multileader styles that name a text style.
|
||||
style_refs: HashMap<String, usize>,
|
||||
/// Layers that name a linetype.
|
||||
linetype_refs: HashMap<String, usize>,
|
||||
/// Upper-case names of blocks reachable from model space, the layouts and dimensions.
|
||||
used_blocks: HashSet<String>,
|
||||
/// Set when the scan stopped at its limit, so every count and verdict is partial.
|
||||
incomplete: Option<Incomplete>,
|
||||
}
|
||||
|
||||
impl Scan {
|
||||
/// Scans the drawing once, reading at most `limit` objects: counts each entity's layer,
|
||||
/// explicit linetype, block, text style and attribute style, plus the table references, and
|
||||
/// borrows block reachability from PURGE. Records whether the limit cut the drawing short.
|
||||
fn new(d: &Drawing, limit: usize) -> Scan {
|
||||
let total = entity_total(d);
|
||||
let scanned = total.min(limit);
|
||||
let mut sc = Scan { used_blocks: purge::used_blocks(d), ..Scan::default() };
|
||||
sc.incomplete = (scanned < total).then_some(Incomplete { scanned, total });
|
||||
for (e, in_block) in each_entity(d, scanned) {
|
||||
bump(&mut sc.layers, &e.common.layer, in_block);
|
||||
if !linetype_value(&e.common.linetype) {
|
||||
bump(&mut sc.linetypes, &e.common.linetype, in_block);
|
||||
}
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => {
|
||||
bump(&mut sc.blocks, &i.block, in_block);
|
||||
for a in &i.attribs {
|
||||
bump(&mut sc.styles, &a.text.style, in_block);
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(dm) => {
|
||||
if let Some(b) = &dm.block {
|
||||
bump(&mut sc.blocks, b, in_block);
|
||||
}
|
||||
}
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(b) = &m.block {
|
||||
bump(&mut sc.blocks, &b.block, in_block);
|
||||
}
|
||||
if let Some(t) = &m.text {
|
||||
bump(&mut sc.styles, &t.style, in_block);
|
||||
}
|
||||
}
|
||||
EntityKind::Text(t) => bump(&mut sc.styles, &t.style, in_block),
|
||||
EntityKind::MText(t) => bump(&mut sc.styles, &t.style, in_block),
|
||||
EntityKind::AttDef(a) => bump(&mut sc.styles, &a.text.style, in_block),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for st in &d.dim_styles {
|
||||
bump_reference(&mut sc.style_refs, &st.text_style);
|
||||
}
|
||||
for st in &d.mleader_styles {
|
||||
bump_reference(&mut sc.style_refs, &st.text_style);
|
||||
}
|
||||
for l in &d.layers {
|
||||
if !linetype_value(&l.linetype) {
|
||||
bump_reference(&mut sc.linetype_refs, &l.linetype);
|
||||
}
|
||||
}
|
||||
sc
|
||||
}
|
||||
}
|
||||
|
||||
/// PURGE's unused lists, kept once so each item can say whether PURGE would offer it.
|
||||
struct Unused {
|
||||
lists: Vec<(Category, Vec<String>, HashSet<String>)>,
|
||||
}
|
||||
|
||||
impl Unused {
|
||||
/// Runs PURGE's unused scan for all four kinds, keeping each list in table order plus a
|
||||
/// lower-case set for fast membership checks.
|
||||
fn new(d: &Drawing) -> Unused {
|
||||
let lists = Category::ALL
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let names = purge::candidates(d, c.purge());
|
||||
let lower = names.iter().map(|n| n.to_ascii_lowercase()).collect();
|
||||
(c, names, lower)
|
||||
})
|
||||
.collect();
|
||||
Unused { lists }
|
||||
}
|
||||
/// Whether PURGE would offer `name` of kind `c` as unused (case-insensitive).
|
||||
fn has(&self, c: Category, name: &str) -> bool {
|
||||
self.lists.iter().find(|(k, ..)| *k == c).is_some_and(|(_, _, lower)| lower.contains(&name.to_ascii_lowercase()))
|
||||
}
|
||||
/// How many objects of kind `c` PURGE would offer — the full count, even when the name list
|
||||
/// in the JSON output is truncated.
|
||||
fn count(&self, c: Category) -> usize {
|
||||
self.lists.iter().find(|(k, ..)| *k == c).map_or(0, |(_, names, _)| names.len())
|
||||
}
|
||||
/// The `unused` member: per kind, the total count, the returned page of names capped at
|
||||
/// `MAX_NAMES`, and whether that page is truncated.
|
||||
fn json(&self) -> Value {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (c, names, _) in &self.lists {
|
||||
let page: Vec<&String> = names.iter().take(MAX_NAMES).collect();
|
||||
out.insert(
|
||||
c.plural().into(),
|
||||
json!({
|
||||
"count": names.len(),
|
||||
"returned": page.len(),
|
||||
"truncated": page.len() < names.len(),
|
||||
"names": page,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// One report item: the use counts, the caller's `used`/`empty` verdicts, `insideBlocksOnly`
|
||||
/// derived from those counts and PURGE's `unused` answer. `current` is emitted only for the kinds
|
||||
/// that have a current entry (layers, text styles and linetypes).
|
||||
fn item_json(
|
||||
c: Category,
|
||||
name: &str,
|
||||
entities: usize,
|
||||
block_entities: usize,
|
||||
references: usize,
|
||||
used: bool,
|
||||
empty: bool,
|
||||
current: Option<bool>,
|
||||
unused: &Unused,
|
||||
) -> Value {
|
||||
let mut v = json!({
|
||||
"type": c.key(),
|
||||
"name": name,
|
||||
"entities": entities,
|
||||
"blockEntities": block_entities,
|
||||
"references": references,
|
||||
"used": used,
|
||||
"insideBlocksOnly": used && entities == 0 && block_entities > 0 && references == 0,
|
||||
"empty": empty,
|
||||
"unused": unused.has(c, name),
|
||||
});
|
||||
if let (Some(cur), Some(o)) = (current, v.as_object_mut()) {
|
||||
o.insert("current".into(), json!(cur));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Builds one item per named object in table order — layers, blocks, text styles, linetypes —
|
||||
/// reading the scanned use counts and adding the current-object flag where the drawing has one.
|
||||
fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
let current_layer = d.header.str("CLAYER", "0");
|
||||
for l in &d.layers {
|
||||
let (e, b) = scan.layers.get(&l.name.to_ascii_lowercase()).copied().unwrap_or_default();
|
||||
let uses = e.saturating_add(b);
|
||||
let cur = l.name.eq_ignore_ascii_case(¤t_layer);
|
||||
out.push(item_json(Category::Layer, &l.name, e, b, 0, uses > 0, uses == 0, Some(cur), unused));
|
||||
}
|
||||
for b in d.blocks.values() {
|
||||
let (e, n) = scan.blocks.get(&b.name.to_ascii_lowercase()).copied().unwrap_or_default();
|
||||
// A definition is used when a reference to it is reachable, as PURGE counts it.
|
||||
let used = scan.used_blocks.contains(&b.name.to_ascii_uppercase());
|
||||
out.push(item_json(Category::Block, &b.name, e, n, 0, used, e.saturating_add(n) == 0, None, unused));
|
||||
}
|
||||
let current_style = d.header.str("TEXTSTYLE", "Standard");
|
||||
for t in &d.text_styles {
|
||||
let (e, b) = scan.styles.get(&t.name.to_ascii_lowercase()).copied().unwrap_or_default();
|
||||
let r = scan.style_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0);
|
||||
let uses = e.saturating_add(b).saturating_add(r);
|
||||
let cur = t.name.eq_ignore_ascii_case(¤t_style);
|
||||
out.push(item_json(Category::TextStyle, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused));
|
||||
}
|
||||
let current_linetype = d.header.str("CELTYPE", "ByLayer");
|
||||
for t in &d.linetypes {
|
||||
let (e, b) = scan.linetypes.get(&t.name.to_ascii_lowercase()).copied().unwrap_or_default();
|
||||
let r = scan.linetype_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0);
|
||||
let uses = e.saturating_add(b).saturating_add(r);
|
||||
let cur = t.name.eq_ignore_ascii_case(¤t_linetype);
|
||||
out.push(item_json(Category::Linetype, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Totals per kind over every item — total, used, inside blocks only, empty — plus how many
|
||||
/// objects of that kind PURGE would offer.
|
||||
fn summary(items: &[Value], unused: &Unused) -> Value {
|
||||
let mut out = serde_json::Map::new();
|
||||
for c in Category::ALL {
|
||||
let mut total = 0usize;
|
||||
let mut used = 0usize;
|
||||
let mut inside = 0usize;
|
||||
let mut empty = 0usize;
|
||||
for it in items.iter().filter(|i| i["type"] == c.key()) {
|
||||
total = total.saturating_add(1);
|
||||
used = used.saturating_add(usize::from(it["used"].as_bool() == Some(true)));
|
||||
inside = inside.saturating_add(usize::from(it["insideBlocksOnly"].as_bool() == Some(true)));
|
||||
empty = empty.saturating_add(usize::from(it["empty"].as_bool() == Some(true)));
|
||||
}
|
||||
out.insert(c.plural().into(), json!({ "total": total, "used": used, "insideBlocksOnly": inside, "empty": empty, "unused": unused.count(c) }));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// The human-readable summary, one line per kind, plus a note when the filter matched no named
|
||||
/// objects at all and a note when the scan was cut short.
|
||||
fn message(items: &[&Value], summary: &Value, incomplete: Option<Incomplete>) -> String {
|
||||
let mut lines = Vec::new();
|
||||
for c in Category::ALL {
|
||||
let s = &summary[c.plural()];
|
||||
lines.push(format!(
|
||||
"{}: {} total, {} used, {} inside blocks only, {} empty, {} unused.",
|
||||
c.label(),
|
||||
s["total"].as_u64().unwrap_or(0),
|
||||
s["used"].as_u64().unwrap_or(0),
|
||||
s["insideBlocksOnly"].as_u64().unwrap_or(0),
|
||||
s["empty"].as_u64().unwrap_or(0),
|
||||
s["unused"].as_u64().unwrap_or(0),
|
||||
));
|
||||
}
|
||||
if items.is_empty() {
|
||||
lines.push("There are no named objects of this type.".into());
|
||||
}
|
||||
if let Some(inc) = incomplete {
|
||||
lines.push(format!("Scan incomplete: read the first {} of {} objects; the counts above are partial.", inc.scanned, inc.total));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Runs `USAGE`: validates `type`, `limit` and `offset` (rejecting bad input without touching the
|
||||
/// drawing), scans read-only, and returns the paged items with the per-kind summary, PURGE's
|
||||
/// unused lists and the message.
|
||||
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
|
||||
let count = |k: &str, default: usize| -> Result<usize> {
|
||||
given(k).map_or(Ok(default), |v| {
|
||||
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
|
||||
})
|
||||
};
|
||||
let ty = match given("type") {
|
||||
None => None,
|
||||
Some(Value::String(t)) => {
|
||||
Category::parse(t).ok_or_else(|| bad(CMD, format!("`type` must be layers, blocks, textstyles, linetypes or all, got \"{t}\"")))?
|
||||
}
|
||||
Some(v) => return Err(bad(CMD, format!("`type` must be a string, got {v}"))),
|
||||
};
|
||||
let limit = count("limit", 200)?;
|
||||
let offset = count("offset", 0)?;
|
||||
let st = s.state()?;
|
||||
let d = &st.doc;
|
||||
let scan = Scan::new(d, MAX_SCAN);
|
||||
let unused = Unused::new(d);
|
||||
let all = items(&scan, &unused, d);
|
||||
let matched: Vec<&Value> = all.iter().filter(|i| ty.is_none_or(|c| i["type"] == c.key())).collect();
|
||||
let total = matched.len();
|
||||
let page: Vec<&Value> = matched.iter().skip(offset).take(limit).copied().collect();
|
||||
let summary = summary(&all, &unused);
|
||||
let mut out = json!({
|
||||
"schema": 1,
|
||||
"count": total,
|
||||
"returned": page.len(),
|
||||
"offset": offset,
|
||||
"truncated": page.len() < total.saturating_sub(offset),
|
||||
"incomplete": scan.incomplete.is_some(),
|
||||
"items": page,
|
||||
"summary": summary,
|
||||
"unused": unused.json(),
|
||||
"message": message(&matched, &summary, scan.incomplete),
|
||||
});
|
||||
if let (Some(inc), Some(o)) = (scan.incomplete, out.as_object_mut()) {
|
||||
o.insert("incompleteReason".into(), json!(inc.reason()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "usage_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Unit tests for USAGE's scan limit (#1439): a scan cut at its limit must say so. A drawing with
|
||||
//! more than 5,000,000 objects is impractical to build in a test, so the limit is a parameter and
|
||||
//! these tests cut a tiny drawing instead.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Line};
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
use super::{Incomplete, Scan, entity_total};
|
||||
|
||||
/// A LINE on `layer`, one unit long.
|
||||
fn line(handle: u64, layer: &str) -> Entity {
|
||||
let mut e = Entity::new(Handle(handle), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }));
|
||||
e.common = Common { layer: layer.into(), ..Common::default() };
|
||||
e
|
||||
}
|
||||
|
||||
/// A drawing with `top` lines on layer Used in model space and `held` lines on layer Held in the
|
||||
/// block HELD, so the block's objects come last in the scan, exactly as they do in a real drawing.
|
||||
fn drawing(top: usize, held: usize) -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
for i in 0..top {
|
||||
d.model.push(line(0x100 + i as u64, "Used"));
|
||||
}
|
||||
let mut b = Block::new("HELD");
|
||||
for i in 0..held {
|
||||
b.entities.push(line(0x200 + i as u64, "Held"));
|
||||
}
|
||||
d.blocks.insert("HELD".into(), Arc::new(b));
|
||||
d
|
||||
}
|
||||
|
||||
/// Guards the detector's boundary: a drawing holding exactly the limit is read whole and is not
|
||||
/// reported as cut short, so `incomplete` never cries wolf on the last readable object.
|
||||
#[test]
|
||||
fn a_drawing_at_the_limit_is_complete() {
|
||||
let d = drawing(2, 1);
|
||||
assert_eq!(entity_total(&d), 3);
|
||||
let sc = Scan::new(&d, 3);
|
||||
assert_eq!(sc.incomplete, None);
|
||||
assert_eq!(sc.layers.get("used").copied(), Some((2, 0)));
|
||||
assert_eq!(sc.layers.get("held").copied(), Some((0, 1)));
|
||||
}
|
||||
|
||||
/// Guards that a scan stopped one object early reports what it omitted and why: HELD's line is
|
||||
/// past the limit, so its layer looks unused — the miscount the `incomplete` signal exists to
|
||||
/// prevent a caller from trusting.
|
||||
#[test]
|
||||
fn a_cut_scan_reports_the_objects_it_omitted() {
|
||||
let d = drawing(2, 1);
|
||||
let sc = Scan::new(&d, 2);
|
||||
let inc = sc.incomplete.expect("a cut scan must report it");
|
||||
assert_eq!(inc, Incomplete { scanned: 2, total: 3 });
|
||||
assert_eq!(sc.layers.get("held").copied(), None);
|
||||
let reason = inc.reason();
|
||||
assert!(reason.contains("first 2 of 3 objects"), "{reason}");
|
||||
assert!(reason.contains("partial"), "{reason}");
|
||||
}
|
||||
@@ -270,9 +270,13 @@ fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = layout_name("layout.rename", s.doc()?, &from)?;
|
||||
let to = str_param(p, "to").ok_or_else(|| bad("layout.rename", "`to` is required"))?;
|
||||
let to = check_name("layout.rename", s.doc()?, to, Some(&from))?;
|
||||
if let Some(l) = s.doc_mut()?.layouts.iter_mut().find(|l| l.name == from) {
|
||||
let d = s.doc_mut()?;
|
||||
if let Some(l) = d.layouts.iter_mut().find(|l| l.name == from) {
|
||||
l.name = to.clone();
|
||||
}
|
||||
if d.active_layout.as_ref().is_some_and(|a| a.eq_ignore_ascii_case(&from)) {
|
||||
d.active_layout = Some(to.clone());
|
||||
}
|
||||
let (old, new) = (Space::Paper(from.clone()), Space::Paper(to.clone()));
|
||||
let st = s.state_mut()?;
|
||||
for (sp, _) in st.views.iter_mut() {
|
||||
@@ -832,7 +836,10 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad(cmd, "paths are not available on the web; omit `path` to get the PDF as base64 `data`"))
|
||||
{
|
||||
super::exchange::deliver_file(cmd, _path, &bytes, super::file::deliver())?;
|
||||
Ok(json!({ "path": _path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
|
||||
}
|
||||
}
|
||||
None => Ok(json!({ "data": base64_encode(&bytes), "bytes": bytes.len(), "layout": layout, "pages": pages })),
|
||||
}
|
||||
@@ -925,6 +932,14 @@ mod tests {
|
||||
assert!(s.doc().unwrap().layout("Layout3").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_keeps_the_active_layout_whatever_its_case() {
|
||||
let mut s = session_with_model();
|
||||
s.doc_mut().unwrap().active_layout = Some("layout1".into());
|
||||
s.execute("layout.rename", &json!({"from": "Layout1", "to": "Sheet A"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().active_layout.as_deref(), Some("Sheet A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_copy_delete() {
|
||||
let mut s = session_with_model();
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("appload", "Load Application...", run_appload)
|
||||
.menu(&["Tools", "Load Application..."])
|
||||
.alias(&["ap"])
|
||||
.params("{path: .lsp file} | {name, data: base64 | text} → {value?, error?}")
|
||||
.params("{path: .lsp file} | {name, data: base64 | text} → {value?, commands: [commands the file defined], error?}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(ApploadM))),
|
||||
CommandSpec::new("lisp", "Evaluate LISP", run_lisp)
|
||||
@@ -23,6 +23,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether `path` names an AutoLISP routine: APPLOAD loads `.lsp` files only, in any case.
|
||||
fn is_lisp_path(path: &str) -> bool {
|
||||
std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("lsp"))
|
||||
}
|
||||
|
||||
/// The text of a LISP file from `{path}` or `{name, data | text}`, and its name.
|
||||
fn source(p: &Value) -> Result<(String, String)> {
|
||||
if let Some(text) = str_param(p, "text") {
|
||||
@@ -32,7 +37,14 @@ fn source(p: &Value) -> Result<(String, String)> {
|
||||
let bytes = super::file::base64_decode(data).ok_or_else(|| bad("appload", "invalid base64"))?;
|
||||
return Ok((str_param(p, "name").unwrap_or("untitled.lsp").to_string(), String::from_utf8_lossy(&bytes).into_owned()));
|
||||
}
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("appload", "`path`, or `name` with `data` (base64) or `text`, is required"))?;
|
||||
// The path is read as it is given (a JSON caller may name a file or folder with spaces);
|
||||
// only a path of blanks alone is refused.
|
||||
let path = str_param(p, "path")
|
||||
.filter(|p| !p.trim().is_empty())
|
||||
.ok_or_else(|| bad("appload", "`path`, or `name` with `data` (base64) or `text`, is required"))?;
|
||||
if !is_lisp_path(path) {
|
||||
return Err(bad("appload", format!("{path}: not an AutoLISP file (APPLOAD loads .lsp files)")));
|
||||
}
|
||||
let text = crate::lisp::builtins::read_text_file(path).ok_or_else(|| bad("appload", format!("{path}: can't read the file")))?;
|
||||
Ok((path.to_string(), text))
|
||||
}
|
||||
@@ -49,9 +61,14 @@ fn report(s: &Session, from_log: usize, r: Option<std::result::Result<Lv, String
|
||||
|
||||
fn run_appload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, text) = source(p)?;
|
||||
let before = s.lisp_commands();
|
||||
let from = s.log.len();
|
||||
let r = s.lisp_load(&name, &text, true)?;
|
||||
Ok(report(s, from, r))
|
||||
let mut out = report(s, from, r);
|
||||
// The commands the file defined, so a caller can run them next (a failed file may still
|
||||
// have defined some before the error).
|
||||
out["commands"] = json!(s.lisp_commands().into_iter().filter(|c| !before.contains(c)).collect::<Vec<_>>());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A JSON input as a LISP value: numbers, strings, `[x, y(, z)]` points, null for Enter.
|
||||
|
||||
@@ -310,6 +310,67 @@ fn segment_dir(m: &MLeader) -> Option<Vec2> {
|
||||
(v.len() > 1e-12).then(|| v.normalized())
|
||||
}
|
||||
|
||||
fn align_parallel(items: &[(Handle, MLeader)], reference: Option<&MLeader>) -> Result<Vec<(Handle, Vec2)>> {
|
||||
let rdir = reference.and_then(segment_dir).ok_or_else(|| bad("mleaderalign", "the reference multileader has no leader"))?;
|
||||
let mut moves = Vec::new();
|
||||
for (h, m) in items {
|
||||
let Some(arrow) = m.leaders.first().and_then(|l| l.last()) else { continue };
|
||||
let len = (m.landing.xy() - arrow.xy()).len();
|
||||
moves.push((*h, arrow.xy() + rdir * len - m.landing.xy()));
|
||||
}
|
||||
Ok(moves)
|
||||
}
|
||||
|
||||
fn align_distribute(mut items: Vec<(Handle, MLeader)>, line: Option<(Vec2, Vec2)>) -> Result<Vec<(Handle, Vec2)>> {
|
||||
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`points` (first and last position) are required"))?;
|
||||
let d = b - a;
|
||||
if d.len() < 1e-12 {
|
||||
return Err(bad("mleaderalign", "the two points coincide"));
|
||||
}
|
||||
let u = d.normalized();
|
||||
items.sort_by(|x, y| (x.1.landing.xy() - a).dot(u).total_cmp(&(y.1.landing.xy() - a).dot(u)));
|
||||
let n = items.len();
|
||||
let mut moves = Vec::new();
|
||||
for (i, (h, m)) in items.iter().enumerate() {
|
||||
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
|
||||
moves.push((*h, a + d * f - m.landing.xy()));
|
||||
}
|
||||
Ok(moves)
|
||||
}
|
||||
|
||||
fn align_directional(
|
||||
items: &[(Handle, MLeader)],
|
||||
reference: Option<&MLeader>,
|
||||
line: Option<(Vec2, Vec2)>,
|
||||
spacing: Spacing,
|
||||
) -> Result<Vec<(Handle, Vec2)>> {
|
||||
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`direction` (two points or an angle) is required"))?;
|
||||
let d = b - a;
|
||||
if d.len() < 1e-12 {
|
||||
return Err(bad("mleaderalign", "the direction is zero"));
|
||||
}
|
||||
let u = d.normalized();
|
||||
let origin = reference.map(|r| r.landing.xy()).unwrap_or(a);
|
||||
let t_of = |m: &MLeader| (m.landing.xy() - origin).dot(u);
|
||||
let mut moves = Vec::new();
|
||||
if let Spacing::Value(sp) = spacing {
|
||||
let (mut pos, mut neg): (Vec<_>, Vec<_>) = items.iter().partition(|(_, m)| t_of(m) >= 0.0);
|
||||
pos.sort_by(|x, y| t_of(&x.1).total_cmp(&t_of(&y.1)));
|
||||
neg.sort_by(|x, y| t_of(&y.1).total_cmp(&t_of(&x.1)));
|
||||
for (k, (h, m)) in pos.iter().enumerate() {
|
||||
moves.push((*h, origin + u * (sp * (k + 1) as f64) - m.landing.xy()));
|
||||
}
|
||||
for (k, (h, m)) in neg.iter().enumerate() {
|
||||
moves.push((*h, origin - u * (sp * (k + 1) as f64) - m.landing.xy()));
|
||||
}
|
||||
} else {
|
||||
for (h, m) in items {
|
||||
moves.push((*h, origin + u * t_of(m) - m.landing.xy()));
|
||||
}
|
||||
}
|
||||
Ok(moves)
|
||||
}
|
||||
|
||||
fn align(s: &mut Session, hs: &[Handle], to: Option<Handle>, spacing: Spacing, line: Option<(Vec2, Vec2)>) -> Result<usize> {
|
||||
let mut items: Vec<(Handle, MLeader)> = Vec::new();
|
||||
for h in hs {
|
||||
@@ -331,56 +392,11 @@ fn align(s: &mut Session, hs: &[Handle], to: Option<Handle>, spacing: Spacing, l
|
||||
(_, Some(t)) => Some(get_mleader(s, t).ok_or_else(|| bad("mleaderalign", "`to` is not a multileader"))?),
|
||||
(_, None) => return Err(bad("mleaderalign", "`to` (the multileader to align to) is required")),
|
||||
};
|
||||
let mut moves: Vec<(Handle, Vec2)> = Vec::new();
|
||||
match spacing {
|
||||
Spacing::Parallel => {
|
||||
let rdir = reference.as_ref().and_then(segment_dir).ok_or_else(|| bad("mleaderalign", "the reference multileader has no leader"))?;
|
||||
for (h, m) in &items {
|
||||
let Some(arrow) = m.leaders.first().and_then(|l| l.last()) else { continue };
|
||||
let len = (m.landing.xy() - arrow.xy()).len();
|
||||
moves.push((*h, arrow.xy() + rdir * len - m.landing.xy()));
|
||||
}
|
||||
}
|
||||
Spacing::Distribute => {
|
||||
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`points` (first and last position) are required"))?;
|
||||
let d = b - a;
|
||||
if d.len() < 1e-12 {
|
||||
return Err(bad("mleaderalign", "the two points coincide"));
|
||||
}
|
||||
let u = d.normalized();
|
||||
items.sort_by(|x, y| (x.1.landing.xy() - a).dot(u).total_cmp(&(y.1.landing.xy() - a).dot(u)));
|
||||
let n = items.len();
|
||||
for (i, (h, m)) in items.iter().enumerate() {
|
||||
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
|
||||
moves.push((*h, a + d * f - m.landing.xy()));
|
||||
}
|
||||
}
|
||||
Spacing::Current | Spacing::Value(_) => {
|
||||
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`direction` (two points or an angle) is required"))?;
|
||||
let d = b - a;
|
||||
if d.len() < 1e-12 {
|
||||
return Err(bad("mleaderalign", "the direction is zero"));
|
||||
}
|
||||
let u = d.normalized();
|
||||
let origin = reference.as_ref().map(|r| r.landing.xy()).unwrap_or(a);
|
||||
let t_of = |m: &MLeader| (m.landing.xy() - origin).dot(u);
|
||||
if let Spacing::Value(sp) = spacing {
|
||||
let (mut pos, mut neg): (Vec<_>, Vec<_>) = items.iter().partition(|(_, m)| t_of(m) >= 0.0);
|
||||
pos.sort_by(|x, y| t_of(&x.1).total_cmp(&t_of(&y.1)));
|
||||
neg.sort_by(|x, y| t_of(&y.1).total_cmp(&t_of(&x.1)));
|
||||
for (k, (h, m)) in pos.iter().enumerate() {
|
||||
moves.push((*h, origin + u * (sp * (k + 1) as f64) - m.landing.xy()));
|
||||
}
|
||||
for (k, (h, m)) in neg.iter().enumerate() {
|
||||
moves.push((*h, origin - u * (sp * (k + 1) as f64) - m.landing.xy()));
|
||||
}
|
||||
} else {
|
||||
for (h, m) in &items {
|
||||
moves.push((*h, origin + u * t_of(m) - m.landing.xy()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let moves = match spacing {
|
||||
Spacing::Parallel => align_parallel(&items, reference.as_ref())?,
|
||||
Spacing::Distribute => align_distribute(items, line)?,
|
||||
Spacing::Current | Spacing::Value(_) => align_directional(&items, reference.as_ref(), line, spacing)?,
|
||||
};
|
||||
move_content(s, &moves)?;
|
||||
Ok(moves.len())
|
||||
}
|
||||
|
||||
@@ -120,3 +120,88 @@ fn hostile_mleader_edit_params_never_panic() {
|
||||
}
|
||||
let _ = cadcraft_render::build(s.doc().unwrap(), &cadcraft_doc::Space::Model, &cadcraft_render::Options::default());
|
||||
}
|
||||
|
||||
mod units {
|
||||
use super::super::{Spacing, align_directional, align_distribute, align_parallel};
|
||||
use cadcraft_doc::{Handle, MLeader};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
|
||||
fn unit_ml(arrow: [f64; 2], land: [f64; 2]) -> MLeader {
|
||||
MLeader {
|
||||
leaders: vec![vec![Vec3::new(arrow[0], arrow[1], 0.0)]],
|
||||
landing: Vec3::new(land[0], land[1], 0.0),
|
||||
dogleg: 0.0,
|
||||
text: None,
|
||||
style: String::new(),
|
||||
arrow_size: 1.0,
|
||||
spline: false,
|
||||
block: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn no_leader(land: [f64; 2]) -> MLeader {
|
||||
MLeader {
|
||||
leaders: vec![],
|
||||
landing: Vec3::new(land[0], land[1], 0.0),
|
||||
dogleg: 0.0,
|
||||
text: None,
|
||||
style: String::new(),
|
||||
arrow_size: 1.0,
|
||||
spline: false,
|
||||
block: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A five-unit-long leader whose direction differs from the reference's `+y`: moving the
|
||||
/// arrow onto `y` plus `5` (a five-unit parallel push) and back along the landing gives
|
||||
/// `(-3, 1)`.
|
||||
#[test]
|
||||
fn parallel_requires_a_reference_and_skips_leaders() {
|
||||
assert!(align_parallel(&[(Handle(1), unit_ml([0.0, 0.0], [3.0, 4.0]))], None).is_err());
|
||||
let d = align_parallel(
|
||||
&[(Handle(2), no_leader([1.0, 1.0])), (Handle(3), unit_ml([0.0, 0.0], [3.0, 4.0]))],
|
||||
Some(&unit_ml([0.0, 0.0], [0.0, 4.0])),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(d.len(), 1, "a multileader without a leader line is skipped");
|
||||
assert_eq!(d[0].0, Handle(3));
|
||||
assert!((d[0].1.x + 3.0).abs() < 1e-9);
|
||||
assert!((d[0].1.y - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribute_orders_landings_along_the_line() {
|
||||
let mk = |h: u64, land: [f64; 2]| (Handle(h), unit_ml([0.0, 0.0], land));
|
||||
assert!(align_distribute(vec![mk(1, [2.0, 0.0])], None).is_err(), "needs two points");
|
||||
assert!(align_distribute(Vec::new(), Some((Vec2::new(0.0, 0.0), Vec2::new(0.0, 0.0)))).is_err(), "coinciding points give no direction");
|
||||
let items = vec![mk(1, [2.0, 0.0]), mk(2, [0.0, 0.0])];
|
||||
let d = align_distribute(items, Some((Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0)))).unwrap();
|
||||
assert_eq!(d[0].0, Handle(2), "sorted by projection onto the line");
|
||||
assert!((d[0].1.x - 0.0).abs() < 1e-9 && (d[0].1.y - 0.0).abs() < 1e-9, "first fraction is 0");
|
||||
assert_eq!(d[1].0, Handle(1));
|
||||
assert!((d[1].1.x - 2.0).abs() < 1e-9 && (d[1].1.y - 0.0).abs() < 1e-9, "last fraction is 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_projects_landings_and_supports_spacing() {
|
||||
let line = Some((Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0)));
|
||||
assert!(align_directional(&[], None, None, Spacing::Current).is_err(), "needs a direction");
|
||||
assert!(
|
||||
align_directional(&[], None, Some((Vec2::new(0.0, 0.0), Vec2::new(0.0, 0.0))), Spacing::Current).is_err(),
|
||||
"a zero direction is an error"
|
||||
);
|
||||
// Without a reference the origin is the first point: the landing is projected onto it.
|
||||
let d = align_directional(&[(Handle(7), unit_ml([0.0, 0.0], [1.0, 5.0]))], None, line, Spacing::Current).unwrap();
|
||||
assert_eq!(d.len(), 1);
|
||||
assert!((d[0].1.x - 0.0).abs() < 1e-9);
|
||||
assert!((d[0].1.y + 5.0).abs() < 1e-9);
|
||||
// With a reference as origin, given spacing counts outward on both sides of it.
|
||||
let items = vec![(Handle(1), unit_ml([0.0, 0.0], [4.0, 0.0])), (Handle(2), unit_ml([0.0, 0.0], [-4.0, 0.0]))];
|
||||
let d = align_directional(&items, Some(&unit_ml([0.0, 0.0], [0.0, 4.0])), line, Spacing::Value(2.0)).unwrap();
|
||||
assert_eq!(d.len(), 2);
|
||||
assert_eq!(d[0].0, Handle(1));
|
||||
assert!((d[0].1.x + 2.0).abs() < 1e-9 && (d[0].1.y - 4.0).abs() < 1e-9);
|
||||
assert_eq!(d[1].0, Handle(2));
|
||||
assert!((d[1].1.x - 2.0).abs() < 1e-9 && (d[1].1.y - 4.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ pub mod plotters;
|
||||
mod props;
|
||||
pub use draw2::breakline::BreaklineOptions;
|
||||
pub use props::MatchProps;
|
||||
pub(crate) use props::set_current_color_value;
|
||||
mod qselect;
|
||||
mod selecttools;
|
||||
mod settings;
|
||||
|
||||
@@ -219,6 +219,22 @@ impl StretchArea {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add to `hs` the visible multilines in `space` with a control vertex inside `area`. A crossing
|
||||
/// selection tests the drawn element lines, which sit away from the control vertices by the
|
||||
/// multiline's scale, so a small area around a vertex alone would miss them.
|
||||
fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, area: &StretchArea, hs: &mut Vec<Handle>) {
|
||||
let Some(store) = d.space(space) else { return };
|
||||
for e in store.iter() {
|
||||
if let EntityKind::MLine(m) = &e.kind
|
||||
&& d.is_visible(e)
|
||||
&& !hs.contains(&e.handle)
|
||||
&& m.vertices.iter().any(|v| area.contains(v.p.xy()))
|
||||
{
|
||||
hs.push(e.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the vertices of `e` that lie inside any of the areas `wins` by `d` (STRETCH).
|
||||
fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) {
|
||||
let inside = |p: Vec2| wins.iter().any(|a| a.contains(p));
|
||||
@@ -240,6 +256,7 @@ fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) {
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::MLine(m) => m.vertices.iter_mut().for_each(|v| mv(&mut v.p)),
|
||||
EntityKind::Arc(a) => {
|
||||
// Arcs keep their shape: move when the centre is inside.
|
||||
if inside(a.center.xy()) {
|
||||
@@ -287,6 +304,7 @@ fn run_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bx = cadcraft_geom::Bounds2::new(*a, *b);
|
||||
(crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx))
|
||||
};
|
||||
add_mline_vertex_hits(s.doc()?, &space, &area, &mut hs);
|
||||
hs.retain(|h| !super::curves::is_locked(s, *h));
|
||||
let doc = s.doc_mut()?;
|
||||
let areas = [area];
|
||||
@@ -353,7 +371,12 @@ impl StretchM {
|
||||
/// of two) the mode is dropped.
|
||||
fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> {
|
||||
let space = s.space();
|
||||
let Some(hs) = m.select(s.doc()?, &space) else { return Ok(()) };
|
||||
let Some(mut hs) = m.select(s.doc()?, &space) else { return Ok(()) };
|
||||
let crossing = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
|
||||
(SelectKind::Crossing | SelectKind::CPolygon, _) => true,
|
||||
(SelectKind::Box, Some((a, b))) => b.x < a.x,
|
||||
_ => false,
|
||||
};
|
||||
let area = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
|
||||
(SelectKind::WPolygon | SelectKind::CPolygon, _) => Some(StretchArea::Polygon(m.pts)),
|
||||
(SelectKind::Window | SelectKind::Crossing | SelectKind::Box, Some((a, b))) => {
|
||||
@@ -363,6 +386,9 @@ impl StretchM {
|
||||
_ => None,
|
||||
};
|
||||
let whole = area.is_none();
|
||||
if let (Some(a), true, false) = (&area, crossing, self.removing) {
|
||||
add_mline_vertex_hits(s.doc()?, &space, a, &mut hs);
|
||||
}
|
||||
if let (Some(a), false) = (area, self.removing) {
|
||||
self.windows.push(a);
|
||||
}
|
||||
@@ -400,8 +426,11 @@ impl StretchM {
|
||||
Some(c) => {
|
||||
let bx = cadcraft_geom::Bounds2::new(c, p);
|
||||
let space = s.space();
|
||||
let hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
|
||||
let mut hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
|
||||
if !self.removing {
|
||||
if p.x < c.x {
|
||||
add_mline_vertex_hits(s.doc()?, &space, &StretchArea::Box(bx), &mut hs);
|
||||
}
|
||||
self.windows.push(StretchArea::Box(bx));
|
||||
}
|
||||
self.add(s, hs, false);
|
||||
@@ -564,6 +593,21 @@ impl Interactive for StretchM {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether OFFSET can work on this kind of object at all.
|
||||
fn offsettable(k: &EntityKind) -> bool {
|
||||
matches!(
|
||||
k,
|
||||
EntityKind::Line(_)
|
||||
| EntityKind::Circle(_)
|
||||
| EntityKind::Arc(_)
|
||||
| EntityKind::Ellipse(_)
|
||||
| EntityKind::LwPolyline(_)
|
||||
| EntityKind::Spline(_)
|
||||
| EntityKind::XLine(_)
|
||||
| EntityKind::Ray(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// Offset an entity by `dist` towards `side`. Returns the new geometry.
|
||||
pub(crate) fn offset_kind(k: &EntityKind, dist: f64, side: Vec2) -> Option<EntityKind> {
|
||||
match k {
|
||||
@@ -1388,7 +1432,7 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
attach: 5,
|
||||
rotation: g.text_angle,
|
||||
style: style.text_style.clone(),
|
||||
contents: g.value.clone(),
|
||||
contents: g.mtext_contents(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
@@ -2239,8 +2283,24 @@ impl Interactive for OffsetM {
|
||||
fn name(&self) -> &'static str {
|
||||
"OFFSET"
|
||||
}
|
||||
/// Starts OFFSET, keeping a single preselected offsettable object (issue #1610) so the prompt
|
||||
/// sequence goes distance then side instead of asking for the object again; any other
|
||||
/// selection is cleared and the object loop asks for it. With PICKFIRST off the selection is
|
||||
/// likewise ignored and cleared.
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.set_selection(Vec::new());
|
||||
// Exactly one offsettable object selected before the command is taken at once, so only the
|
||||
// side is asked for; the object loop stays for the ones after it (issue #1610). A multiple
|
||||
// selection is left for the select prompt, as AutoCAD does.
|
||||
let pre = s.selection();
|
||||
if s.settings.pickfirst
|
||||
&& let [h] = pre[..]
|
||||
&& s.doc()?.entity(h).is_some_and(|e| offsettable(&e.kind))
|
||||
{
|
||||
self.picked = Some(h);
|
||||
s.set_selection(vec![h]);
|
||||
} else {
|
||||
s.set_selection(Vec::new());
|
||||
}
|
||||
let (erase, current) = offset_options(s);
|
||||
s.echo(format!(
|
||||
"Current settings: Erase source={} Layer={} OFFSETGAPTYPE=0",
|
||||
|
||||
@@ -52,6 +52,7 @@ pub const PROFILE_VARS: &[&str] = &[
|
||||
"PICKADD",
|
||||
"PICKDRAG",
|
||||
"MAXARRAY",
|
||||
"MEASUREINIT",
|
||||
];
|
||||
|
||||
/// The most profile entries [`restore`] reads (a saved profile is untrusted input).
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("properties", "Properties", run_get).menu(&["Modify", "Properties"]).alias(&["pr", "props", "ch"]).params("{handles?} → properties of the selection").noundo(),
|
||||
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)}"),
|
||||
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, thickness?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)}"),
|
||||
CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop)
|
||||
.menu(&["Modify", "Match Properties"])
|
||||
.alias(&["ma", "painter"])
|
||||
@@ -82,6 +82,7 @@ pub(super) fn entity_props(d: &cadcraft_doc::Drawing, e: &cadcraft_doc::Entity)
|
||||
"lineweight": e.common.lineweight.name(),
|
||||
"ltscale": e.common.ltscale,
|
||||
"transparency": transparency_value(e.common.transparency),
|
||||
"thickness": e.common.thickness,
|
||||
"visible": e.common.visible,
|
||||
});
|
||||
if cadcraft_doc::can_be_annotative(&e.kind)
|
||||
@@ -185,6 +186,10 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.get("transparency")
|
||||
.map(|v| parse_transparency(v).ok_or_else(|| bad("properties.set", "transparency must be ByLayer, ByBlock or a percentage from 0 to 90")))
|
||||
.transpose()?;
|
||||
let thickness = p
|
||||
.get("thickness")
|
||||
.map(|v| v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("properties.set", "thickness must be a finite number")))
|
||||
.transpose()?;
|
||||
let lw = p.get("lineweight").map(|v| super::layer::parse_lw(v).ok_or_else(|| bad("properties.set", "bad lineweight"))).transpose()?;
|
||||
let d = s.doc_mut()?;
|
||||
// An explicit object linetype must be ByLayer, ByBlock or a loaded definition (as `linetype current` requires).
|
||||
@@ -229,6 +234,9 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(t) = transparency {
|
||||
e.common.transparency = t;
|
||||
}
|
||||
if let Some(th) = thickness {
|
||||
e.common.thickness = th;
|
||||
}
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
e.common.visible = v;
|
||||
}
|
||||
@@ -410,7 +418,25 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn run_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = p.get("color").and_then(parse_color).ok_or_else(|| bad("color", "`color` is required"))?;
|
||||
let d = s.doc_mut()?;
|
||||
set_current_color(s.doc_mut()?, c);
|
||||
ok()
|
||||
}
|
||||
|
||||
/// `setvar CECOLOR`: the same input as the `color` command, stored the same way.
|
||||
pub(crate) fn set_current_color_value(s: &mut Session, v: &Value) -> Option<()> {
|
||||
// An out-of-range index is refused here, where `color` folds it to ByLayer.
|
||||
let c = match v {
|
||||
Value::String(t) => Color::parse(t),
|
||||
Value::Number(n) => n.as_i64().and_then(|i| Color::parse(&i.to_string())),
|
||||
_ => None,
|
||||
}?;
|
||||
set_current_color(s.doc_mut().ok()?, c);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// A true colour keeps its RGB in CECOLOR_RGB (which wins for new objects); any other colour must
|
||||
/// clear a stale one.
|
||||
fn set_current_color(d: &mut cadcraft_doc::Drawing, c: Color) {
|
||||
match c {
|
||||
Color::True(rgb) => {
|
||||
d.header.set_i64("CECOLOR", i64::from(cadcraft_color::nearest_aci(rgb)));
|
||||
@@ -421,7 +447,6 @@ fn run_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.header.vars.remove("CECOLOR_RGB");
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
mod linfile;
|
||||
|
||||
@@ -73,7 +73,7 @@ struct Parser<'a> {
|
||||
|
||||
impl Parser<'_> {
|
||||
fn peek(&mut self) -> Option<u8> {
|
||||
while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) {
|
||||
while self.s.get(self.i).is_some_and(u8::is_ascii_whitespace) {
|
||||
self.i += 1;
|
||||
}
|
||||
self.s.get(self.i).copied()
|
||||
|
||||
@@ -23,7 +23,7 @@ pub(crate) struct Guarded {
|
||||
}
|
||||
|
||||
pub(crate) fn message(p: &(dyn Any + Send)) -> String {
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
}
|
||||
|
||||
impl Guarded {
|
||||
|
||||
@@ -359,6 +359,9 @@ pub struct Settings {
|
||||
pub gridmajor: u32,
|
||||
/// ZOOMFACTOR: how far one mouse wheel step zooms (3..=100).
|
||||
pub zoomfactor: u32,
|
||||
/// MEASUREINIT: the units of a new drawing made without a template (NEW, QNEW and the first
|
||||
/// drawing at startup): `false` (0, the default) imperial, `true` (1) metric.
|
||||
pub measureinit: bool,
|
||||
/// -VIEW sEttings > Layer snapshot: named views save the layer settings with them.
|
||||
pub view_layer_snapshot: bool,
|
||||
pub dynmode: bool,
|
||||
@@ -400,6 +403,10 @@ pub struct Settings {
|
||||
pub pickfirst: bool,
|
||||
/// ACADLSPASDOC: acad.lsp loads into every drawing, not once per session.
|
||||
pub acadlspasdoc: bool,
|
||||
/// CMDECHO: kept for routines that switch command echo around their commands; no command reads it yet.
|
||||
pub cmdecho: bool,
|
||||
/// FILEDIA: kept for routines that switch file dialogs off around a script; no command reads it yet.
|
||||
pub filedia: bool,
|
||||
pub pickadd: bool,
|
||||
/// PICKDRAG: false = two-click windows (0), true = clicks or dragging (2).
|
||||
/// Drag-only mode (1) is not supported yet.
|
||||
@@ -473,6 +480,7 @@ impl Default for Settings {
|
||||
gridunit: Vec2::new(0.5, 0.5),
|
||||
gridmajor: 5,
|
||||
zoomfactor: 60,
|
||||
measureinit: false,
|
||||
view_layer_snapshot: true,
|
||||
dynmode: true,
|
||||
dyndigrip: 31,
|
||||
@@ -491,6 +499,8 @@ impl Default for Settings {
|
||||
aperture: 10.0,
|
||||
pickfirst: true,
|
||||
acadlspasdoc: false,
|
||||
cmdecho: true,
|
||||
filedia: true,
|
||||
pickadd: true,
|
||||
pickdrag: true,
|
||||
pickstyle: 1,
|
||||
@@ -819,7 +829,7 @@ impl Session {
|
||||
}
|
||||
/// The space commands act on (model space while working inside a layout viewport).
|
||||
pub fn space(&self) -> Space {
|
||||
self.state().map(|s| s.edit_space()).unwrap_or_default()
|
||||
self.state().map(DocState::edit_space).unwrap_or_default()
|
||||
}
|
||||
/// The tab being displayed: Model or a layout, regardless of MSPACE.
|
||||
pub fn layout_space(&self) -> Space {
|
||||
@@ -881,7 +891,7 @@ impl Session {
|
||||
|
||||
/// World units per pixel at the current view.
|
||||
pub fn pixel_size(&self) -> f64 {
|
||||
let v = self.state().map(|s| s.view()).unwrap_or_default();
|
||||
let v = self.state().map(DocState::view).unwrap_or_default();
|
||||
v.height / self.viewport_px.1.max(1.0)
|
||||
}
|
||||
|
||||
@@ -901,8 +911,11 @@ impl Session {
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let msg =
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
|
||||
let msg = p
|
||||
.downcast_ref::<&str>()
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| p.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "panic".into());
|
||||
// Restore the drawing as it was.
|
||||
if let Some((doc, sel, uid)) = &before
|
||||
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == *uid)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! `entmake` defines blocks (#1662): a BLOCK list begins a definition, the entities made after it
|
||||
//! go into the block instead of the drawing, and an ENDBLK list adds the block and returns its name.
|
||||
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use super::tests::session;
|
||||
use super::value::{Value, to_text};
|
||||
use crate::Session;
|
||||
|
||||
fn eval(s: &mut Session, code: &str) -> Result<String, String> {
|
||||
match s.lisp_eval(code, false, true, Vec::<Value>::new()) {
|
||||
Ok(Some(Ok(v))) => Ok(to_text(&v, true)),
|
||||
Ok(Some(Err(e))) => Err(e),
|
||||
Ok(None) => Err("did not finish".into()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn ok(s: &mut Session, code: &str) -> String {
|
||||
match eval(s, code) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{code}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn model_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().model.len()
|
||||
}
|
||||
|
||||
const BLOCK_EB: &str = r#"(entmake '((0 . "BLOCK") (2 . "EB") (70 . 0) (10 5.0 5.0 0.0) (4 . "made by entmake")))"#;
|
||||
const LINE: &str = r#"(entmake '((0 . "LINE") (8 . "0") (10 0.0 0.0 0.0) (11 10.0 0.0 0.0)))"#;
|
||||
const CIRCLE: &str = r#"(entmake '((0 . "CIRCLE") (8 . "PARTS") (10 5.0 5.0 0.0) (40 . 2.5)))"#;
|
||||
const ENDBLK: &str = r#"(entmake '((0 . "ENDBLK")))"#;
|
||||
|
||||
/// The issue's routine: BLOCK, a LINE, ENDBLK, then tblsearch.
|
||||
#[test]
|
||||
fn the_issues_routine_defines_the_block() {
|
||||
let mut s = session();
|
||||
let code = r#"(list (entmake (quote ((0 . "BLOCK") (2 . "EB") (70 . 0) (10 0.0 0.0 0.0)))) (entmake (quote ((0 . "LINE") (8 . "0") (10 0.0 0.0 0.0) (11 10.0 0.0 0.0)))) (entmake (quote ((0 . "ENDBLK")))) (cdr (assoc 2 (tblsearch "BLOCK" "EB"))))"#;
|
||||
let v = ok(&mut s, code);
|
||||
assert!(v.starts_with("(((0 . \"BLOCK\")"), "{v}");
|
||||
assert!(v.ends_with(" \"EB\" \"EB\")"), "ENDBLK returns the name and tblsearch finds it: {v}");
|
||||
assert_eq!(model_len(&s), 0, "the LINE went into the block, not model space");
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.block("EB").unwrap().entities.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_entities_base_point_and_description_are_kept() {
|
||||
let mut s = session();
|
||||
assert!(ok(&mut s, BLOCK_EB).starts_with("((0 . \"BLOCK\")"));
|
||||
assert!(ok(&mut s, LINE).starts_with("((0 . \"LINE\")"), "an entity in a definition returns its list");
|
||||
ok(&mut s, CIRCLE);
|
||||
assert_eq!(model_len(&s), 0);
|
||||
assert!(s.doc().unwrap().block("EB").is_none(), "nothing is added before ENDBLK");
|
||||
assert_eq!(ok(&mut s, ENDBLK), "\"EB\"");
|
||||
let d = s.doc().unwrap();
|
||||
let b = d.block("EB").unwrap();
|
||||
assert_eq!((b.base.x, b.base.y, b.description.as_str()), (5.0, 5.0, "made by entmake"));
|
||||
let hs: Vec<_> = b.entities.iter().map(|e| e.handle).collect();
|
||||
assert_eq!(hs.len(), 2);
|
||||
assert!(hs[0].0 != 0 && hs[0] != hs[1], "each entity gets its own handle: {hs:?}");
|
||||
assert!(d.layers.iter().any(|l| l.name == "PARTS"), "a layer the entities name is created");
|
||||
// It inserts like any block, and entmake makes entities in the drawing again afterwards.
|
||||
s.execute("insert", &serde_json::json!({"name": "EB", "at": [100, 0]})).unwrap();
|
||||
ok(&mut s, LINE);
|
||||
assert_eq!(model_len(&s), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_blocks_get_a_name() {
|
||||
let mut s = session();
|
||||
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "*U") (70 . 1) (10 0.0 0.0 0.0)))"#);
|
||||
ok(&mut s, LINE);
|
||||
let name = ok(&mut s, ENDBLK);
|
||||
assert!(name.starts_with("\"*U") && name.len() > 4, "{name}");
|
||||
let d = s.doc().unwrap();
|
||||
let b = d.block(name.trim_matches('"')).unwrap();
|
||||
assert!(b.anonymous);
|
||||
assert_eq!(b.entities.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_of_the_same_name_is_redefined() {
|
||||
let mut s = session();
|
||||
for (n, code) in [(1, LINE), (2, CIRCLE)] {
|
||||
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "R") (70 . 0) (10 0.0 0.0 0.0)))"#);
|
||||
for _ in 0..n {
|
||||
ok(&mut s, code);
|
||||
}
|
||||
assert_eq!(ok(&mut s, ENDBLK), "\"R\"");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().block("R").unwrap().entities.len(), 2, "the second definition");
|
||||
// Names are case-insensitive: the existing spelling is kept.
|
||||
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "r") (70 . 0) (10 0.0 0.0 0.0)))"#);
|
||||
assert_eq!(ok(&mut s, ENDBLK), "\"R\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_cant_be_defined_is_refused() {
|
||||
let mut s = session();
|
||||
assert_eq!(ok(&mut s, ENDBLK), "nil", "ENDBLK without a BLOCK");
|
||||
for bad in [
|
||||
r#"(entmake '((0 . "BLOCK") (2 . "") (70 . 0) (10 0.0 0.0 0.0)))"#,
|
||||
r#"(entmake '((0 . "BLOCK") (2 . "A<B") (70 . 0) (10 0.0 0.0 0.0)))"#,
|
||||
r#"(entmake '((0 . "BLOCK") (2 . "*NOTANON") (70 . 0) (10 0.0 0.0 0.0)))"#,
|
||||
r#"(entmake '((0 . "BLOCK") (2 . "XR") (70 . 4) (10 0.0 0.0 0.0)))"#,
|
||||
r#"(entmake '((0 . "BLOCK") (70 . 0) (10 0.0 0.0 0.0)))"#,
|
||||
] {
|
||||
assert_eq!(ok(&mut s, bad), "nil", "{bad}");
|
||||
}
|
||||
// A BLOCK while one is open is refused; the open one goes on.
|
||||
ok(&mut s, BLOCK_EB);
|
||||
assert_eq!(ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "OTHER") (70 . 0) (10 0.0 0.0 0.0)))"#), "nil");
|
||||
ok(&mut s, LINE);
|
||||
assert_eq!(ok(&mut s, ENDBLK), "\"EB\"");
|
||||
assert!(s.doc().unwrap().block("OTHER").is_none());
|
||||
// A block that inserts itself isn't added.
|
||||
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "SELF") (70 . 0) (10 0.0 0.0 0.0)))"#);
|
||||
ok(&mut s, r#"(entmake '((0 . "INSERT") (2 . "SELF") (10 0.0 0.0 0.0)))"#);
|
||||
assert_eq!(ok(&mut s, ENDBLK), "nil");
|
||||
assert!(s.doc().unwrap().block("SELF").is_none());
|
||||
assert_eq!(model_len(&s), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_drawing_keeps_the_block() {
|
||||
let mut s = session();
|
||||
for code in [BLOCK_EB, LINE, CIRCLE, ENDBLK] {
|
||||
ok(&mut s, code);
|
||||
}
|
||||
s.execute("insert", &serde_json::json!({"name": "EB", "at": [100, 0]})).unwrap();
|
||||
let read = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
|
||||
let b = read.block("EB").unwrap();
|
||||
assert_eq!((b.entities.len(), b.base.x, b.base.y), (2, 5.0, 5.0));
|
||||
assert_eq!(read.model.len(), 1, "the insert");
|
||||
}
|
||||
|
||||
/// Flag 1 makes an anonymous block whatever the name says: a name that isn't `*X` gets `*U`.
|
||||
#[test]
|
||||
fn an_anonymous_block_is_always_named_with_a_star() {
|
||||
let mut s = session();
|
||||
for (given, prefix) in [("FOO", "*U"), ("*X", "*X"), ("*", "*U")] {
|
||||
ok(&mut s, &format!(r#"(entmake '((0 . "BLOCK") (2 . "{given}") (70 . 1) (10 0.0 0.0 0.0)))"#));
|
||||
ok(&mut s, LINE);
|
||||
let name = ok(&mut s, ENDBLK);
|
||||
let name = name.trim_matches('"');
|
||||
assert!(name.starts_with(prefix) && name.len() > prefix.len(), "{given} -> {name}");
|
||||
assert!(s.doc().unwrap().block(name).unwrap().anonymous);
|
||||
}
|
||||
}
|
||||
|
||||
/// As BLOCK: a block being edited in place (REFEDIT) isn't redefined until REFCLOSE.
|
||||
#[test]
|
||||
fn a_block_being_edited_in_place_is_not_redefined() {
|
||||
let mut s = session();
|
||||
for code in [r#"(entmake '((0 . "BLOCK") (2 . "R") (70 . 0) (10 0.0 0.0 0.0)))"#, LINE, ENDBLK] {
|
||||
ok(&mut s, code);
|
||||
}
|
||||
let ins = s.execute("insert", &serde_json::json!({"name": "R", "at": [0, 0]})).unwrap();
|
||||
s.execute("refedit", &serde_json::json!({"handle": ins["handle"]})).unwrap();
|
||||
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "r") (70 . 0) (10 0.0 0.0 0.0)))"#);
|
||||
ok(&mut s, CIRCLE);
|
||||
ok(&mut s, CIRCLE);
|
||||
assert_eq!(ok(&mut s, ENDBLK), "nil");
|
||||
s.execute("refclose", &serde_json::json!({"option": "discard"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().block("R").unwrap().entities.len(), 1, "the definition is unchanged");
|
||||
// After REFCLOSE it can be redefined.
|
||||
for code in [r#"(entmake '((0 . "BLOCK") (2 . "R") (70 . 0) (10 0.0 0.0 0.0)))"#, CIRCLE, CIRCLE] {
|
||||
ok(&mut s, code);
|
||||
}
|
||||
assert_eq!(ok(&mut s, ENDBLK), "\"R\"");
|
||||
assert_eq!(s.doc().unwrap().block("R").unwrap().entities.len(), 2);
|
||||
}
|
||||
|
||||
/// A routine that fails drops the definition it began, with or without an *error* handler: the
|
||||
/// next routine's entities go into the drawing. One that ends normally keeps it open.
|
||||
#[test]
|
||||
fn a_failed_routine_drops_its_open_definition() {
|
||||
let mut s = session();
|
||||
for handler in [None, Some("(defun *error* (msg) (princ msg))")] {
|
||||
if let Some(h) = handler {
|
||||
ok(&mut s, h);
|
||||
}
|
||||
let before = model_len(&s);
|
||||
assert!(eval(&mut s, r#"(progn (entmake '((0 . "BLOCK") (2 . "FAIL") (70 . 0) (10 0.0 0.0 0.0))) (car 1))"#).is_err());
|
||||
ok(&mut s, LINE);
|
||||
assert_eq!(model_len(&s), before + 1, "the line is in the drawing ({handler:?})");
|
||||
assert_eq!(ok(&mut s, ENDBLK), "nil", "nothing is open");
|
||||
assert!(s.doc().unwrap().block("FAIL").is_none());
|
||||
}
|
||||
// A routine that ends normally leaves its definition open for the next one.
|
||||
ok(&mut s, BLOCK_EB);
|
||||
ok(&mut s, LINE);
|
||||
assert_eq!(ok(&mut s, ENDBLK), "\"EB\"");
|
||||
}
|
||||
|
||||
/// An anonymous block that inserts the name it is about to get (`*U1` in an empty drawing) is a
|
||||
/// block inserting itself too: refused like a named one.
|
||||
#[test]
|
||||
fn an_anonymous_block_that_inserts_itself_is_refused() {
|
||||
let mut s = session();
|
||||
assert!(s.doc().unwrap().block("*U1").is_none());
|
||||
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "*U") (70 . 1) (10 0.0 0.0 0.0)))"#);
|
||||
ok(&mut s, r#"(entmake '((0 . "INSERT") (2 . "*U1") (10 0.0 0.0 0.0)))"#);
|
||||
assert_eq!(ok(&mut s, ENDBLK), "nil");
|
||||
assert!(s.doc().unwrap().block("*U1").is_none());
|
||||
// The next anonymous block still gets a name and is added.
|
||||
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "*U") (70 . 1) (10 0.0 0.0 0.0)))"#);
|
||||
ok(&mut s, LINE);
|
||||
assert_eq!(ok(&mut s, ENDBLK), "\"*U1\"");
|
||||
}
|
||||
|
||||
/// The layers a definition's entities name are created only when the block is added: a refused
|
||||
/// definition, or one a failed routine drops, leaves no layer behind.
|
||||
#[test]
|
||||
fn a_definition_that_isnt_added_leaves_no_layer() {
|
||||
let mut s = session();
|
||||
let has = |s: &Session, n: &str| s.doc().unwrap().layers.iter().any(|l| l.name.eq_ignore_ascii_case(n));
|
||||
// Refused at ENDBLK: it inserts itself.
|
||||
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "SELF") (70 . 0) (10 0.0 0.0 0.0)))"#);
|
||||
ok(&mut s, r#"(entmake '((0 . "LINE") (8 . "ORPHAN1") (10 0.0 0.0 0.0) (11 1.0 0.0 0.0)))"#);
|
||||
ok(&mut s, r#"(entmake '((0 . "INSERT") (8 . "ORPHAN1") (2 . "SELF") (10 0.0 0.0 0.0)))"#);
|
||||
assert!(!has(&s, "ORPHAN1"), "nothing before ENDBLK");
|
||||
assert_eq!(ok(&mut s, ENDBLK), "nil");
|
||||
assert!(!has(&s, "ORPHAN1"), "a refused block leaves no layer");
|
||||
// Dropped: the routine fails.
|
||||
assert!(eval(&mut s, r#"(progn (entmake '((0 . "BLOCK") (2 . "GONE") (70 . 0) (10 0.0 0.0 0.0))) (entmake '((0 . "LINE") (8 . "ORPHAN2") (10 0.0 0.0 0.0) (11 1.0 0.0 0.0))) (car 1))"#).is_err());
|
||||
assert!(!has(&s, "ORPHAN2"), "a dropped definition leaves no layer");
|
||||
// Added: its layers are created.
|
||||
ok(&mut s, BLOCK_EB);
|
||||
ok(&mut s, CIRCLE);
|
||||
assert!(!has(&s, "PARTS"));
|
||||
assert_eq!(ok(&mut s, ENDBLK), "\"EB\"");
|
||||
assert!(has(&s, "PARTS"), "the block's layer exists once it is added");
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
//! Errors use AutoLISP's wording (`bad argument type: numberp: nil`, `too few arguments`,
|
||||
//! `divide by zero`), since routines and their `*error*` handlers match on it.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::ffi::OsStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::eval::{Ctx, Flow, Special};
|
||||
@@ -335,16 +337,16 @@ fn num_ne(a: &[Value]) -> R {
|
||||
eq_chain(a, false)
|
||||
}
|
||||
fn lt(a: &[Value]) -> R {
|
||||
chain(a, |o| o.is_lt())
|
||||
chain(a, Ordering::is_lt)
|
||||
}
|
||||
fn gt(a: &[Value]) -> R {
|
||||
chain(a, |o| o.is_gt())
|
||||
chain(a, Ordering::is_gt)
|
||||
}
|
||||
fn le(a: &[Value]) -> R {
|
||||
chain(a, |o| o.is_le())
|
||||
chain(a, Ordering::is_le)
|
||||
}
|
||||
fn ge(a: &[Value]) -> R {
|
||||
chain(a, |o| o.is_ge())
|
||||
chain(a, Ordering::is_ge)
|
||||
}
|
||||
|
||||
fn zerop(a: &[Value]) -> R {
|
||||
@@ -658,12 +660,12 @@ fn vl_list_to_string(a: &[Value]) -> R {
|
||||
fn vl_filename_base(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let s = string(&a[0])?;
|
||||
Ok(Value::str(std::path::Path::new(&*s.replace('\\', "/")).file_stem().and_then(|x| x.to_str()).unwrap_or("")))
|
||||
Ok(Value::str(std::path::Path::new(&*s.replace('\\', "/")).file_stem().and_then(OsStr::to_str).unwrap_or("")))
|
||||
}
|
||||
fn vl_filename_extension(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let s = string(&a[0])?;
|
||||
Ok(std::path::Path::new(&*s.replace('\\', "/")).extension().and_then(|x| x.to_str()).map(|e| Value::str(&format!(".{e}"))).unwrap_or_default())
|
||||
Ok(std::path::Path::new(&*s.replace('\\', "/")).extension().and_then(OsStr::to_str).map(|e| Value::str(&format!(".{e}"))).unwrap_or_default())
|
||||
}
|
||||
fn vl_filename_directory(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
|
||||
@@ -357,10 +357,25 @@ fn entity_from_list(ctx: &Ctx<'_>, list: &Value) -> Result<(String, cadcraft_doc
|
||||
pub fn entmake(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 0, 1)?;
|
||||
let Some(list) = a.first().filter(|v| !v.is_nil()) else { return Ok(Flow::Value(Value::Nil)) };
|
||||
// A block definition is a BLOCK list, its entities, then an ENDBLK list (#1662).
|
||||
match list_to_groups(list) {
|
||||
Ok((k, tags)) if k == "BLOCK" => return Ok(Flow::Value(begin_block(ctx, list, &tags))),
|
||||
Ok((k, _)) if k == "ENDBLK" => return Ok(Flow::Value(end_block(ctx))),
|
||||
_ => {}
|
||||
}
|
||||
let (_, common, kind) = match entity_from_list(ctx, list) {
|
||||
Ok(x) => x,
|
||||
Err(_) => return Ok(Flow::Value(Value::Nil)),
|
||||
};
|
||||
if let Some((_, ents)) = ctx.env.pending_block.as_mut() {
|
||||
// Into the block being defined; it is added to the drawing, with handles and the layers
|
||||
// its entities name, at ENDBLK (a refused or dropped definition leaves nothing behind).
|
||||
if ents.len() >= MAX_PENDING_BLOCK_ENTITIES {
|
||||
return Ok(Flow::Value(Value::Nil));
|
||||
}
|
||||
ents.push(Entity { handle: Handle(0), common, kind });
|
||||
return Ok(Flow::Value(list.clone()));
|
||||
}
|
||||
let space = ctx.s.space();
|
||||
let Ok(d) = ctx.s.doc_mut() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
// A layer entmake names that doesn't exist yet is created.
|
||||
@@ -374,6 +389,75 @@ pub fn entmake(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `entmake` of a BLOCK list (2 name, 70 flags, 10 base point, 4 description): begin a block
|
||||
/// definition. Flag 1 makes an anonymous block (`*U…`, named at ENDBLK); xrefs (flag 4) can't be
|
||||
/// made this way. nil, and the definition already begun kept, when one is open.
|
||||
fn begin_block(ctx: &mut Ctx<'_>, list: &Value, tags: &[Tag]) -> Value {
|
||||
if ctx.env.pending_block.is_some() {
|
||||
return Value::Nil;
|
||||
}
|
||||
let group = |code: i32| tags.iter().find(|t| t.code == code);
|
||||
let flags = group(70).map(Tag::i64).unwrap_or(0);
|
||||
let name = group(2).map(|t| t.str().trim().to_string()).unwrap_or_default();
|
||||
let anonymous = flags & 1 != 0;
|
||||
if flags & 4 != 0 || !(anonymous && name.starts_with('*') || crate::cmd::blocks::valid_block_name(&name)) {
|
||||
return Value::Nil;
|
||||
}
|
||||
let mut b = cadcraft_doc::Block::new(&name);
|
||||
b.anonymous = anonymous;
|
||||
b.base = cadcraft_geom::Vec3::new(
|
||||
group(10).map(Tag::f64).unwrap_or(0.0),
|
||||
group(20).map(Tag::f64).unwrap_or(0.0),
|
||||
group(30).map(Tag::f64).unwrap_or(0.0),
|
||||
);
|
||||
if !b.base.is_finite() {
|
||||
return Value::Nil;
|
||||
}
|
||||
b.description = group(4).map(Tag::str).unwrap_or_default();
|
||||
ctx.env.pending_block = Some((b, Vec::new()));
|
||||
list.clone()
|
||||
}
|
||||
|
||||
/// The most entities one `entmake` block definition takes (a runaway loop can't fill memory).
|
||||
const MAX_PENDING_BLOCK_ENTITIES: usize = 1_000_000;
|
||||
|
||||
/// `entmake` of an ENDBLK list: add the block begun to the drawing (redefining one of the same
|
||||
/// name) and return its name, generated for an anonymous block. nil without one begun, or when
|
||||
/// its entities reference the block itself.
|
||||
fn end_block(ctx: &mut Ctx<'_>) -> Value {
|
||||
let Some((mut b, ents)) = ctx.env.pending_block.take() else { return Value::Nil };
|
||||
let Ok(d) = ctx.s.doc_mut() else { return Value::Nil };
|
||||
if b.anonymous {
|
||||
// Its kind letter (`*U`, `*X`…) is kept; a name that isn't one gets `*U`.
|
||||
let prefix: String = b.name.chars().take(2).collect();
|
||||
let prefix = if prefix.chars().count() == 2 && prefix.starts_with('*') { prefix.as_str() } else { "*U" };
|
||||
b.name = d.anonymous_block_name(prefix);
|
||||
} else {
|
||||
// Block names are case-insensitive: a redefinition keeps the existing name.
|
||||
if let Some(existing) = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&b.name)) {
|
||||
b.name = existing.clone();
|
||||
}
|
||||
// As BLOCK: a block being edited in place isn't redefined until REFCLOSE.
|
||||
if d.refedit.as_ref().is_some_and(|r| r.block.eq_ignore_ascii_case(&b.name)) {
|
||||
return Value::Nil;
|
||||
}
|
||||
}
|
||||
// Named or anonymous (a routine can know the name an anonymous block will get), a block
|
||||
// that inserts itself isn't added.
|
||||
if crate::cmd::blocks::references_block(d, &ents, &b.name) {
|
||||
return Value::Nil;
|
||||
}
|
||||
for mut e in ents {
|
||||
d.ensure_layer(&e.common.layer);
|
||||
e.handle = d.new_handle();
|
||||
b.entities.push(e);
|
||||
}
|
||||
let name = b.name.clone();
|
||||
d.blocks.insert(name.clone(), Arc::new(b));
|
||||
ctx.s.touch();
|
||||
Value::str(&name)
|
||||
}
|
||||
|
||||
pub fn entmod(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 1)?;
|
||||
let list = &a[0];
|
||||
@@ -985,7 +1069,14 @@ pub fn distof(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
pub fn angtof(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 2)?;
|
||||
let s = string(&a[0])?;
|
||||
Ok(Flow::Value(ctx.s.angle_settings().direction(&s).map(Value::Real).unwrap_or_default()))
|
||||
let mut settings = ctx.s.angle_settings();
|
||||
// AUNITS 0..=4; any other units argument is an error in AutoCAD, nil here.
|
||||
match a.get(1) {
|
||||
None | Some(Value::Nil) => {}
|
||||
Some(Value::Int(u)) if (0..=4).contains(u) => settings.aunits = i64::from(*u),
|
||||
Some(_) => return Ok(Flow::Value(Value::Nil)),
|
||||
}
|
||||
Ok(Flow::Value(settings.direction(&s).map(Value::Real).unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// `(trans pt from to [disp])`: points are in the world system throughout (no UCS yet).
|
||||
|
||||
@@ -167,6 +167,7 @@ impl GetRequest {
|
||||
let t = if spaces { t } else { t.split(' ').next().unwrap_or("") };
|
||||
Ok(Value::str(t))
|
||||
}
|
||||
GetKind::Kword if any => Ok(Value::str(t)),
|
||||
GetKind::Kword => Err("Invalid option keyword.".into()),
|
||||
GetKind::Int => match t.trim().parse::<i32>() {
|
||||
Ok(i) if (-32768..=32767).contains(&i) => self.check_number(f64::from(i), true),
|
||||
@@ -260,7 +261,7 @@ fn keyword(keywords: &[String], t: &str) -> Option<String> {
|
||||
impl Session {
|
||||
/// ANGBASE (radians).
|
||||
fn lisp_angbase(&self) -> f64 {
|
||||
crate::sysvars::get(self, "ANGBASE").and_then(|v| v.as_f64()).unwrap_or(0.0)
|
||||
self.doc().map(|d| d.header.f64("ANGBASE", 0.0)).unwrap_or(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ pub struct Env {
|
||||
pub order: Option<(usize, Arc<Vec<Handle>>)>,
|
||||
/// `tblnext`'s place in each table.
|
||||
pub tblnext: HashMap<String, usize>,
|
||||
/// A block definition `entmake` has begun (a BLOCK list) and not yet ended (ENDBLK): the
|
||||
/// entities made meanwhile go into it, numbered when it ends.
|
||||
pub pending_block: Option<(cadcraft_doc::Block, Vec<cadcraft_doc::Entity>)>,
|
||||
/// The command history's length after LISP last printed a line: when something else wrote since
|
||||
/// (a prompt, a command's echo), a leading newline only ends that line.
|
||||
pub printed_at: Option<usize>,
|
||||
@@ -75,6 +78,7 @@ impl Default for Env {
|
||||
deleted: HashMap::new(),
|
||||
order: None,
|
||||
tblnext: HashMap::new(),
|
||||
pending_block: None,
|
||||
printed_at: None,
|
||||
};
|
||||
builtins::install(&mut e);
|
||||
@@ -440,6 +444,12 @@ impl Session {
|
||||
/// A LISP error: run `*error*` when the routine defined one, else print it.
|
||||
fn lisp_error(&mut self, e: LispError) {
|
||||
let Some(mut run) = self.lisp.run.take() else { return };
|
||||
// A block definition the failed routine began is dropped: a later routine's entities
|
||||
// would otherwise go into it instead of the drawing (#1662). A definition still spans
|
||||
// runs that end normally.
|
||||
if let Some(env) = self.lisp.envs.get_mut(&run.uid) {
|
||||
env.pending_block = None;
|
||||
}
|
||||
// The command a failed routine left running is cancelled.
|
||||
if self.running.is_some() {
|
||||
self.cancel();
|
||||
@@ -567,6 +577,20 @@ impl Session {
|
||||
self.lisp.active
|
||||
}
|
||||
|
||||
/// The commands the active drawing's LISP defines (`c:` functions), uppercase and sorted.
|
||||
pub fn lisp_commands(&self) -> Vec<String> {
|
||||
let Ok(uid) = self.state().map(|s| s.uid) else { return Vec::new() };
|
||||
let Some(env) = self.lisp.envs.get(&uid) else { return Vec::new() };
|
||||
let mut names: Vec<String> = env
|
||||
.globals
|
||||
.iter()
|
||||
.filter(|(name, f)| name.name().starts_with("C:") && matches!(f, Value::Func(_) | Value::Subr(_)))
|
||||
.filter_map(|(name, _)| name.name().strip_prefix("C:").filter(|n| !n.is_empty()).map(str::to_string))
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// A `c:` function for a command name typed at the command line.
|
||||
pub(crate) fn lisp_command(&self, name: &str) -> Option<Value> {
|
||||
if self.lisp.active {
|
||||
@@ -600,7 +624,7 @@ impl Session {
|
||||
self.lisp.files.insert(file_key(name), text.to_string());
|
||||
let r = self.lisp_eval(text, false, blocking, Vec::new())?;
|
||||
if !matches!(r, Some(Err(_))) {
|
||||
let shown = std::path::Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(name).to_string();
|
||||
let shown = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name).to_string();
|
||||
self.echo(format!("{shown} successfully loaded."));
|
||||
}
|
||||
Ok(r)
|
||||
@@ -608,9 +632,12 @@ impl Session {
|
||||
}
|
||||
|
||||
fn file_key(name: &str) -> String {
|
||||
let base = std::path::Path::new(&name.replace('\\', "/")).file_name().and_then(|n| n.to_str()).unwrap_or(name).to_ascii_lowercase();
|
||||
let base = std::path::Path::new(&name.replace('\\', "/")).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name).to_ascii_lowercase();
|
||||
base.strip_suffix(".lsp").map(str::to_string).unwrap_or(base)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod block_tests;
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::value::{Value, to_text};
|
||||
use crate::Session;
|
||||
use crate::cmd::file::{EntityCodec, set_entity_codec};
|
||||
|
||||
fn session() -> Session {
|
||||
pub(super) fn session() -> Session {
|
||||
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
|
||||
Session::new()
|
||||
}
|
||||
@@ -437,6 +437,24 @@ fn tables_and_variables() {
|
||||
assert_eq!(ok(&mut s, "(getvar \"NOSUCHVAR\")"), "nil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn getvar_drawing_and_session_variables() {
|
||||
let mut s = session();
|
||||
assert_eq!(ok(&mut s, "(list (getvar \"DWGPREFIX\") (getvar \"DWGTITLED\"))"), "(\"\" 0)");
|
||||
assert_eq!(ok(&mut s, "(list (getvar \"FILEDIA\") (getvar \"CMDECHO\") (getvar \"TILEMODE\"))"), "(1 1 1)");
|
||||
assert_eq!(ok(&mut s, "(setvar \"FILEDIA\" 0) (setvar \"CMDECHO\" 0) (getvar \"FILEDIA\")"), "0");
|
||||
// Both belong to the session, so they survive a switch to another drawing.
|
||||
s.new_drawing(true);
|
||||
assert_eq!(ok(&mut s, "(list (getvar \"FILEDIA\") (getvar \"CMDECHO\"))"), "(0 0)");
|
||||
assert_eq!(ok(&mut s, "(getvar \"CMDACTIVE\")"), "0");
|
||||
let dir = std::env::temp_dir();
|
||||
let file = dir.join("lisp_getvar_test.dxf");
|
||||
s.state_mut().unwrap().path = Some(file.to_string_lossy().into_owned());
|
||||
let want = format!("{}{}", dir.to_string_lossy().trim_end_matches(std::path::MAIN_SEPARATOR), std::path::MAIN_SEPARATOR);
|
||||
assert_eq!(crate::sysvars::get(&s, "DWGPREFIX"), Some(serde_json::json!(want)));
|
||||
assert_eq!(ok(&mut s, "(getvar \"DWGTITLED\")"), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_functions_take_supplied_answers() {
|
||||
let mut s = session();
|
||||
@@ -464,6 +482,13 @@ fn interactive_routine_waits_for_the_user() {
|
||||
s.cmdline("(progn (initget 1 \"Yes No\") (setq ans (getkword \"\\nSure? [Yes/No]: \")))").unwrap();
|
||||
s.cmdline("n").unwrap();
|
||||
assert_eq!(to_text(&s.lisp_var("ANS"), true), "\"No\"");
|
||||
// Bit 128 lets getkword return arbitrary text; a keyword still wins.
|
||||
s.cmdline("(progn (initget 128 \"SElect CUrrent\") (setq ans (getkword \"\\nOption: \")))").unwrap();
|
||||
s.cmdline("banana").unwrap();
|
||||
assert_eq!(to_text(&s.lisp_var("ANS"), true), "\"banana\"");
|
||||
s.cmdline("(progn (initget 128 \"SElect CUrrent\") (setq ans (getkword \"\\nOption: \")))").unwrap();
|
||||
s.cmdline("cu").unwrap();
|
||||
assert_eq!(to_text(&s.lisp_var("ANS"), true), "\"CUrrent\"");
|
||||
s.cmdline("(setq d (getdist '(0 0)))").unwrap();
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert_eq!(to_text(&s.lisp_var("D"), true), "5.0");
|
||||
@@ -479,6 +504,24 @@ fn interactive_routine_waits_for_the_user() {
|
||||
assert!(s.doc().unwrap().model.iter().any(|e| matches!(&e.kind, cadcraft_doc::EntityKind::Circle(c) if c.center.x == 50.0 && c.radius == 2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn getangle_is_relative_to_angbase() {
|
||||
let mut s = session();
|
||||
s.cmdline("(setvar \"ANGBASE\" 90)").unwrap();
|
||||
// A picked point at 180° in world terms is 90° from ANGBASE.
|
||||
s.cmdline("(setq a (getangle '(0 0)))").unwrap();
|
||||
s.input(crate::Input::Point(cadcraft_geom::Vec2::new(-1.0, 0.0))).unwrap();
|
||||
assert_eq!(to_text(&s.lisp_var("A"), true), "1.5708");
|
||||
// getorient ignores ANGBASE.
|
||||
s.cmdline("(setq o (getorient '(0 0)))").unwrap();
|
||||
s.input(crate::Input::Point(cadcraft_geom::Vec2::new(-1.0, 0.0))).unwrap();
|
||||
assert_eq!(to_text(&s.lisp_var("O"), true), "3.14159");
|
||||
// A typed angle is already measured from ANGBASE.
|
||||
s.cmdline("(setq t2 (getangle))").unwrap();
|
||||
s.cmdline("180").unwrap();
|
||||
assert_eq!(to_text(&s.lisp_var("T2"), true), "3.14159");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appload_and_load() {
|
||||
let mut s = session();
|
||||
@@ -499,6 +542,63 @@ fn appload_and_load() {
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains("bad argument type")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appload_reads_a_lsp_path_and_reports_its_commands() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-appload-lisp-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Tools.LSP");
|
||||
std::fs::write(&path, "(defun c:hi () (princ \"hi\") (princ)) (defun c:bye () (princ))").unwrap();
|
||||
let mut s = session();
|
||||
let r = s.execute("appload", &serde_json::json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
assert_eq!(r["commands"], serde_json::json!(["BYE", "HI"]));
|
||||
assert!(s.log.iter().any(|l| l == "Tools.LSP successfully loaded."));
|
||||
s.cmdline("HI").unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("hi"));
|
||||
// Only `.lsp` files are APPLOADed, whatever the case of the extension.
|
||||
let txt = dir.join("notes.txt");
|
||||
std::fs::write(&txt, "(defun c:no () (princ))").unwrap();
|
||||
let e = s.execute("appload", &serde_json::json!({"path": txt.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(e.contains(".lsp"), "{e}");
|
||||
// A missing file and a file that fails to parse are ordinary errors.
|
||||
let missing = dir.join("missing.lsp");
|
||||
let e = s.execute("appload", &serde_json::json!({"path": missing.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(e.contains("can't read"), "{e}");
|
||||
let broken = dir.join("broken.lsp");
|
||||
std::fs::write(&broken, "(defun c:oops (").unwrap();
|
||||
let e = s.execute("appload", &serde_json::json!({"path": broken.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(!e.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appload_keeps_the_whitespace_of_a_relative_path() {
|
||||
/// A folder in the test's current directory, removed even when an assertion fails.
|
||||
struct TempDir(std::path::PathBuf);
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
// A JSON call's path is read as it is given: a relative path whose folder starts with a
|
||||
// space names that folder, not the one its trimmed name would.
|
||||
let dir = TempDir(std::path::PathBuf::from(format!(" cadcraft-appload-space-{}", std::process::id())));
|
||||
let _ = std::fs::remove_dir_all(&dir.0);
|
||||
std::fs::create_dir_all(&dir.0).unwrap();
|
||||
let path = dir.0.join("tools.lsp");
|
||||
std::fs::write(&path, "(setq space-loaded 1)").unwrap();
|
||||
let mut s = session();
|
||||
let r = s.execute("appload", &serde_json::json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
assert_eq!(ok(&mut s, "space-loaded"), "1");
|
||||
// A path of blanks only is still refused, not read as "".
|
||||
for blank in ["", " ", "\t "] {
|
||||
let e = s.execute("appload", &serde_json::json!({"path": blank})).unwrap_err().to_string();
|
||||
assert!(e.contains("`path`"), "{blank:?}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_lisp_never_panics() {
|
||||
let mut s = session();
|
||||
@@ -746,3 +846,12 @@ fn a_panic_inside_a_routine_ends_it_with_an_error() {
|
||||
assert!(s.log.iter().any(|l| l.contains("internal error")), "{:?}", s.log);
|
||||
assert_eq!(ok(&mut s, "(+ 1 2)"), "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angtof_reads_the_string_in_the_given_units() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(ok(&mut s, "(angtof \"1.5\" 3)"), "1.5");
|
||||
assert_eq!(ok(&mut s, "(angtof \"180\" 0)"), "3.14159");
|
||||
assert_eq!(ok(&mut s, "(angtof \"180\" 7)"), "nil");
|
||||
assert_eq!(ok(&mut s, "(angtof \"180\" \"x\")"), "nil");
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ impl Prompt {
|
||||
self
|
||||
}
|
||||
pub fn kw(mut self, k: &[&str]) -> Self {
|
||||
self.keywords = k.iter().map(|s| s.to_string()).collect();
|
||||
self.keywords = k.iter().map(ToString::to_string).collect();
|
||||
self
|
||||
}
|
||||
pub fn base(mut self, p: Vec2) -> Self {
|
||||
|
||||
+167
-18
@@ -78,12 +78,53 @@ impl SnapHit {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum SnapCurve {
|
||||
/// A circle, or the circle an arc lies on.
|
||||
/// A circle, or the circle an arc lies on; an arc's sweep is carried by [`SnapCurve::Arc`].
|
||||
Circle { center: Vec2, radius: f64 },
|
||||
/// An arc: the points of `Circle { center, radius }` swept CCW from `start` to `end` (radians).
|
||||
Arc { center: Vec2, radius: f64, start: f64, end: f64 },
|
||||
/// The infinite line through `a` and `b` (a line, xline or ray).
|
||||
Line { a: Vec2, b: Vec2 },
|
||||
}
|
||||
|
||||
impl SnapCurve {
|
||||
/// Whether `p` lies on the drawn curve: always on a circle or a line, and only within the
|
||||
/// sweep on an arc. Tangent and perpendicular solutions on an arc's circle that fall off the
|
||||
/// arc are filtered out with this, so they never snap into the gap.
|
||||
pub fn contains(&self, p: Vec2) -> bool {
|
||||
match self {
|
||||
// A zero sweep is how a full circle is stored (see `fix_circle`): the whole circle.
|
||||
SnapCurve::Arc { center, radius, start, end } => {
|
||||
(*start - *end).abs() < 1e-12 || cadcraft_geom::Arc::new(*center, *radius, *start, *end).contains_angle(center.angle_to(p))
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
/// Points on the curve whose tangent passes through `p` (a circle's or an arc's).
|
||||
fn tangents(&self, p: Vec2) -> Vec<Vec2> {
|
||||
match self {
|
||||
SnapCurve::Circle { center, radius } | SnapCurve::Arc { center, radius, .. } => {
|
||||
Circle::new(*center, *radius).tangent_points(p).into_iter().filter(|t| self.contains(*t)).collect()
|
||||
}
|
||||
SnapCurve::Line { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Points on the curve whose perpendicular passes through `p`; `picked` is where the curve
|
||||
/// was picked, kept when `p` is the center of a circle or arc (every radius is perpendicular).
|
||||
fn perpendiculars(&self, p: Vec2, picked: Vec2) -> Vec<Vec2> {
|
||||
match self {
|
||||
SnapCurve::Circle { center, radius } | SnapCurve::Arc { center, radius, .. } => {
|
||||
let u = (p - *center).normalized();
|
||||
if u == Vec2::ZERO {
|
||||
vec![Circle::new(*center, *radius).closest(picked)]
|
||||
} else {
|
||||
[*center + u * *radius, *center - u * *radius].into_iter().filter(|q| self.contains(*q)).collect()
|
||||
}
|
||||
}
|
||||
SnapCurve::Line { a, b } => line_dir(*a, *b).map(|_| vec![cadcraft_geom::Line::new(*a, *b).project(p)]).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A deferred tangent ([`mode::TAN`]) or perpendicular ([`mode::PER`]) snap, picked while there
|
||||
/// is no base point yet (the first point of a line). The real point depends on the other end of
|
||||
/// the line, so it is resolved later with [`resolve_deferred`].
|
||||
@@ -107,6 +148,18 @@ pub fn tangent_point_in_direction(curve: &SnapCurve, dir: f64, hint: Vec2) -> Op
|
||||
let c2 = *center - Vec2::new(n.cos(), n.sin()) * *radius;
|
||||
Some(if c1.dist(hint) < c2.dist(hint) { c1 } else { c2 })
|
||||
}
|
||||
SnapCurve::Arc { center, radius, start, end } => {
|
||||
let n = dir + std::f64::consts::FRAC_PI_2;
|
||||
let c1 = *center + Vec2::new(n.cos(), n.sin()) * *radius;
|
||||
let c2 = *center - Vec2::new(n.cos(), n.sin()) * *radius;
|
||||
let on = cadcraft_geom::Arc::new(*center, *radius, *start, *end);
|
||||
let ok = |c: Vec2| on.contains_angle(center.angle_to(c));
|
||||
let (c1, c2) = (ok(c1).then_some(c1), ok(c2).then_some(c2));
|
||||
match (c1, c2) {
|
||||
(Some(a), Some(b)) => Some(if a.dist(hint) < b.dist(hint) { a } else { b }),
|
||||
(a, b) => a.or(b),
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -423,7 +476,12 @@ fn finish(
|
||||
if osmode & mode::PER != 0 {
|
||||
match p {
|
||||
Prim::Seg(Segment::Line(l)) => cands.push((mode::PER, l.project(bp))),
|
||||
Prim::Seg(Segment::Arc { arc, .. }) => cands.push((mode::PER, Circle::new(arc.center, arc.radius).closest(bp))),
|
||||
// Both antipodes, filtered to the arc's sweep: a perpendicular in the gap is
|
||||
// not offered, and the far side is still found.
|
||||
Prim::Seg(Segment::Arc { arc, .. }) => {
|
||||
let curve = SnapCurve::Arc { center: arc.center, radius: arc.radius, start: arc.start, end: arc.end };
|
||||
cands.extend(curve.perpendiculars(bp, bp).into_iter().map(|q| (mode::PER, q)));
|
||||
}
|
||||
Prim::Circle(c) => cands.push((mode::PER, c.closest(bp))),
|
||||
Prim::Infinite { base: b0, dir, .. } => cands.push((mode::PER, *b0 + *dir * (bp - *b0).dot(*dir))),
|
||||
Prim::Ellipse(e) => cands.extend(curves::ellipse_perpendiculars(e, bp).into_iter().map(|q| (mode::PER, q))),
|
||||
@@ -433,7 +491,12 @@ fn finish(
|
||||
}
|
||||
if osmode & mode::TAN != 0 {
|
||||
let pts = match p {
|
||||
Prim::Seg(Segment::Arc { arc, .. }) => Circle::new(arc.center, arc.radius).tangent_points(bp),
|
||||
// Only the tangent points that lie on the arc itself (not its circle).
|
||||
Prim::Seg(Segment::Arc { arc, .. }) => Circle::new(arc.center, arc.radius)
|
||||
.tangent_points(bp)
|
||||
.into_iter()
|
||||
.filter(|t| arc.contains_angle(arc.center.angle_to(*t)))
|
||||
.collect(),
|
||||
Prim::Circle(c) => c.tangent_points(bp),
|
||||
Prim::Ellipse(e) => curves::ellipse_tangents(e, bp),
|
||||
Prim::Spline(s) => curves::spline_tangents(s, bp),
|
||||
@@ -808,7 +871,7 @@ fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32, clip:
|
||||
for p in prims {
|
||||
let curve = match p {
|
||||
Prim::Circle(c) => SnapCurve::Circle { center: c.center, radius: c.radius },
|
||||
Prim::Seg(Segment::Arc { arc, .. }) => SnapCurve::Circle { center: arc.center, radius: arc.radius },
|
||||
Prim::Seg(Segment::Arc { arc, .. }) => SnapCurve::Arc { center: arc.center, radius: arc.radius, start: arc.start, end: arc.end },
|
||||
Prim::Seg(Segment::Line(l)) => SnapCurve::Line { a: l.a, b: l.b },
|
||||
Prim::Infinite { base, dir, .. } => SnapCurve::Line { a: *base, b: *base + *dir },
|
||||
_ => continue,
|
||||
@@ -820,6 +883,12 @@ fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32, clip:
|
||||
SnapCurve::Circle { center, radius } if osmode & mode::PER != 0 => {
|
||||
(mode::PER, "Deferred Perpendicular", Circle::new(center, radius).closest(cursor))
|
||||
}
|
||||
SnapCurve::Arc { center, radius, start, end } if osmode & mode::TAN != 0 => {
|
||||
(mode::TAN, "Deferred Tangent", cadcraft_geom::Arc::new(center, radius, start, end).closest(cursor))
|
||||
}
|
||||
SnapCurve::Arc { center, radius, start, end } if osmode & mode::PER != 0 => {
|
||||
(mode::PER, "Deferred Perpendicular", cadcraft_geom::Arc::new(center, radius, start, end).closest(cursor))
|
||||
}
|
||||
SnapCurve::Line { a, b } if osmode & mode::PER != 0 => {
|
||||
// Pick on the drawn extent: a segment's own span, an xline/ray anywhere.
|
||||
let l = cadcraft_geom::Line::new(a, b);
|
||||
@@ -848,18 +917,9 @@ pub fn resolve_deferred(a: LineEnd, b: LineEnd) -> Option<(Vec2, Vec2)> {
|
||||
if !p.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let cands: Vec<Vec2> = match (d.mode, d.curve) {
|
||||
(mode::TAN, SnapCurve::Circle { center, radius }) => Circle::new(center, radius).tangent_points(p),
|
||||
(mode::PER, SnapCurve::Circle { center, radius }) => {
|
||||
let u = (p - center).normalized();
|
||||
if u == Vec2::ZERO {
|
||||
// Every radius is perpendicular: keep the picked point.
|
||||
vec![Circle::new(center, radius).closest(d.at)]
|
||||
} else {
|
||||
vec![center + u * radius, center - u * radius]
|
||||
}
|
||||
}
|
||||
(mode::PER, SnapCurve::Line { a, b }) => line_dir(a, b).map(|_| vec![cadcraft_geom::Line::new(a, b).project(p)]).unwrap_or_default(),
|
||||
let cands: Vec<Vec2> = match d.mode {
|
||||
mode::TAN => d.curve.tangents(p),
|
||||
mode::PER => d.curve.perpendiculars(p, d.at),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
pick_best(cands.into_iter().map(|t| (t, p)), d.at, None)
|
||||
@@ -875,7 +935,12 @@ pub fn resolve_deferred(a: LineEnd, b: LineEnd) -> Option<(Vec2, Vec2)> {
|
||||
/// caller retries swapped).
|
||||
fn deferred_pairs(d1: &Deferred, d2: &Deferred) -> Option<Vec<(Vec2, Vec2)>> {
|
||||
use SnapCurve::{Circle as C, Line as L};
|
||||
Some(match ((d1.mode, d1.curve), (d2.mode, d2.curve)) {
|
||||
// The math below is on the circle an arc lies on; the sweep is checked afterwards.
|
||||
let circle_of = |c: SnapCurve| match c {
|
||||
SnapCurve::Circle { center, radius } | SnapCurve::Arc { center, radius, .. } => C { center, radius },
|
||||
other => other,
|
||||
};
|
||||
let pairs = match ((d1.mode, circle_of(d1.curve)), (d2.mode, circle_of(d2.curve))) {
|
||||
((mode::TAN, C { center: c1, radius: r1 }), (mode::TAN, C { center: c2, radius: r2 })) => {
|
||||
Circle::new(c1, r1).common_tangents(&Circle::new(c2, r2))
|
||||
}
|
||||
@@ -923,7 +988,10 @@ fn deferred_pairs(d1: &Deferred, d2: &Deferred) -> Option<Vec<(Vec2, Vec2)>> {
|
||||
vec![(p, cadcraft_geom::Line::new(a2, b2).project(p))]
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
};
|
||||
// A solution on an arc's circle that falls off the arc is no solution: the line would touch
|
||||
// the circle, not the arc that was picked.
|
||||
Some(pairs.into_iter().filter(|(p, q)| d1.curve.contains(*p) && d2.curve.contains(*q)).collect())
|
||||
}
|
||||
|
||||
/// Unit direction of the line through `a` and `b`, if it has one.
|
||||
@@ -1185,4 +1253,85 @@ mod tests {
|
||||
let curve = SnapCurve::Line { a: Vec2::ZERO, b: Vec2::X };
|
||||
assert!(resolve_deferred(LineEnd::Deferred(Deferred { mode: mode::TAN, curve, at: Vec2::ZERO }), pt(3.0, 3.0)).is_none());
|
||||
}
|
||||
|
||||
/// A drawing with one arc: center (20,0), radius 2, swept CCW from 0° to 90° (the first
|
||||
/// quadrant only, so the rest of its circle is the gap).
|
||||
fn arc_drawing() -> Drawing {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.add(
|
||||
&Space::Model,
|
||||
Common::default(),
|
||||
EntityKind::Arc(cadcraft_doc::Arc { center: Vec3::new(20.0, 0.0, 0.0), radius: 2.0, start: 0.0, end: std::f64::consts::FRAC_PI_2 }),
|
||||
)
|
||||
.unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn on_arc(p: Vec2) -> bool {
|
||||
let a = Vec2::new(20.0, 0.0).angle_to(p);
|
||||
cadcraft_geom::angle_in_sweep(a, 0.0, std::f64::consts::FRAC_PI_2) && (p.dist(Vec2::new(20.0, 0.0)) - 2.0).abs() < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_tangent_only_on_the_arc_not_its_circle() {
|
||||
let d = arc_drawing();
|
||||
// From (20,5), the circle's tangents touch at about ±23.6° from the vertical through the
|
||||
// center: one lands at 90+23.6 = 113.6° (the gap), the other at 90-23.6 = 66.4° (on the
|
||||
// arc). Only the one on the arc may snap.
|
||||
let base = Some(Vec2::new(20.0, 5.0));
|
||||
let t = osnap(&d, &Space::Model, Vec2::new(20.0 + 2.0, 0.0), 8.0, mode::TAN, base, false).expect("the arc's tangent");
|
||||
assert!(on_arc(t.point), "tangent must lie on the arc, got {:?}", t.point);
|
||||
// The other solution is on the circle but off the arc, so it is not offered.
|
||||
let off = Vec2::polar(Vec2::new(20.0, 0.0), 2.0, 113.6f64.to_radians());
|
||||
assert!(!on_arc(off));
|
||||
let none = osnap(&d, &Space::Model, off, 0.05, mode::TAN, base, false);
|
||||
assert!(none.is_none(), "a tangent in the arc's gap must not snap, got {none:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_perpendicular_clamps_to_the_arc() {
|
||||
let d = arc_drawing();
|
||||
// The foot of the perpendicular from below lands at 270°, in the gap. The opposite
|
||||
// antipode at (20,2) is on the arc. The cursor is 5 units away from it, so the aperture
|
||||
// is a little larger to keep the distance test off the boundary.
|
||||
let h = osnap(&d, &Space::Model, Vec2::new(20.0, -3.0), 5.5, mode::PER, Some(Vec2::new(20.0, -6.0)), false)
|
||||
.expect("the perpendicular on the arc");
|
||||
assert!(on_arc(h.point), "perpendicular must stay on the arc, got {:?}", h.point);
|
||||
assert!(h.point.near(Vec2::new(20.0, 2.0), 1e-9), "the opposite antipode on the arc, got {:?}", h.point);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_deferred_tangent_keeps_its_sweep() {
|
||||
let d = arc_drawing();
|
||||
let at = Vec2::polar(Vec2::new(20.0, 0.0), 2.0, 45f64.to_radians());
|
||||
let h = osnap(&d, &Space::Model, at, 0.5, mode::TAN, None, true).expect("deferred tangent on the arc");
|
||||
assert_eq!((h.mode, h.name), (mode::TAN, "Deferred Tangent"));
|
||||
let df = h.deferred.unwrap();
|
||||
// The sweep travels with the curve, so a later solve stays on the arc.
|
||||
assert!(matches!(df.curve, SnapCurve::Arc { .. }), "the arc's sweep must be kept: {:?}", df.curve);
|
||||
// A deferred tangent solves to a point on the arc, not into its gap.
|
||||
let solved = resolve_deferred(LineEnd::Deferred(df), pt(10.0, 10.0)).expect("a tangent from (10,10)");
|
||||
// "In the order given": the deferred end solves first.
|
||||
assert!(on_arc(solved.0), "resolved tangent must lie on the arc, got {:?}", solved.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_tangent_pairs_with_arcs() {
|
||||
// The arc is the top half of a circle at (0,0) r=5.
|
||||
let arc = SnapCurve::Arc { center: Vec2::new(0.0, 0.0), radius: 5.0, start: 0.0, end: std::f64::consts::PI };
|
||||
// The circle is at (10,0) r=5.
|
||||
let circle = SnapCurve::Circle { center: Vec2::new(10.0, 0.0), radius: 5.0 };
|
||||
|
||||
let d_arc = Deferred { mode: mode::TAN, curve: arc, at: Vec2::new(0.0, 5.0) };
|
||||
let d_circle = Deferred { mode: mode::TAN, curve: circle, at: Vec2::new(10.0, 5.0) };
|
||||
|
||||
// Common tangents of the carrier circles: two outer (y=5 and y=-5) and two inner.
|
||||
// The y=-5 tangent touches the arc's circle at (0,-5), which is in the gap.
|
||||
// The y=5 tangent touches at (0,5), on the arc.
|
||||
// The inner tangents touch the left circle at angles that are also not on the top half.
|
||||
// Only the y=5 solution survives the arc's sweep.
|
||||
let solved = resolve_deferred(LineEnd::Deferred(d_arc), LineEnd::Deferred(d_circle)).unwrap();
|
||||
assert!(solved.0.near(Vec2::new(0.0, 5.0), 1e-9), "expected the belt tangent on the top arc, got {:?}", solved.0);
|
||||
assert!(solved.1.near(Vec2::new(10.0, 5.0), 1e-9));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,13 @@ impl<'a> Projection<'a> {
|
||||
}
|
||||
SnapCurve::Circle { center, radius }
|
||||
}
|
||||
SnapCurve::Arc { center, radius, start, end } => {
|
||||
let (center, radius) = (self.to_paper(center), radius * self.scale);
|
||||
if !(center.is_finite() && radius.is_finite() && radius > 0.0) {
|
||||
return None;
|
||||
}
|
||||
SnapCurve::Arc { center, radius, start, end }
|
||||
}
|
||||
SnapCurve::Line { a, b } => {
|
||||
let (a, b) = (self.to_paper(a), self.to_paper(b));
|
||||
if !(a.is_finite() && b.is_finite()) {
|
||||
|
||||
@@ -55,6 +55,8 @@ const SESSION_VARS: &[&str] = &[
|
||||
"APERTURE",
|
||||
"PICKFIRST",
|
||||
"ACADLSPASDOC",
|
||||
"CMDECHO",
|
||||
"FILEDIA",
|
||||
"PICKADD",
|
||||
"PICKDRAG",
|
||||
"PICKSTYLE",
|
||||
@@ -62,6 +64,7 @@ const SESSION_VARS: &[&str] = &[
|
||||
"CURSORSIZE",
|
||||
"MAXARRAY",
|
||||
"ZOOMFACTOR",
|
||||
"MEASUREINIT",
|
||||
"LASTPOINT",
|
||||
"FONTALT",
|
||||
"FONTFALLBACK",
|
||||
@@ -129,6 +132,8 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
"APERTURE" => json!(st.aperture),
|
||||
"PICKFIRST" => json!(i32::from(st.pickfirst)),
|
||||
"ACADLSPASDOC" => json!(i32::from(st.acadlspasdoc)),
|
||||
"CMDECHO" => json!(i32::from(st.cmdecho)),
|
||||
"FILEDIA" => json!(i32::from(st.filedia)),
|
||||
"PICKADD" => json!(i32::from(st.pickadd)),
|
||||
"PICKDRAG" => json!(i32::from(st.pickdrag) * 2),
|
||||
"PICKSTYLE" => json!(st.pickstyle),
|
||||
@@ -136,6 +141,7 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
"CURSORSIZE" => json!(st.cursorsize),
|
||||
"MAXARRAY" => json!(st.maxarray),
|
||||
"ZOOMFACTOR" => json!(st.zoomfactor),
|
||||
"MEASUREINIT" => json!(i32::from(st.measureinit)),
|
||||
"LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]),
|
||||
// Held in radians (`$ANGBASE` in DXF is in degrees); reported in degrees, like POLARANG.
|
||||
"ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()),
|
||||
@@ -149,6 +155,12 @@ 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 drawing's folder with a trailing separator ("" while it has no file); DWGTITLED is 1 once it has one.
|
||||
"DWGPREFIX" => json!(s.state().ok().and_then(|d| d.path.as_deref()).map(dwg_prefix).unwrap_or_default()),
|
||||
"DWGTITLED" => json!(s.state().map(|d| i32::from(d.path.is_some())).unwrap_or(0)),
|
||||
"CMDACTIVE" => json!(i32::from(s.running.is_some())),
|
||||
"TILEMODE" => json!(i32::from(s.state().is_ok_and(|d| d.space.layout().is_none()))),
|
||||
"PLATFORM" => json!(std::env::consts::OS),
|
||||
"BLOCKEDITOR" => json!(i32::from(s.state().is_ok_and(|d| d.block_edit.is_some()))),
|
||||
// The template NEW/QNEW start from (a profile setting, like FONTALT; "" = none).
|
||||
"QNEWTEMPLATE" => json!(crate::cmd::file::qnew_template()),
|
||||
@@ -186,6 +198,21 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// DWGPREFIX: the folder of the drawing file, made absolute, with a trailing separator.
|
||||
fn dwg_prefix(path: &str) -> String {
|
||||
let full = std::path::absolute(path).unwrap_or_else(|_| std::path::PathBuf::from(path));
|
||||
match full.parent() {
|
||||
Some(dir) => {
|
||||
let mut text = dir.to_string_lossy().into_owned();
|
||||
if !text.ends_with(std::path::MAIN_SEPARATOR) {
|
||||
text.push(std::path::MAIN_SEPARATOR);
|
||||
}
|
||||
text
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_bool(v: &Value) -> Option<bool> {
|
||||
v.as_bool().or_else(|| v.as_i64().map(|i| i != 0)).or_else(|| v.as_str().and_then(|s| s.trim().parse::<i64>().ok()).map(|i| i != 0))
|
||||
}
|
||||
@@ -205,8 +232,25 @@ fn as_pt(v: &Value) -> Option<cadcraft_geom::Vec2> {
|
||||
}
|
||||
|
||||
/// Variables `get` reports from session or drawing state that `set` can't change.
|
||||
const READ_ONLY: &[&str] =
|
||||
&["CMDNAMES", "DWGNAME", "DBMOD", "BLOCKEDITOR", "CTAB", "LASTPOINT", "VIEWCTR", "VIEWSIZE", "EXTMIN", "EXTMAX", "UNDOCTL", "UNDOMARKS"];
|
||||
const READ_ONLY: &[&str] = &[
|
||||
"CMDNAMES",
|
||||
"DWGNAME",
|
||||
"DWGPREFIX",
|
||||
"DWGTITLED",
|
||||
"CMDACTIVE",
|
||||
"TILEMODE",
|
||||
"PLATFORM",
|
||||
"DBMOD",
|
||||
"BLOCKEDITOR",
|
||||
"CTAB",
|
||||
"LASTPOINT",
|
||||
"VIEWCTR",
|
||||
"VIEWSIZE",
|
||||
"EXTMIN",
|
||||
"EXTMAX",
|
||||
"UNDOCTL",
|
||||
"UNDOMARKS",
|
||||
];
|
||||
|
||||
pub fn is_read_only(name: &str) -> bool {
|
||||
READ_ONLY.iter().any(|r| r.eq_ignore_ascii_case(name.trim())) || table::is_read_only(&name.trim().to_ascii_uppercase())
|
||||
@@ -234,6 +278,8 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
let st = &mut s.settings;
|
||||
match n.as_str() {
|
||||
"OSMODE" => st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).ok_or_else(bad)? as u32 & 0x7fff,
|
||||
// The `color` command's input: ByLayer, ByBlock, a name, an index or a true colour.
|
||||
"CECOLOR" => crate::cmd::set_current_color_value(s, v).ok_or_else(bad)?,
|
||||
"OSNAPHATCH" => st.osnaphatch = as_bool(v).ok_or_else(bad)?,
|
||||
"ANNOAUTOSCALE" => st.annoautoscale = as_i(v).filter(|i| (-4..=4).contains(i)).ok_or_else(bad)? > 0,
|
||||
// A scale of the drawing's list; ANNOAUTOSCALE adds it to the annotative objects.
|
||||
@@ -302,6 +348,8 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
"APERTURE" => st.aperture = as_f64(v).ok_or_else(bad)?.clamp(1.0, 50.0),
|
||||
"PICKFIRST" => st.pickfirst = as_bool(v).ok_or_else(bad)?,
|
||||
"ACADLSPASDOC" => st.acadlspasdoc = as_bool(v).ok_or_else(bad)?,
|
||||
"CMDECHO" => st.cmdecho = small(v, 1).ok_or_else(bad)? == 1,
|
||||
"FILEDIA" => st.filedia = small(v, 1).ok_or_else(bad)? == 1,
|
||||
"PICKADD" => st.pickadd = as_bool(v).ok_or_else(bad)?,
|
||||
"PICKDRAG" => st.pickdrag = as_i(v).filter(|b| matches!(b, 0 | 2)).map(|b| b == 2).ok_or_else(bad)?,
|
||||
"PICKSTYLE" => st.pickstyle = as_i(v).filter(|b| (0..=3).contains(b)).and_then(|b| u8::try_from(b).ok()).ok_or_else(bad)?,
|
||||
@@ -309,6 +357,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
"CURSORSIZE" => st.cursorsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0),
|
||||
"MAXARRAY" => st.maxarray = as_f64(v).ok_or_else(bad)?.clamp(100.0, 10_000_000.0) as u64,
|
||||
"ZOOMFACTOR" => st.zoomfactor = as_i(v).filter(|z| (3..=100).contains(z)).ok_or_else(bad)? as u32,
|
||||
"MEASUREINIT" => st.measureinit = small(v, 1).ok_or_else(bad)? == 1,
|
||||
"FONTALT" => cadcraft_fonts::ttf::set_font_alt(v.as_str().ok_or_else(bad)?),
|
||||
"FONTFALLBACK" => cadcraft_fonts::ttf::set_fallback_fonts(v.as_str().ok_or_else(bad)?),
|
||||
"SAVETIME" => crate::autosave::set_savetime(
|
||||
|
||||
@@ -510,6 +510,22 @@ fn properties_set_transparency() {
|
||||
assert_eq!(i["entities"][0]["transparency"], json!("ByBlock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_set_thickness() {
|
||||
// Issue #1722: `properties.set` used to ignore `thickness` while still reporting a change.
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap();
|
||||
let h = r["handle"].as_str().unwrap().to_string();
|
||||
let hh = cadcraft_doc::Handle::parse_hex(&h).unwrap();
|
||||
let th = |s: &Session| s.doc().unwrap().entity(hh).unwrap().common.thickness;
|
||||
s.execute("properties.set", &json!({"handles": [h], "thickness": 8})).unwrap();
|
||||
assert_eq!(th(&s), 8.0);
|
||||
let p = s.execute("properties", &json!({"handles": [h]})).unwrap();
|
||||
assert_eq!(p["objects"][0]["thickness"], json!(8.0));
|
||||
assert!(s.execute("properties.set", &json!({"handles": [h], "thickness": "thick"})).is_err());
|
||||
assert_eq!(th(&s), 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_extents_fits_drawing() {
|
||||
let mut s = Session::new();
|
||||
|
||||
+87
-24
@@ -2,6 +2,10 @@
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Largest scaled value (below `i64::MAX`) that the integer feet/inch splitting handles; larger
|
||||
/// distances fall back to decimal.
|
||||
const I64_SAFE_MAX: f64 = 9.0e18;
|
||||
|
||||
/// Format a distance per LUNITS (1 scientific, 2 decimal, 3 engineering, 4 architectural,
|
||||
/// 5 fractional) and LUPREC.
|
||||
pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String {
|
||||
@@ -25,7 +29,11 @@ pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String {
|
||||
let neg = v < 0.0;
|
||||
let a = v.abs();
|
||||
let denom = 1i64 << prec.min(8);
|
||||
let total = (a * denom as f64).round() as i64;
|
||||
let scaled = (a * denom as f64).round();
|
||||
if scaled >= I64_SAFE_MAX {
|
||||
return format!("{:.*}", prec, v);
|
||||
}
|
||||
let total = scaled as i64;
|
||||
let ft = total / (12 * denom);
|
||||
let rem = total - ft * 12 * denom;
|
||||
let inch = rem / denom;
|
||||
@@ -42,7 +50,11 @@ pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String {
|
||||
let neg = v < 0.0;
|
||||
let a = v.abs();
|
||||
let denom = 1i64 << prec.min(8);
|
||||
let total = (a * denom as f64).round() as i64;
|
||||
let scaled = (a * denom as f64).round();
|
||||
if scaled >= I64_SAFE_MAX {
|
||||
return format!("{:.*}", prec, v);
|
||||
}
|
||||
let total = scaled as i64;
|
||||
let whole = total / denom;
|
||||
let frac = total % denom;
|
||||
let fs = if frac == 0 {
|
||||
@@ -76,32 +88,35 @@ fn gcd(a: i64, b: i64) -> i64 {
|
||||
if b == 0 { a.abs().max(1) } else { gcd(b, a % b) }
|
||||
}
|
||||
|
||||
/// Degrees (`[0, 360]`) as degrees/minutes/seconds, shown to the AUPREC precision (`12d34'57"`).
|
||||
fn format_dms(deg: f64, prec: usize) -> String {
|
||||
// Round at the displayed precision before splitting, carrying through a full turn.
|
||||
let scale = 10i64.pow(prec.saturating_sub(4) as u32);
|
||||
let seconds_per_unit = match prec {
|
||||
0 => 3600,
|
||||
1 | 2 => 60,
|
||||
_ => 1,
|
||||
};
|
||||
let units = (deg * (3600 / seconds_per_unit * scale) as f64).round() as i64;
|
||||
let total = units * seconds_per_unit % (360 * 3600 * scale);
|
||||
let (d, m) = (total / (3600 * scale), (total / (60 * scale)) % 60);
|
||||
match prec {
|
||||
0 => format!("{d}d"),
|
||||
1 | 2 => format!("{d}d{m}'"),
|
||||
_ => {
|
||||
let s = (total % (60 * scale)) as f64 / scale as f64;
|
||||
format!("{d}d{m}'{:.*}\"", prec.saturating_sub(4), s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an angle (radians) per AUNITS (0 decimal degrees, 1 deg/min/sec, 2 grads, 3 radians,
|
||||
/// 4 surveyor's) and AUPREC.
|
||||
pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String {
|
||||
let prec = auprec.clamp(0, 8) as usize;
|
||||
let deg = cadcraft_geom::norm_angle(a).to_degrees();
|
||||
match aunits {
|
||||
1 => {
|
||||
// Round at the displayed precision before splitting, carrying through a full turn.
|
||||
let scale = 10i64.pow(prec.saturating_sub(4) as u32);
|
||||
let seconds_per_unit = match prec {
|
||||
0 => 3600,
|
||||
1 | 2 => 60,
|
||||
_ => 1,
|
||||
};
|
||||
let units = (deg * (3600 / seconds_per_unit * scale) as f64).round() as i64;
|
||||
let total = units * seconds_per_unit % (360 * 3600 * scale);
|
||||
let (d, m) = (total / (3600 * scale), (total / (60 * scale)) % 60);
|
||||
match prec {
|
||||
0 => format!("{d}d"),
|
||||
1 | 2 => format!("{d}d{m}'"),
|
||||
_ => {
|
||||
let s = (total % (60 * scale)) as f64 / scale as f64;
|
||||
format!("{d}d{m}'{:.*}\"", prec.saturating_sub(4), s)
|
||||
}
|
||||
}
|
||||
}
|
||||
1 => format_dms(deg, prec),
|
||||
2 => format!("{:.*}g", prec, wrap_full_turn(deg / 0.9, 400.0, prec)),
|
||||
3 => format!("{:.*}r", prec, wrap_full_turn(cadcraft_geom::norm_angle(a), 2.0 * PI, prec)),
|
||||
4 => {
|
||||
@@ -115,7 +130,7 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String {
|
||||
} else {
|
||||
("S", "E", deg - 270.0)
|
||||
};
|
||||
format!("{ns} {:.*}d {ew}", prec, ang)
|
||||
format!("{ns} {} {ew}", format_dms(ang, prec))
|
||||
}
|
||||
_ => format!("{:.*}", prec, wrap_full_turn(deg, 360.0, prec)),
|
||||
}
|
||||
@@ -139,6 +154,9 @@ pub fn parse_distance(s: &str) -> Option<f64> {
|
||||
Some(r) => (true, r.trim()),
|
||||
None => (false, t),
|
||||
};
|
||||
if neg && t.starts_with(['-', '+']) {
|
||||
return None;
|
||||
}
|
||||
let mut total = 0.0;
|
||||
let mut rest = t;
|
||||
let mut parsed = false;
|
||||
@@ -175,7 +193,13 @@ fn parse_mixed(s: &str) -> Option<f64> {
|
||||
};
|
||||
match parts.as_slice() {
|
||||
[a] => frac(a),
|
||||
[a, b] => Some(a.parse::<f64>().ok()? + frac(b)?),
|
||||
[a, b] => {
|
||||
let (n, d) = b.split_once('/')?;
|
||||
if n.trim().starts_with(['-', '+']) || d.trim().starts_with(['-', '+']) {
|
||||
return None;
|
||||
}
|
||||
Some(a.parse::<f64>().ok()? + frac(b)?)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -284,6 +308,9 @@ fn bearing(t: &str) -> Option<f64> {
|
||||
None => (rest.strip_suffix('w')?, false),
|
||||
};
|
||||
let a = parse_dms(ang_s.trim())?;
|
||||
if !(0.0..=90.0).contains(&a) {
|
||||
return None;
|
||||
}
|
||||
let deg = match (north, east) {
|
||||
(true, true) => 90.0 - a,
|
||||
(true, false) => 90.0 + a,
|
||||
@@ -329,6 +356,24 @@ pub const DEG: f64 = PI / 180.0;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mixed_distance_needs_a_positive_fraction_after_the_whole_number() {
|
||||
assert_eq!(parse_distance("6 1/2"), Some(6.5));
|
||||
assert_eq!(parse_distance("5 5"), None);
|
||||
assert_eq!(parse_distance("1 -1/2"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_architectural_and_fractional_distances_do_not_saturate() {
|
||||
for lunits in [4, 5] {
|
||||
for v in [1e30, -1e30] {
|
||||
assert_eq!(format_distance(v, lunits, 4), format!("{:.4}", v), "lunits {lunits}");
|
||||
}
|
||||
}
|
||||
assert_eq!(format_distance(1e15, 5, 4), "1000000000000000");
|
||||
assert_eq!(format_distance(12.5, 4, 4), "1'-0 1/2\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directions_show_from_angbase_in_the_angdir_sense() {
|
||||
let north_clockwise = AngleSettings { aunits: 0, angbase: 90f64.to_radians(), clockwise: true };
|
||||
@@ -427,6 +472,14 @@ mod tests {
|
||||
assert_eq!(parse_distance(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_distance_rejects_doubled_minus() {
|
||||
assert_eq!(parse_distance("--5"), None);
|
||||
assert_eq!(parse_distance("- -3/4"), None);
|
||||
assert_eq!(parse_distance("-5"), Some(-5.0));
|
||||
assert_eq!(parse_distance("-3/4"), Some(-0.75));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_distance_needs_a_number() {
|
||||
assert_eq!(parse_distance("-"), None);
|
||||
@@ -444,4 +497,14 @@ mod tests {
|
||||
assert_eq!(format_angle(PI / 2.0, 0, 0), "90");
|
||||
assert_eq!(format_angle(PI / 4.0, 4, 0), "N 45d E");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surveyor_display_is_degrees_minutes_seconds() {
|
||||
// 73.6975 degrees south of east: 73d41'51" (0.6975 deg = 41.85' = 41'51")
|
||||
let angle = (270.0 + 73.6975_f64).to_radians();
|
||||
assert_eq!(format_angle(angle, 4, 4), "S 73d41'51\" E");
|
||||
assert_eq!(format_angle(angle, 4, 2), "S 73d42' E");
|
||||
assert_eq!(format_angle(angle, 4, 0), "S 74d E");
|
||||
assert_eq!(format_angle(angle, 4, 6), "S 73d41'51.00\" E");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,42 @@ fn autosave_files_are_written_in_the_background_and_removed_on_exit() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Autosave files are unsaved drawings: on Unix an autosave folder that doesn't exist yet is made
|
||||
/// owner-only, and one that already exists (SAVEFILEPATH can be any folder) keeps its mode.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_new_autosave_folder_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
hooks();
|
||||
let base = temp_dir("modes");
|
||||
let picked = base.join("picked");
|
||||
std::fs::create_dir_all(&picked).unwrap();
|
||||
std::fs::set_permissions(&picked, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
for (dir, expected) in [(base.join("new").join("autosave"), 0o700), (picked, 0o755)] {
|
||||
let mut s = Session::new();
|
||||
draw(&mut s);
|
||||
// Not instance_id(): a test started in the same millisecond would get the same id, and
|
||||
// shutdown() would then remove its lock from the process-wide SAVEFILEPATH.
|
||||
let mut a = Autosaver::new("41-c");
|
||||
a.plan(&s, 0.0, 1, &dir.to_string_lossy());
|
||||
let jobs = a.plan(&s, 60.0, 1, &dir.to_string_lossy());
|
||||
let path = writes(&jobs).pop().unwrap();
|
||||
a.perform(jobs);
|
||||
for _ in 0..500 {
|
||||
if std::path::Path::new(&path).exists() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(std::path::Path::new(&path).exists());
|
||||
assert_eq!(mode(&dir), expected, "{}", dir.display());
|
||||
a.shutdown();
|
||||
}
|
||||
assert_eq!(mode(&base.join("new")), 0o700);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_autosave_is_tried_again_without_another_change() {
|
||||
hooks();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! #1645: a diameter dimension's text in its `*D` block, and in the MTEXT EXPLODE leaves, holds
|
||||
//! the diameter sign as `%%c`, as CADCraft's own TEXT and MTEXT keep it. The decoded ⌀ (U+2300)
|
||||
//! is in no SHX font, so other programs plotted the dimension in an SHX style ("romans.shx")
|
||||
//! without its sign. CADCraft still draws and reads the sign.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn h(v: &Value) -> Handle {
|
||||
v["handle"].as_str().and_then(Handle::parse_hex).unwrap()
|
||||
}
|
||||
|
||||
/// The issue's drawing: an SHX text style, a dimension style using it, a diameter dimension
|
||||
/// of a circle of radius 30.
|
||||
fn session() -> (Session, Handle) {
|
||||
let mut s = Session::new();
|
||||
s.execute("units", &json!({"measurement": "metric"})).ok();
|
||||
s.execute("style", &json!({"name": "R", "font": "romans.shx", "current": true})).unwrap();
|
||||
s.execute("dimstyle", &json!({"name": "D", "current": true, "DIMTXSTY": "R", "DIMTXT": 5, "DIMASZ": 5, "DIMDEC": 0})).unwrap();
|
||||
let c = h(&s.execute("circle", &json!({"center": [0, 0], "radius": 30})).unwrap());
|
||||
let dim = h(&s.execute("dimdiameter", &json!({"handle": c.hex(), "at": [40, 40]})).unwrap());
|
||||
(s, dim)
|
||||
}
|
||||
|
||||
/// The MTEXT contents (group 1) inside each `*D…` block of a DXF.
|
||||
fn dblock_texts(dxf: &str) -> Vec<String> {
|
||||
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
|
||||
let mut out = Vec::new();
|
||||
let (mut in_d, mut in_mtext) = (false, false);
|
||||
for pair in lines.chunks(2) {
|
||||
let [code, value] = pair else { continue };
|
||||
match (*code, *value) {
|
||||
("0", "BLOCK") => in_d = false,
|
||||
("2", name) if !in_mtext && name.starts_with("*D") => in_d = true,
|
||||
("0", "MTEXT") => in_mtext = in_d,
|
||||
("0", _) => in_mtext = false,
|
||||
("1", text) if in_mtext => out.push(text.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dimension_block_writes_the_sign_as_a_code() {
|
||||
let (s, dim) = session();
|
||||
let d = s.doc().unwrap();
|
||||
let EntityKind::Dimension(dm) = &d.entity(dim).unwrap().kind else { panic!("not a dimension") };
|
||||
assert!(matches!(dm.kind, cadcraft_engine::doc::DimKind::Diameter));
|
||||
let texts = dblock_texts(&cadcraft_io::write_dxf(d));
|
||||
assert_eq!(texts, vec!["%%c60".to_string()], "the *D block's MTEXT");
|
||||
// Read back, CADCraft still shows the sign.
|
||||
let read = cadcraft_io::read_dxf(cadcraft_io::write_dxf(d).as_bytes()).unwrap();
|
||||
let dm = read.model.iter().find_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm.clone()) } else { None }).unwrap();
|
||||
let st = read.dim_styles.iter().find(|x| x.name.eq_ignore_ascii_case(&dm.style)).cloned().unwrap_or_default();
|
||||
assert_eq!(cadcraft_render::dimension_geometry(&dm, &st, 1.0).value, "⌀60");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exploding_keeps_the_sign_as_a_code() {
|
||||
let (mut s, dim) = session();
|
||||
s.execute("explode", &json!({"handles": [dim.hex()]})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let texts: Vec<String> =
|
||||
d.model.iter().filter_map(|e| if let EntityKind::MText(m) = &e.kind { Some(m.contents.clone()) } else { None }).collect();
|
||||
assert_eq!(texts, vec!["%%c60".to_string()]);
|
||||
let saved = cadcraft_io::write_dxf(d);
|
||||
assert!(!saved.contains('⌀'), "no U+2300 is saved");
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! #1651: the issue's repro through the commands. Every dimension CADCraft reports as
|
||||
//! associated (the diameter, radius and angular ones, as well as the linear control) is saved
|
||||
//! with a DIMASSOC object whose references name its geometry.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The entity handle a command result reports, from its `handle` field.
|
||||
fn handle(r: &Value) -> Handle {
|
||||
Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// The first line of a LINE result (`handles`).
|
||||
fn first_line(r: &Value) -> Handle {
|
||||
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// The DXF tag pairs, `(code, value)`.
|
||||
fn pairs(text: &str) -> Vec<(i32, String)> {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut out = Vec::new();
|
||||
for chunk in lines.chunks(2) {
|
||||
let [code, value] = chunk else { break };
|
||||
let Ok(c) = code.trim().parse::<i32>() else { continue };
|
||||
out.push((c, value.trim().to_string()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The DIMASSOC object of a dimension, if the file has one.
|
||||
fn dimassoc(text: &str, dim: Handle) -> Option<Vec<(i32, String)>> {
|
||||
let hex = dim.hex();
|
||||
let mut recs = Vec::new();
|
||||
let mut cur: Option<Vec<(i32, String)>> = None;
|
||||
for (c, v) in pairs(text) {
|
||||
if c == 0 {
|
||||
if let Some(r) = cur.take() {
|
||||
recs.push(r);
|
||||
}
|
||||
cur = Some(vec![(c, v)]);
|
||||
} else if let Some(r) = cur.as_mut() {
|
||||
r.push((c, v));
|
||||
}
|
||||
}
|
||||
if let Some(r) = cur {
|
||||
recs.push(r);
|
||||
}
|
||||
recs.into_iter()
|
||||
.filter(|r| r.first().is_some_and(|(c, v)| *c == 0 && v == "DIMASSOC"))
|
||||
.find(|r| r.iter().rfind(|(c, _)| *c == 330).is_some_and(|(_, v)| v.eq_ignore_ascii_case(&hex)))
|
||||
}
|
||||
|
||||
/// The `AcDbOsnapPointRef` blocks of a DIMASSOC record, in file order: one per reference the
|
||||
/// dimension makes to its geometry.
|
||||
fn point_refs(rec: &[(i32, String)]) -> Vec<Vec<(i32, String)>> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<Vec<(i32, String)>> = None;
|
||||
for (c, v) in rec {
|
||||
if *c == 1 && v == "AcDbOsnapPointRef" {
|
||||
if let Some(r) = cur.take() {
|
||||
out.push(r);
|
||||
}
|
||||
cur = Some(Vec::new());
|
||||
} else if let Some(r) = cur.as_mut() {
|
||||
r.push((*c, v.clone()));
|
||||
}
|
||||
}
|
||||
if let Some(r) = cur {
|
||||
out.push(r);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The first integer value of a group code in a record (72 the osnap type, 90 the flags).
|
||||
fn int(rec: &[(i32, String)], code: i32) -> Option<i64> {
|
||||
rec.iter().find(|(c, _)| *c == code).and_then(|(_, v)| v.parse().ok())
|
||||
}
|
||||
|
||||
/// The first string value of a group code in a record (331 the referenced entity, 332 the other
|
||||
/// entity of an intersection).
|
||||
fn string(rec: &[(i32, String)], code: i32) -> Option<&str> {
|
||||
rec.iter().find(|(c, _)| *c == code).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// #1651's repro through the commands: after DIMREASSOCIATE, every dimension the session calls
|
||||
/// associated (diameter, radius, angular and a linear control) is written with a DIMASSOC whose
|
||||
/// references name real geometry. Guards the saving side, where radial and diametric dimensions
|
||||
/// used to fall through the kind-to-slot match and be written without any DIMASSOC object.
|
||||
#[test]
|
||||
fn the_issue_repro_saves_every_associated_dimension_with_dimassoc() {
|
||||
let mut s = Session::new();
|
||||
s.execute("dimstyle", &json!({ "name": "D", "current": true, "DIMTXT": 5, "DIMASZ": 5, "DIMDEC": 0 })).unwrap();
|
||||
let circle = handle(&s.execute("circle", &json!({ "center": [0, 0], "radius": 30 })).unwrap());
|
||||
let diameter = handle(&s.execute("dimdiameter", &json!({ "handle": circle.hex(), "at": [40, 40] })).unwrap());
|
||||
let radius = handle(&s.execute("dimradius", &json!({ "handle": circle.hex(), "at": [-40, -40] })).unwrap());
|
||||
let l1 = first_line(&s.execute("line", &json!({ "points": [[100, 0], [200, 0]] })).unwrap());
|
||||
let l2 = first_line(&s.execute("line", &json!({ "points": [[100, 0], [180, 60]] })).unwrap());
|
||||
let angular = handle(&s.execute("dimangular", &json!({ "lines": [l1.hex(), l2.hex()], "at": [160, 20] })).unwrap());
|
||||
let l3 = first_line(&s.execute("line", &json!({ "points": [[0, -80], [100, -80]] })).unwrap());
|
||||
let linear = handle(&s.execute("dimlinear", &json!({ "object": l3.hex(), "at": [50, -100] })).unwrap());
|
||||
|
||||
let dims = [diameter, radius, angular, linear];
|
||||
{
|
||||
let d = s.doc().unwrap();
|
||||
for h in dims {
|
||||
match d.entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Dimension(dm)) => assert!(!dm.assoc.is_empty(), "{} is associative in CADCraft", h.hex()),
|
||||
_ => panic!("{} is not a dimension", h.hex()),
|
||||
}
|
||||
}
|
||||
}
|
||||
// The issue runs DIMREASSOCIATE before saving; it does not lose any of them.
|
||||
let hs: Vec<String> = dims.iter().map(|h| h.hex()).collect();
|
||||
let r = s.execute("dimreassociate", &json!({ "handles": hs })).unwrap();
|
||||
assert_eq!(r["associated"], json!(4), "{r}");
|
||||
|
||||
let text = cadcraft_io::write_dxf(s.doc().unwrap());
|
||||
for (h, what) in [(diameter, "diameter"), (radius, "radius"), (angular, "angular"), (linear, "linear")] {
|
||||
let rec = dimassoc(&text, h).unwrap_or_else(|| panic!("{what} dimension {} has a DIMASSOC", h.hex()));
|
||||
let refs = point_refs(&rec);
|
||||
assert!(!refs.is_empty(), "{what}: the DIMASSOC has point references");
|
||||
assert!(refs.iter().all(|r| string(r, 331).is_some()), "{what}: every reference names geometry");
|
||||
}
|
||||
// The radial and diametric dimensions name the circle by its centre.
|
||||
for (h, what) in [(diameter, "diameter"), (radius, "radius")] {
|
||||
let refs = point_refs(&dimassoc(&text, h).unwrap());
|
||||
assert_eq!(refs.len(), 1, "{what}: one reference, as AutoCAD writes radial dimensions");
|
||||
assert_eq!(int(&refs[0], 72), Some(3), "{what}: a centre snap");
|
||||
assert!(string(&refs[0], 331).is_some_and(|h| h.eq_ignore_ascii_case(&circle.hex())), "{what}: to the circle");
|
||||
}
|
||||
// The angular dimension names both lines, and the linear one its line.
|
||||
let refs = point_refs(&dimassoc(&text, angular).unwrap());
|
||||
assert!(refs.len() >= 2, "angular: at least both leg points: {refs:?}");
|
||||
let named = |r: &[(i32, String)], h: Handle| string(r, 331).is_some_and(|x| x.eq_ignore_ascii_case(&h.hex()));
|
||||
assert!(refs.iter().any(|r| named(r, l1)) && refs.iter().any(|r| named(r, l2)), "angular: both lines");
|
||||
let refs = point_refs(&dimassoc(&text, linear).unwrap());
|
||||
assert_eq!(refs.len(), 2, "linear: both origins");
|
||||
assert!(refs.iter().all(|r| named(r, l3)), "linear: its line");
|
||||
}
|
||||
@@ -162,3 +162,32 @@ fn hostile_nesting_is_bounded() {
|
||||
assert!(r["blocks"]["LOOP"].as_u64().unwrap() >= 1, "{r}");
|
||||
assert!(r["blocks"].get("L16").is_none(), "nesting stops at MAX_BLOCK_DEPTH: {r}");
|
||||
}
|
||||
|
||||
/// #1440: COUNT says which objects it scanned, which it counted, and why the rest were skipped.
|
||||
#[test]
|
||||
fn self_check_says_what_count_covered() {
|
||||
let mut s = plates();
|
||||
put(&mut s, &Space::Model, circle(300.0, 1.0));
|
||||
let r = s.execute("count", &json!({})).unwrap();
|
||||
let sc = &r["selfCheck"];
|
||||
assert_eq!(sc["schema"], 1);
|
||||
assert_eq!(sc["found"], 4, "three block references and one circle: {r}");
|
||||
assert_eq!(sc["analyzed"], 3);
|
||||
assert_eq!(sc["skipped"]["Circle"], json!({ "count": 1, "reason": "not a block reference" }));
|
||||
assert_eq!(sc["caps"], json!({}));
|
||||
assert_eq!(sc["implausible"], json!([]));
|
||||
|
||||
// Counting the circles compares only circles, and says so.
|
||||
let h = put(&mut s, &Space::Model, circle(310.0, 1.0));
|
||||
let r = s.execute("count", &json!({"object": h.hex()})).unwrap();
|
||||
let sc = &r["selfCheck"];
|
||||
assert_eq!((sc["found"].clone(), sc["analyzed"].clone()), (json!(5), json!(2)), "{r}");
|
||||
assert_eq!(sc["skipped"]["Block Reference"]["reason"], "a different type");
|
||||
|
||||
// When nested expansion hits the work cap, the cap and its value are reported.
|
||||
let mut s = Session::new();
|
||||
define(&mut s, "LOOP", vec![insert("LOOP", 0.0, 1, 1)]);
|
||||
put(&mut s, &Space::Model, insert("LOOP", 0.0, 1, 1));
|
||||
let r: Value = s.execute("count", &json!({"space": "all"})).unwrap();
|
||||
assert_eq!(r["selfCheck"]["caps"], json!({ "blockDepth": 16 }), "the infinite LOOP hits the depth cap: {r}");
|
||||
}
|
||||
|
||||
@@ -42,6 +42,26 @@ fn gap_tolerance_closes_small_gaps() {
|
||||
assert!(hatch(&s, &h).associative);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_ending_on_an_ellipse_close_a_boundary() {
|
||||
// #1690: the verticals end on the true ellipse, which the boundary tracer sees as chords
|
||||
// that pass just below those ends.
|
||||
let mut s = Session::new();
|
||||
s.execute("ellipse", &json!({ "center": [0, 0], "major": [10, 0], "ratio": 0.5 })).unwrap();
|
||||
for x in [1.0f64, 2.0] {
|
||||
let y = 5.0 * (1.0 - x * x / 100.0).sqrt();
|
||||
s.execute("line", &json!({ "points": [[x, 8.0], [x, y]] })).unwrap();
|
||||
}
|
||||
let top = s.execute("line", &json!({ "points": [[0, 8], [3, 8]] })).unwrap()["handles"][0].clone();
|
||||
let h = handle(&s.execute("hatch", &json!({ "points": [[1.5, 7]] })).unwrap());
|
||||
let hh = hatch(&s, &h);
|
||||
assert_eq!(hh.loops.len(), 1);
|
||||
assert_eq!(hh.loops[0].sources.len(), 4);
|
||||
// The associative refill traces the same way: sliding the top edge keeps the hatch.
|
||||
s.execute("move", &json!({ "handles": [top], "delta": [0.5, 0] })).unwrap();
|
||||
assert!(hatch(&s, &h).associative);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_origin_options() {
|
||||
let mut s = Session::new();
|
||||
@@ -249,3 +269,13 @@ fn hatchedit_refuses_unknown_patterns_and_region_boundaries() {
|
||||
// Region boundaries need REGION objects; the hatch is left as it was.
|
||||
assert!(s.execute("hatchedit", &json!({ "handles": [h], "recreate": "region" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_wall_hatch_keeps_the_room_as_an_island() {
|
||||
// A closed multiline wall: picking inside the wall hatches the band between its outer and
|
||||
// inner lines; the room the inner lines enclose is an island, not filled.
|
||||
let mut s = Session::new();
|
||||
s.execute("mline", &json!({ "points": [[0, 0], [10, 0], [10, 10], [0, 10]], "closed": true, "scale": 1, "justification": "zero" })).unwrap();
|
||||
let h = handle(&s.execute("hatch", &json!({ "points": [[0, 5]], "pattern": "SOLID" })).unwrap());
|
||||
assert_eq!(hatch(&s, &h).loops.len(), 2, "outer face + the room as an island");
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ mod select_similar;
|
||||
mod select_underscore_keywords;
|
||||
mod selection_cycling;
|
||||
mod selection_modes;
|
||||
mod setvar_cecolor;
|
||||
mod setvar_coverage;
|
||||
mod shapes;
|
||||
mod snap_annotations;
|
||||
|
||||
@@ -29,12 +29,16 @@ fn rect_region(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
|
||||
r
|
||||
}
|
||||
|
||||
/// Add a circle and turn it into a region, returning the handle of the region's source circle.
|
||||
fn circle_region(s: &mut Session, c: [f64; 2], r: f64) -> Handle {
|
||||
let hd = h(&s.execute("circle", &json!({"center": c, "radius": r})).unwrap());
|
||||
s.execute("region", &json!({"handles": [hd.hex()]})).unwrap();
|
||||
hd
|
||||
}
|
||||
|
||||
/// REGION turns closed rectangles and circles into regions and refuses an open line; AREA and
|
||||
/// MASSPROP then report the expected area, perimeter, centroid and moments (a rectangle's and a
|
||||
/// circle's integrals are known exactly).
|
||||
#[test]
|
||||
fn region_from_closed_objects_measures() {
|
||||
let mut s = Session::new();
|
||||
@@ -68,6 +72,42 @@ fn region_from_closed_objects_measures() {
|
||||
assert!(s.execute("massprop", &json!({"handles": [line.hex()]})).is_err());
|
||||
}
|
||||
|
||||
/// #1440: MASSPROP says what it measured and what it skipped, and carries a self-check.
|
||||
#[test]
|
||||
fn massprop_self_check_reports_skipped_objects() {
|
||||
let mut s = Session::new();
|
||||
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 4.0]);
|
||||
s.execute("line", &json!({"points": [[0, 10], [5, 10]]})).unwrap();
|
||||
let line = s.doc().unwrap().model.iter().last().unwrap().handle;
|
||||
let m = s.execute("massprop", &json!({"handles": [a.hex(), line.hex()]})).unwrap();
|
||||
let sc = &m["selfCheck"];
|
||||
assert_eq!(sc["schema"], 1);
|
||||
assert_eq!((sc["found"].clone(), sc["analyzed"].clone()), (json!(2), json!(1)), "{m}");
|
||||
assert_eq!(sc["skipped"]["Line"], json!({ "count": 1, "reason": "not a region" }));
|
||||
assert_eq!(sc["caps"], json!({}));
|
||||
assert_eq!(sc["implausible"], json!([]));
|
||||
}
|
||||
|
||||
/// Make `hd` invisible: it no longer counts towards the drawing extents.
|
||||
fn hide(s: &mut Session, hd: Handle) {
|
||||
s.doc_mut().unwrap().modify_entity(hd, |e| e.common.visible = false).unwrap();
|
||||
}
|
||||
|
||||
/// #1440 review: with no visible extents (the only region is invisible) there is no drawing bound
|
||||
/// to compare an area against, so it is not flagged as larger than the extents.
|
||||
#[test]
|
||||
fn massprop_does_not_flag_area_without_visible_extents() {
|
||||
let mut s = Session::new();
|
||||
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 4.0]);
|
||||
hide(&mut s, a);
|
||||
assert!(s.doc().unwrap().extents(&Space::Model).is_empty());
|
||||
let m = s.execute("massprop", &json!({"handles": [a.hex()]})).unwrap();
|
||||
assert_eq!(m["regions"][0]["area"], json!(40.0));
|
||||
assert_eq!(m["selfCheck"]["implausible"], json!([]), "no extents means no area bound: {m}");
|
||||
}
|
||||
|
||||
/// SUBTRACT cuts a hole where a region lies inside another, and UNION adds an island when a region
|
||||
/// sits inside that hole; the loops, area and containment follow, and the removed region is gone.
|
||||
#[test]
|
||||
fn booleans_on_separate_and_nested_regions() {
|
||||
// Subtract a circle inside a rectangle: a hole.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! `setvar CECOLOR` takes what the `color` command takes: "ByLayer", "ByBlock", a colour name or an
|
||||
//! index (#1999).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::color::{Color, Rgb};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn cecolor(s: &mut Session) -> Value {
|
||||
s.execute("getvar", &json!({ "name": "CECOLOR" })).unwrap()["value"].clone()
|
||||
}
|
||||
|
||||
fn new_line_color(s: &mut Session) -> Color {
|
||||
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
|
||||
s.doc().unwrap().model.last().unwrap().common.color
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_cecolor_accepts_names() {
|
||||
for (value, aci) in [("BYLAYER", 256), ("ByLayer", 256), ("BYBLOCK", 0), ("red", 1), ("250", 250)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("setvar", &json!({ "name": "CECOLOR", "value": value })).unwrap_or_else(|e| panic!("{value}: {e}"));
|
||||
assert_eq!(cecolor(&mut s), json!(aci), "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_cecolor_name_replaces_a_true_colour() {
|
||||
let mut s = Session::new();
|
||||
s.execute("color", &json!({ "color": "10,20,30" })).unwrap();
|
||||
assert_eq!(new_line_color(&mut s), Color::True(Rgb(10, 20, 30)));
|
||||
s.execute("setvar", &json!({ "name": "CECOLOR", "value": "ByLayer" })).unwrap();
|
||||
assert_eq!(cecolor(&mut s), json!(256));
|
||||
assert_eq!(new_line_color(&mut s), Color::ByLayer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_cecolor_number_replaces_a_true_colour() {
|
||||
let mut s = Session::new();
|
||||
s.execute("color", &json!({ "color": "10,20,30" })).unwrap();
|
||||
s.execute("setvar", &json!({ "name": "CECOLOR", "value": 250 })).unwrap();
|
||||
assert_eq!(new_line_color(&mut s), Color::Index(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_cecolor_refuses_what_is_not_a_colour() {
|
||||
let mut s = Session::new();
|
||||
for bad in [json!("chartreuse"), json!(257), json!(-1), json!(true)] {
|
||||
assert!(s.execute("setvar", &json!({ "name": "CECOLOR", "value": bad })).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(cecolor(&mut s), json!(256));
|
||||
}
|
||||
@@ -2,18 +2,32 @@
|
||||
//! closed shapes in a drawing (#1436).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::geom::{PolyVertex, Vec2};
|
||||
use cadcraft_engine::doc::{Common, EntityKind, LwPolyline, Space};
|
||||
use cadcraft_engine::doc::geom::{PolyVertex, Vec2, Vec3};
|
||||
use cadcraft_engine::doc::{Common, EntityKind, Face3d, LwPolyline, Space};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const EPS: f64 = 1e-9;
|
||||
|
||||
/// Add a polyline through `(x, y, bulge)` vertices to model space.
|
||||
fn pline(s: &mut Session, pts: &[(f64, f64, f64)], closed: bool) {
|
||||
let vertices = pts.iter().map(|&(x, y, b)| PolyVertex::with_bulge(Vec2::new(x, y), b)).collect();
|
||||
let k = EntityKind::LwPolyline(LwPolyline { vertices, closed, const_width: 0.0, elevation: 0.0, plinegen: false });
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), k).unwrap();
|
||||
}
|
||||
|
||||
/// Add a unit 3D face at height `z`; a face is not a closed 2D shape, so SHAPES must skip it.
|
||||
fn face(s: &mut Session, z: f64) {
|
||||
let corners = [[0.0, 0.0, z], [1.0, 0.0, z], [1.0, 1.0, z], [0.0, 1.0, z]].map(|c| Vec3::new(c[0], c[1], c[2]));
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Face3d(Face3d { corners, hidden_edges: 0 })).unwrap();
|
||||
}
|
||||
|
||||
/// The last model-space entity, made invisible so it lies outside the drawing extents.
|
||||
fn hide_last(s: &mut Session) {
|
||||
let h = s.doc().unwrap().model.iter().last().unwrap().handle;
|
||||
s.doc_mut().unwrap().modify_entity(h, |e| e.common.visible = false).unwrap();
|
||||
}
|
||||
|
||||
/// Add a closed 10 × 10 square polyline at the origin.
|
||||
fn square(s: &mut Session) {
|
||||
pline(s, &[(0.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 10.0, 0.0), (0.0, 10.0, 0.0)], true);
|
||||
}
|
||||
@@ -75,6 +89,8 @@ fn bow_tie_reports_its_crossing() {
|
||||
near(&x[1], 5.0);
|
||||
}
|
||||
|
||||
/// Two coincident consecutive vertices are reported with their index and distance, while the
|
||||
/// shape is still measured (the duplicate does not change its area).
|
||||
#[test]
|
||||
fn repeated_vertex_is_listed() {
|
||||
let mut s = Session::new();
|
||||
@@ -85,6 +101,8 @@ fn repeated_vertex_is_listed() {
|
||||
near(&sh["area"], 100.0);
|
||||
}
|
||||
|
||||
/// An open polyline whose last vertex repeats its first is treated as closed: one shape, its area
|
||||
/// measured, and the coincident closing vertex not reported as a duplicate.
|
||||
#[test]
|
||||
fn open_polyline_with_matching_ends_is_closed() {
|
||||
let mut s = Session::new();
|
||||
@@ -95,6 +113,8 @@ fn open_polyline_with_matching_ends_is_closed() {
|
||||
assert_eq!(r["shapes"][0]["duplicateVertices"], json!([]));
|
||||
}
|
||||
|
||||
/// A circle is measured exactly, and objects that are not closed shapes (a line, a text, an open
|
||||
/// polyline) are counted under `skipped` with their reasons instead of polluting the result.
|
||||
#[test]
|
||||
fn circle_and_skipped_entities() {
|
||||
let mut s = Session::new();
|
||||
@@ -117,6 +137,8 @@ fn circle_and_skipped_entities() {
|
||||
assert_eq!(sk.values().filter(|v| v["reason"] == "not a closed shape").count(), 2);
|
||||
}
|
||||
|
||||
/// Paging reports only the requested window of shapes, and a malformed option is an error rather
|
||||
/// than silently falling back to a default.
|
||||
#[test]
|
||||
fn paging_and_bad_options() {
|
||||
let mut s = Session::new();
|
||||
@@ -131,3 +153,114 @@ fn paging_and_bad_options() {
|
||||
assert!(s.execute("shapes", &json!({ "limit": "x" })).is_err());
|
||||
assert!(s.execute("shapes", &json!({ "window": [1, 2] })).is_err());
|
||||
}
|
||||
|
||||
/// #1440: an agent must be told what an analysis covered: found, analyzed and skipped, the caps
|
||||
/// it hit, and values that cannot be right. The existing keys stay as they were.
|
||||
#[test]
|
||||
fn self_check_counts_found_analyzed_and_skipped() {
|
||||
let mut s = Session::new();
|
||||
square(&mut s);
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
s.execute("text", &json!({"at": [0, 0], "text": "NOTE", "height": 0.25})).unwrap();
|
||||
face(&mut s, 0.0);
|
||||
let r = shapes(&mut s, json!({}));
|
||||
// Add-only: the old keys and their meanings are unchanged.
|
||||
assert_eq!((r["schema"].clone(), r["count"].clone(), r["returned"].clone()), (json!(1), json!(1), json!(1)));
|
||||
for key in ["offset", "truncated", "shapes", "skipped"] {
|
||||
assert!(!r[key].is_null(), "{key}: {r}");
|
||||
}
|
||||
let sc = &r["selfCheck"];
|
||||
assert_eq!(sc["schema"], 1);
|
||||
assert_eq!(sc["found"], 4);
|
||||
assert_eq!(sc["analyzed"], 1);
|
||||
assert_eq!(sc["skipped"]["3D Face"], json!({ "count": 1, "reason": "not a closed shape" }));
|
||||
assert_eq!(sc["skipped"]["Line"], json!({ "count": 1, "reason": "not a closed shape" }));
|
||||
assert_eq!(sc["skipped"]["Text"], json!({ "count": 1, "reason": "not a closed shape" }));
|
||||
assert_eq!(sc["skipped"], r["skipped"], "the self-check repeats the skip map it is built from");
|
||||
assert_eq!(sc["caps"], json!({}));
|
||||
assert_eq!(sc["implausible"], json!([]));
|
||||
let analyzed = sc["analyzed"].as_u64().unwrap();
|
||||
let skipped: u64 = sc["skipped"].as_object().unwrap().values().map(|v| v["count"].as_u64().unwrap()).sum();
|
||||
assert_eq!(sc["found"].as_u64().unwrap(), analyzed + skipped);
|
||||
}
|
||||
|
||||
/// #1440: a cap that stopped the analysis is reported with its value — the page `limit` when it
|
||||
/// truncates, and `crossingSegments` when a shape has more edges than the self-intersection check
|
||||
/// covers (which then reports no self-intersection) — while an unreached cap stays out of the
|
||||
/// report.
|
||||
#[test]
|
||||
fn self_check_reports_the_caps_it_hits() {
|
||||
let mut s = Session::new();
|
||||
for _ in 0..3 {
|
||||
square(&mut s);
|
||||
}
|
||||
let r = shapes(&mut s, json!({ "limit": 1 }));
|
||||
assert_eq!(r["truncated"], true);
|
||||
assert_eq!(r["selfCheck"]["caps"], json!({ "limit": 1 }), "the page cap and its value: {r}");
|
||||
assert_eq!((r["selfCheck"]["found"].clone(), r["selfCheck"]["analyzed"].clone()), (json!(3), json!(3)));
|
||||
|
||||
let r = shapes(&mut s, json!({ "limit": 10 }));
|
||||
assert_eq!(r["selfCheck"]["caps"], json!({}), "a cap that was not reached is not reported");
|
||||
|
||||
// A closed polyline with more edges than the self-intersection cap can check.
|
||||
let zig: Vec<(f64, f64, f64)> = (0..=4_000).map(|i| (f64::from(i), 0.0, 0.0)).collect();
|
||||
pline(&mut s, &zig, true);
|
||||
let r = shapes(&mut s, json!({ "limit": 10 }));
|
||||
assert_eq!(r["selfCheck"]["caps"], json!({ "crossingSegments": 4_000 }), "{r}");
|
||||
assert_eq!(r["shapes"].as_array().unwrap().last().unwrap()["selfIntersection"], Value::Null);
|
||||
}
|
||||
|
||||
/// #1440: geometry that cannot be right is flagged with the bound it broke — a perimeter past 10×
|
||||
/// the shape's bounding-box diagonal, and an area larger than the drawing extents (invisible
|
||||
/// objects lie outside the extents, which is how the test makes an out-of-extents shape).
|
||||
#[test]
|
||||
fn self_check_flags_implausible_geometry() {
|
||||
let mut s = Session::new();
|
||||
square(&mut s); // visible extents: 10 x 10, area 100
|
||||
// A sawtooth in the same 10 x 10 box: perimeter about 410, past 10 x the 14.14 diagonal.
|
||||
let teeth: Vec<(f64, f64, f64)> = (0..=40).map(|i| (f64::from(i) * 0.25, if i % 2 == 0 { 0.0 } else { 10.0 }, 0.0)).collect();
|
||||
pline(&mut s, &teeth, true);
|
||||
// Invisible (so outside the drawing extents): 100 x 100, area 10,000.
|
||||
pline(&mut s, &[(0.0, 0.0, 0.0), (100.0, 0.0, 0.0), (100.0, 100.0, 0.0), (0.0, 100.0, 0.0)], true);
|
||||
hide_last(&mut s);
|
||||
let r = shapes(&mut s, json!({}));
|
||||
let flags = r["selfCheck"]["implausible"].as_array().unwrap();
|
||||
let perimeter = flags.iter().find(|f| f["field"] == "perimeter").unwrap_or_else(|| panic!("no perimeter flag: {r}"));
|
||||
assert!(perimeter["reason"].as_str().unwrap().contains("10"), "{perimeter}");
|
||||
assert_eq!(perimeter["type"], "Polyline");
|
||||
let area = flags.iter().find(|f| f["field"] == "area").unwrap_or_else(|| panic!("no area flag: {r}"));
|
||||
assert!(area["reason"].as_str().unwrap().contains("drawing extents"), "{area}");
|
||||
assert!(area["value"].as_f64().unwrap() > area["bound"].as_f64().unwrap());
|
||||
}
|
||||
|
||||
/// #1440 review: a border rectangle that fills the drawing extents has an area equal to them in
|
||||
/// exact arithmetic, so it is not implausible; a shape clearly larger than the extents still is.
|
||||
#[test]
|
||||
fn self_check_does_not_flag_an_extents_filling_rectangle() {
|
||||
let mut s = Session::new();
|
||||
square(&mut s); // the extents are its own 10 x 10 box, area 100.
|
||||
let r = shapes(&mut s, json!({}));
|
||||
assert_eq!(r["selfCheck"]["implausible"], json!([]), "an extents-filling rectangle is not a bug: {r}");
|
||||
|
||||
// The same rectangle a millionth larger, invisible so it lies outside the extents: clearly
|
||||
// past the tolerance, so it is still flagged.
|
||||
pline(&mut s, &[(0.0, 0.0, 0.0), (10.000001, 0.0, 0.0), (10.000001, 10.000001, 0.0), (0.0, 10.000001, 0.0)], true);
|
||||
hide_last(&mut s);
|
||||
let r = shapes(&mut s, json!({}));
|
||||
let flags = r["selfCheck"]["implausible"].as_array().unwrap();
|
||||
let area = flags.iter().find(|f| f["field"] == "area").unwrap_or_else(|| panic!("no area flag: {r}"));
|
||||
assert!(area["value"].as_f64().unwrap() > area["bound"].as_f64().unwrap() * (1.0 + 1e-9), "{area}");
|
||||
}
|
||||
|
||||
/// #1440 review: with no visible extents (the only shape is invisible) there is no drawing bound
|
||||
/// to compare an area against, so it is not flagged as larger than the extents.
|
||||
#[test]
|
||||
fn self_check_has_no_area_bound_without_visible_extents() {
|
||||
let mut s = Session::new();
|
||||
square(&mut s);
|
||||
hide_last(&mut s);
|
||||
assert!(s.doc().unwrap().extents(&Space::Model).is_empty());
|
||||
let r = shapes(&mut s, json!({}));
|
||||
assert_eq!(r["count"], 1);
|
||||
assert_eq!(r["selfCheck"]["implausible"], json!([]), "no extents means no area bound: {r}");
|
||||
}
|
||||
|
||||
@@ -128,3 +128,49 @@ fn hostile_polygon_and_fence_never_panic() {
|
||||
s.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_moves_only_the_multiline_vertices_inside_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mline", &json!({ "points": [[0, 0], [3000, 0], [3000, 2000]], "scale": 200 })).unwrap();
|
||||
let r = s.execute("stretch", &json!({ "polygon": [[2500, 1500], [3500, 1500], [3500, 2500], [2500, 2500]], "delta": [610, 0] })).unwrap();
|
||||
assert_eq!(r["stretched"], 1);
|
||||
let pts = |s: &Session| -> Vec<(f64, f64)> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| match &e.kind {
|
||||
EntityKind::MLine(m) => Some(m.vertices.iter().map(|v| (v.p.x, v.p.y)).collect()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(pts(&s), vec![(0.0, 0.0), (3000.0, 0.0), (3610.0, 2000.0)]);
|
||||
// A window over the first vertex moves that vertex only, not the whole multiline.
|
||||
s.execute("stretch", &json!({ "window": [[-100, -100], [100, 100]], "delta": [610, 0] })).unwrap();
|
||||
assert_eq!(pts(&s), vec![(610.0, 0.0), (3000.0, 0.0), (3610.0, 2000.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_area_around_a_multiline_vertex_moves_it_at_a_large_scale() {
|
||||
let mline_points = |s: &Session| -> Vec<(f64, f64)> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| match &e.kind {
|
||||
EntityKind::MLine(m) => Some(m.vertices.iter().map(|v| (v.p.x, v.p.y)).collect()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let mut s = Session::new();
|
||||
s.execute("mline", &json!({ "points": [[0, 0], [3000, 0], [3000, 2000]], "scale": 200 })).unwrap();
|
||||
// The element lines run 100 away from the middle vertex, so this area touches none of them.
|
||||
let r = s.execute("stretch", &json!({ "polygon": [[2995, -5], [3005, -5], [3005, 5], [2995, 5]], "delta": [0, 610] })).unwrap();
|
||||
assert_eq!(r["stretched"], 1);
|
||||
assert_eq!(mline_points(&s), vec![(0.0, 0.0), (3000.0, 610.0), (3000.0, 2000.0)]);
|
||||
s.execute("stretch", &json!({ "window": [[2995, 605], [3005, 615]], "delta": [0, -610] })).unwrap();
|
||||
assert_eq!(mline_points(&s), vec![(0.0, 0.0), (3000.0, 0.0), (3000.0, 2000.0)]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
//! LOOPS: implied closed loops from loose LINE and ARC chains (#1437).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::geom::{PolyVertex, Vec2, Vec3};
|
||||
use cadcraft_engine::doc::{Arc, Common, EntityKind, Line, LwPolyline, Space};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn handle(v: &Value) -> String {
|
||||
v["handle"].as_str().or_else(|| v["handles"][0].as_str()).expect("a handle").to_string()
|
||||
}
|
||||
|
||||
fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> String {
|
||||
handle(&s.execute("line", &json!({ "points": [[a.0, a.1], [b.0, b.1]] })).unwrap())
|
||||
}
|
||||
|
||||
fn arc(s: &mut Session, center: (f64, f64), radius: f64, start: f64, end: f64) -> String {
|
||||
handle(&s.execute("arc", &json!({ "center": [center.0, center.1], "radius": radius, "start": start, "end": end })).unwrap())
|
||||
}
|
||||
|
||||
/// Add an entity straight to the document, for shapes the commands refuse to draw.
|
||||
fn add(s: &mut Session, kind: EntityKind) -> String {
|
||||
s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex()
|
||||
}
|
||||
|
||||
fn rectangle(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> Vec<String> {
|
||||
vec![line(s, (x, y), (x + w, y)), line(s, (x + w, y), (x + w, y + h)), line(s, (x + w, y + h), (x, y + h)), line(s, (x, y + h), (x, y))]
|
||||
}
|
||||
|
||||
fn loops(s: &mut Session, p: Value) -> Value {
|
||||
s.execute("loops", &p).unwrap()
|
||||
}
|
||||
|
||||
fn num(v: &Value) -> f64 {
|
||||
v.as_f64().unwrap()
|
||||
}
|
||||
|
||||
fn near(v: &Value, want: f64) {
|
||||
assert!((num(v) - want).abs() < 1e-9, "{v} != {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_lines_make_a_closed_loop() {
|
||||
let mut s = Session::new();
|
||||
let hs = rectangle(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let revision = s.state().unwrap().revision;
|
||||
let r = loops(&mut s, json!({}));
|
||||
assert_eq!(r["schema"], 1);
|
||||
assert_eq!(r["capped"], false);
|
||||
assert_eq!((r["count"].clone(), r["returned"].clone(), r["offset"].clone()), (json!(1), json!(1), json!(0)));
|
||||
assert_eq!(r["truncated"], false);
|
||||
assert!(num(&r["tolerance"]) > 0.0 && num(&r["tolerance"]).is_finite(), "{r}");
|
||||
assert!(r["pairTests"].as_u64().unwrap() >= 28, "8 endpoints: {r}");
|
||||
let loop_ = &r["loops"][0];
|
||||
assert_eq!(loop_["members"], json!(hs));
|
||||
assert_eq!(loop_["winding"], "ccw");
|
||||
near(&loop_["area"], 100.0);
|
||||
near(&loop_["perimeter"], 40.0);
|
||||
assert_eq!(r["openChains"]["count"], 0, "{r}");
|
||||
assert_eq!(r["junctions"]["count"], 0, "{r}");
|
||||
// Read-only: no entity, no undo step, no revision.
|
||||
assert_eq!(s.state().unwrap().revision, revision);
|
||||
assert_eq!(*s.doc().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_facts_match_shapes() {
|
||||
let mut s = Session::new();
|
||||
rectangle(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let vertices: Vec<PolyVertex> =
|
||||
[(5.0, 0.0), (15.0, 0.0), (15.0, 10.0), (5.0, 10.0)].iter().map(|&(x, y)| PolyVertex::new(Vec2::new(x, y))).collect();
|
||||
s.doc_mut()
|
||||
.unwrap()
|
||||
.add(
|
||||
&Space::Model,
|
||||
Common::default(),
|
||||
EntityKind::LwPolyline(LwPolyline { vertices, closed: true, const_width: 0.0, elevation: 0.0, plinegen: false }),
|
||||
)
|
||||
.unwrap();
|
||||
let shapes = s.execute("shapes", &json!({})).unwrap();
|
||||
let loops = loops(&mut s, json!({}));
|
||||
let (a, b) = (&loops["loops"][0], &shapes["shapes"][0]);
|
||||
for k in ["perimeter", "area", "winding"] {
|
||||
assert_eq!(a[k], b[k], "{k}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corner_off_by_a_gap_is_an_open_chain() {
|
||||
let mut s = Session::new();
|
||||
let hs = vec![
|
||||
line(&mut s, (0.0, 0.0), (10.0, 0.0)),
|
||||
line(&mut s, (10.0, 0.0), (10.0, 10.0)),
|
||||
line(&mut s, (10.0, 10.0), (0.0, 10.0)),
|
||||
line(&mut s, (0.0, 10.0), (0.0, 0.5)),
|
||||
];
|
||||
let r = loops(&mut s, json!({}));
|
||||
assert_eq!(r["count"], 0, "{r}");
|
||||
assert_eq!(r["openChains"]["count"], 1, "{r}");
|
||||
let c = &r["openChains"]["chains"][0];
|
||||
assert_eq!(c["members"], json!(hs));
|
||||
near(&c["gap"], 0.5);
|
||||
near(&c["at"][0], 0.0);
|
||||
near(&c["at"][1], 0.25);
|
||||
assert_eq!(c["ends"], json!([[0.0, 0.0], [0.0, 0.5]]));
|
||||
near(&c["length"], 39.5);
|
||||
// A tolerance that spans the gap closes the loop; one that does not leaves it open.
|
||||
let r = loops(&mut s, json!({ "tolerance": 0.4 }));
|
||||
assert_eq!((r["count"].clone(), r["openChains"]["count"].clone()), (json!(0), json!(1)), "{r}");
|
||||
let r = loops(&mut s, json!({ "tolerance": 0.6 }));
|
||||
assert_eq!((r["count"].clone(), r["openChains"]["count"].clone()), (json!(1), json!(0)), "{r}");
|
||||
near(&r["loops"][0]["area"], 100.0);
|
||||
near(&r["loops"][0]["perimeter"], 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slot_of_lines_and_arcs() {
|
||||
let mut s = Session::new();
|
||||
let hs = vec![
|
||||
line(&mut s, (0.0, -1.0), (10.0, -1.0)),
|
||||
arc(&mut s, (10.0, 0.0), 1.0, -90.0, 90.0),
|
||||
line(&mut s, (10.0, 1.0), (0.0, 1.0)),
|
||||
arc(&mut s, (0.0, 0.0), 1.0, 90.0, 270.0),
|
||||
];
|
||||
let r = loops(&mut s, json!({}));
|
||||
assert_eq!(r["count"], 1, "{r}");
|
||||
let loop_ = &r["loops"][0];
|
||||
assert_eq!(loop_["members"], json!(hs));
|
||||
assert_eq!(loop_["winding"], "ccw");
|
||||
let pi = std::f64::consts::PI;
|
||||
near(&loop_["area"], 20.0 + pi);
|
||||
near(&loop_["perimeter"], 20.0 + 2.0 * pi);
|
||||
assert_eq!(r["openChains"]["count"], 0, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clockwise_chain_reports_cw() {
|
||||
let mut s = Session::new();
|
||||
// The same rectangle drawn the other way round.
|
||||
let hs = vec![
|
||||
line(&mut s, (0.0, 0.0), (0.0, 10.0)),
|
||||
line(&mut s, (0.0, 10.0), (10.0, 10.0)),
|
||||
line(&mut s, (10.0, 10.0), (10.0, 0.0)),
|
||||
line(&mut s, (10.0, 0.0), (0.0, 0.0)),
|
||||
];
|
||||
let r = loops(&mut s, json!({}));
|
||||
assert_eq!(r["count"], 1, "{r}");
|
||||
assert_eq!(r["loops"][0]["members"], json!(hs));
|
||||
assert_eq!(r["loops"][0]["winding"], "cw");
|
||||
near(&r["loops"][0]["area"], 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_t_junction_is_reported_not_resolved() {
|
||||
let mut s = Session::new();
|
||||
let hs = [line(&mut s, (0.0, 0.0), (10.0, 0.0)), line(&mut s, (0.0, 0.0), (0.0, 10.0)), line(&mut s, (0.0, 0.0), (-10.0, 0.0))];
|
||||
// A far-away rectangle still closes: the junction does not poison the whole report.
|
||||
let rect = rectangle(&mut s, 100.0, 0.0, 10.0, 10.0);
|
||||
let r = loops(&mut s, json!({}));
|
||||
assert_eq!(r["count"], 1, "{r}");
|
||||
assert_eq!(r["loops"][0]["members"], json!(rect));
|
||||
assert_eq!(r["junctions"]["count"], 1, "{r}");
|
||||
let j = &r["junctions"]["junctions"][0];
|
||||
assert_eq!(j["ends"], 3, "{r}");
|
||||
near(&j["at"][0], 0.0);
|
||||
near(&j["at"][1], 0.0);
|
||||
let mut members: Vec<&str> = j["members"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
|
||||
members.sort_unstable();
|
||||
let mut want: Vec<&str> = hs.iter().map(String::as_str).collect();
|
||||
want.sort_unstable();
|
||||
assert_eq!(members, want);
|
||||
// Nothing around the junction is resolved into a chain either.
|
||||
let open_members: Vec<&Value> = r["openChains"]["chains"].as_array().unwrap().iter().flat_map(|c| c["members"].as_array().unwrap()).collect();
|
||||
assert!(open_members.iter().all(|m| !hs.iter().any(|h| h == m.as_str().unwrap())), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paging_caps_loops_open_chains_and_junctions() {
|
||||
let mut s = Session::new();
|
||||
for i in 0..3 {
|
||||
rectangle(&mut s, i as f64 * 100.0, 0.0, 10.0, 10.0);
|
||||
}
|
||||
let r = loops(&mut s, json!({ "limit": 1 }));
|
||||
assert_eq!((r["count"].clone(), r["returned"].clone(), r["truncated"].clone()), (json!(3), json!(1), json!(true)), "{r}");
|
||||
let r = loops(&mut s, json!({ "limit": 1, "offset": 1 }));
|
||||
assert_eq!((r["returned"].clone(), r["offset"].clone(), r["truncated"].clone()), (json!(1), json!(1), json!(true)), "{r}");
|
||||
let r = loops(&mut s, json!({ "offset": 2 }));
|
||||
assert_eq!((r["returned"].clone(), r["truncated"].clone()), (json!(1), json!(false)), "{r}");
|
||||
|
||||
let mut s = Session::new();
|
||||
for i in 0..3 {
|
||||
line(&mut s, (i as f64 * 100.0, 0.0), (i as f64 * 100.0 + 1.0, 0.0));
|
||||
}
|
||||
let r = loops(&mut s, json!({ "limit": 2 }));
|
||||
assert_eq!(
|
||||
(r["openChains"]["count"].clone(), r["openChains"]["chains"].as_array().map(Vec::len).unwrap(), r["openChains"]["truncated"].clone()),
|
||||
(json!(3), 2, json!(true)),
|
||||
"{r}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pair_cap_is_reported() {
|
||||
// The default cap with more endpoints than it can compare.
|
||||
let mut s = Session::new();
|
||||
for i in 0..800 {
|
||||
let x = i as f64 * 10.0;
|
||||
add(&mut s, EntityKind::Line(Line { a: Vec3::new(x, 0.0, 0.0), b: Vec3::new(x + 1.0, 0.0, 0.0) }));
|
||||
}
|
||||
let r = loops(&mut s, json!({}));
|
||||
assert_eq!(r["capped"], true, "{r}");
|
||||
assert_eq!(r["count"], 0, "{r}");
|
||||
assert_eq!(r["pairTests"], 0, "{r}");
|
||||
assert_eq!(r["pairLimit"].as_u64().unwrap(), 1_000_000, "{r}");
|
||||
assert!(r["pairTestsNeeded"].as_u64().unwrap() > 1_000_000, "{r}");
|
||||
|
||||
// An explicit, smaller cap with a rectangle's 28 pairs.
|
||||
let mut s = Session::new();
|
||||
rectangle(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let r = loops(&mut s, json!({ "maxPairs": 10 }));
|
||||
assert_eq!(r["capped"], true, "{r}");
|
||||
assert_eq!((r["pairTests"].clone(), r["pairLimit"].clone(), r["pairTestsNeeded"].clone()), (json!(0), json!(10), json!(28)), "{r}");
|
||||
assert_eq!(r["loops"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_entities_and_bad_options_never_panic() {
|
||||
let mut s = Session::new();
|
||||
add(&mut s, EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::ZERO }));
|
||||
add(&mut s, EntityKind::Line(Line { a: Vec3::new(f64::NAN, 0.0, 0.0), b: Vec3::new(1.0, 0.0, 0.0) }));
|
||||
add(&mut s, EntityKind::Arc(Arc { center: Vec3::ZERO, radius: 0.0, start: 0.0, end: 1.0 }));
|
||||
add(&mut s, EntityKind::Arc(Arc { center: Vec3::new(0.0, f64::NAN, 0.0), radius: 1.0, start: 0.0, end: 1.0 }));
|
||||
let r = loops(&mut s, json!({}));
|
||||
assert_eq!(r["count"], 0, "{r}");
|
||||
assert_eq!(r["skipped"]["Line"]["count"], 2, "{r}");
|
||||
assert_eq!(r["skipped"]["Arc"]["count"], 2, "{r}");
|
||||
// A full-circle ARC closes on its own.
|
||||
let h = add(&mut s, EntityKind::Arc(Arc { center: Vec3::new(50.0, 0.0, 0.0), radius: 2.0, start: 0.0, end: 0.0 }));
|
||||
let r = loops(&mut s, json!({}));
|
||||
assert_eq!(r["count"], 1, "{r}");
|
||||
assert_eq!(r["loops"][0]["members"], json!([h]));
|
||||
near(&r["loops"][0]["area"], 4.0 * std::f64::consts::PI);
|
||||
near(&r["loops"][0]["perimeter"], 4.0 * std::f64::consts::PI);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_endpoints_never_panic() {
|
||||
let mut s = Session::new();
|
||||
line(&mut s, (0.0, 0.0), (10.0, 0.0));
|
||||
line(&mut s, (0.0, 0.0), (10.0, 0.0));
|
||||
let r = loops(&mut s, json!({}));
|
||||
// Two coincident lines join into a zero-area loop; what matters is that it is reported, not
|
||||
// that it panics or modifies the drawing.
|
||||
assert_eq!(r["junctions"]["count"], 0, "{r}");
|
||||
assert_eq!(r["count"], 1, "{r}");
|
||||
near(&r["loops"][0]["area"], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_whose_own_ends_meet_within_a_large_tolerance_is_still_open() {
|
||||
let mut s = Session::new();
|
||||
let h = line(&mut s, (0.0, 0.0), (0.1, 0.0));
|
||||
let r = loops(&mut s, json!({ "tolerance": 1.0 }));
|
||||
assert_eq!(r["count"], 0, "{r}");
|
||||
assert_eq!(r["openChains"]["count"], 1, "{r}");
|
||||
assert_eq!(r["openChains"]["chains"][0]["members"], json!([h]));
|
||||
near(&r["openChains"]["chains"][0]["gap"], 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_options_are_errors() {
|
||||
let mut s = Session::new();
|
||||
for p in [
|
||||
json!({ "tolerance": -1 }),
|
||||
json!({ "tolerance": 0 }),
|
||||
json!({ "tolerance": "x" }),
|
||||
json!({ "maxPairs": "x" }),
|
||||
json!({ "maxPairs": -1 }),
|
||||
json!({ "limit": "x" }),
|
||||
json!({ "offset": "x" }),
|
||||
] {
|
||||
assert!(s.execute("loops", &p).is_err(), "{p}");
|
||||
}
|
||||
// Empty drawing: a well-formed empty report.
|
||||
let r = loops(&mut s, json!({}));
|
||||
assert_eq!((r["count"].clone(), r["returned"].clone(), r["capped"].clone()), (json!(0), json!(0), json!(false)), "{r}");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! MEASUREINIT: the units of a new drawing made without a template (issue #1182). It is a profile
|
||||
//! setting, so a metric choice survives restarts; an explicit `metric` or template still wins.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn var(s: &mut Session, name: &str) -> serde_json::Value {
|
||||
s.execute("getvar", &json!({ "name": name })).unwrap()["value"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imperial_by_default() {
|
||||
let mut s = Session::empty();
|
||||
assert_eq!(var(&mut s, "MEASUREINIT"), json!(0));
|
||||
s.execute("new", &json!({})).unwrap();
|
||||
assert_eq!(var(&mut s, "INSUNITS"), json!(1));
|
||||
assert_eq!(var(&mut s, "MEASUREMENT"), json!(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_follows_measureinit() {
|
||||
let mut s = Session::empty();
|
||||
s.execute("options", &json!({ "MEASUREINIT": 1 })).unwrap();
|
||||
s.execute("new", &json!({})).unwrap();
|
||||
assert_eq!(var(&mut s, "INSUNITS"), json!(4));
|
||||
assert_eq!(var(&mut s, "MEASUREMENT"), json!(1));
|
||||
assert_eq!(var(&mut s, "DIMSTYLE"), json!("ISO-25"));
|
||||
// An explicit choice still wins.
|
||||
s.execute("new", &json!({ "metric": false })).unwrap();
|
||||
assert_eq!(var(&mut s, "INSUNITS"), json!(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_zero_or_one() {
|
||||
let mut s = Session::empty();
|
||||
for bad in [json!(2), json!(-1), json!("metric")] {
|
||||
assert!(s.execute("options", &json!({ "MEASUREINIT": bad })).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(var(&mut s, "MEASUREINIT"), json!(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_in_the_profile() {
|
||||
let mut s = Session::empty();
|
||||
s.execute("options", &json!({ "MEASUREINIT": 1 })).unwrap();
|
||||
let saved = cadcraft_engine::cmd::options::profile(&s);
|
||||
assert_eq!(saved["MEASUREINIT"], json!(1));
|
||||
let mut restarted = Session::empty();
|
||||
cadcraft_engine::cmd::options::restore(&mut restarted, &saved);
|
||||
assert_eq!(var(&mut restarted, "MEASUREINIT"), json!(1));
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! OFFSET uses a single object selected before the command (issue #1610): drawing a rectangle,
|
||||
//! selecting it and starting OFFSET used to ask for the object again. Now the preselected object
|
||||
//! goes straight to the side prompt; a multiple selection still asks which object to offset.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle};
|
||||
use serde_json::json;
|
||||
|
||||
/// Every line as ((ax, ay), (bx, by)), sorted by the first y.
|
||||
fn lines(s: &Session) -> Vec<((f64, f64), (f64, f64))> {
|
||||
let mut v: Vec<_> = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(((l.a.x, l.a.y), (l.b.x, l.b.y))) } else { None })
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.0.1.total_cmp(&b.0.1).then(a.0.0.total_cmp(&b.0.0)));
|
||||
v
|
||||
}
|
||||
|
||||
/// A session with two horizontal lines at y=0 and y=5 and their handles in drawing order.
|
||||
fn two_lines() -> (Session, Vec<Handle>) {
|
||||
let mut s = Session::new();
|
||||
s.script("LINE 0,0 10,0\n\nLINE 0,5 10,5\n\n").unwrap();
|
||||
let hs: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.handle).collect();
|
||||
(s, hs)
|
||||
}
|
||||
|
||||
/// The message of the current prompt, or an empty string when no command is running.
|
||||
fn prompt(s: &Session) -> String {
|
||||
s.current_prompt().map(|p| p.message.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Guards #1610: one preselected line is remembered on start, so after the distance only the side
|
||||
/// point is asked for and the offset copy lands on that side; the object loop still runs afterwards.
|
||||
#[test]
|
||||
fn one_preselected_object_goes_straight_to_the_side_prompt() {
|
||||
let (mut s, hs) = two_lines();
|
||||
s.set_selection(vec![hs[0]]);
|
||||
s.cmdline("OFFSET").unwrap();
|
||||
// The distance still comes first (as without a selection)...
|
||||
assert!(prompt(&s).contains("Specify offset distance"), "{}", s.prompt_text());
|
||||
s.cmdline("2").unwrap();
|
||||
// ...then the preselected line is taken and only the side is asked for.
|
||||
assert!(prompt(&s).contains("Specify point on side to offset"), "{}", s.prompt_text());
|
||||
s.cmdline("5,1").unwrap();
|
||||
assert_eq!(lines(&s), vec![((0.0, 0.0), (10.0, 0.0)), ((0.0, 2.0), (10.0, 2.0)), ((0.0, 5.0), (10.0, 5.0))]);
|
||||
// The command keeps its object loop for further objects.
|
||||
assert!(prompt(&s).contains("Select object to offset"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
/// Guards that a multiple preselection is not silently reduced: OFFSET still asks which one object
|
||||
/// to offset, then offsets only the one picked, as AutoCAD does.
|
||||
#[test]
|
||||
fn two_preselected_objects_still_ask_to_select_one() {
|
||||
let (mut s, hs) = two_lines();
|
||||
s.set_selection(hs);
|
||||
s.cmdline("OFFSET").unwrap();
|
||||
s.cmdline("2").unwrap();
|
||||
assert!(prompt(&s).contains("Select object to offset"), "{}", s.prompt_text());
|
||||
s.cmdline("5,0").unwrap();
|
||||
assert!(prompt(&s).contains("Specify point on side to offset"), "{}", s.prompt_text());
|
||||
s.cmdline("5,2").unwrap();
|
||||
assert_eq!(lines(&s), vec![((0.0, 0.0), (10.0, 0.0)), ((0.0, 2.0), (10.0, 2.0)), ((0.0, 5.0), (10.0, 5.0))]);
|
||||
assert!(prompt(&s).contains("Select object to offset"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
/// Guards that a preselected object OFFSET cannot offset (here a text entity) is cleared, so the
|
||||
/// command falls back to asking for the object instead of jumping to the side prompt.
|
||||
#[test]
|
||||
fn one_preselected_non_offsettable_object_still_asks_to_select() {
|
||||
let mut s = Session::new();
|
||||
s.execute("text", &json!({ "at": [0, 0], "text": "NOTE", "height": 2 })).unwrap();
|
||||
let h = s.doc().unwrap().model.iter().next().unwrap().handle;
|
||||
s.set_selection(vec![h]);
|
||||
s.cmdline("OFFSET").unwrap();
|
||||
s.cmdline("2").unwrap();
|
||||
assert!(prompt(&s).contains("Select object to offset"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! PURGE keeps arrowhead blocks a dimension style or a dimension's overrides name (a file's own
|
||||
//! `_Dot`, a user's arrow), and still removes blocks nothing references.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Block, Circle, DimStyle, Entity, EntityKind, Handle};
|
||||
use cadcraft_engine::geom::Vec3;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn purge_keeps_arrowhead_blocks_dimension_styles_name() {
|
||||
let mut s = Session::new();
|
||||
let d = s.doc_mut().unwrap();
|
||||
for (i, name) in ["_Dot", "MyArrow", "Unused"].into_iter().enumerate() {
|
||||
let mut b = Block::new(name);
|
||||
b.entities.push(Entity::new(Handle(0x9000 + i as u64), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 0.5 })));
|
||||
d.blocks.insert(name.into(), Arc::new(b));
|
||||
}
|
||||
d.dim_styles.push(DimStyle { name: "Dots".into(), arrow_block1: " _DOT ".into(), leader_arrow_block: "MyArrow".into(), ..DimStyle::default() });
|
||||
let r = s.execute("purge", &json!({ "type": "blocks" })).unwrap();
|
||||
assert_eq!(r["purged"]["blocks"], json!(["Unused"]), "{r}");
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.block("_Dot").is_some() && d.block("MyArrow").is_some());
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use cadcraft_engine::units::parse_angle;
|
||||
|
||||
#[test]
|
||||
fn bearing_rejects_out_of_range_angles() {
|
||||
for s in ["n100e", "n-45e", "n91e", "s-1w", "n-0.5e"] {
|
||||
assert_eq!(parse_angle(s), None, "{s}");
|
||||
}
|
||||
for s in ["n0e", "n90e", "s45w"] {
|
||||
assert!(parse_angle(s).is_some(), "{s}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! USAGE: a read-only cross-reference of the layers, blocks, text styles and linetypes a drawing
|
||||
//! uses (#1439), built on PURGE's usage scan.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, Linetype, Text, TextStyle};
|
||||
use cadcraft_engine::geom::Vec3;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A LINE on `layer` with an explicit `linetype`, ten units long so it is never degenerate.
|
||||
fn line(handle: u64, layer: &str, linetype: &str) -> Entity {
|
||||
let mut e = Entity::new(Handle(handle), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 0.0, 0.0) }));
|
||||
e.common = Common { layer: layer.into(), linetype: linetype.into(), ..Common::default() };
|
||||
e
|
||||
}
|
||||
|
||||
/// A TEXT reading NOTE in `style` on `layer`, so the text-style table has a visible user.
|
||||
fn text(handle: u64, layer: &str, style: &str) -> Entity {
|
||||
let mut e = Entity::new(
|
||||
Handle(handle),
|
||||
EntityKind::Text(Text {
|
||||
insert: Vec3::ZERO,
|
||||
align_pt: None,
|
||||
height: 1.0,
|
||||
value: "NOTE".into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: style.into(),
|
||||
halign: Default::default(),
|
||||
valign: Default::default(),
|
||||
}),
|
||||
);
|
||||
e.common = Common { layer: layer.into(), ..Common::default() };
|
||||
e
|
||||
}
|
||||
|
||||
/// An INSERT of `block` on `layer`, at unit scale and without attributes.
|
||||
fn insert(handle: u64, block: &str, layer: &str) -> Entity {
|
||||
let mut e = Entity::new(
|
||||
Handle(handle),
|
||||
EntityKind::Insert(Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
}),
|
||||
);
|
||||
e.common = Common { layer: layer.into(), ..Common::default() };
|
||||
e
|
||||
}
|
||||
|
||||
/// A block definition named `name` holding `entities`, shared the way the drawing's block table
|
||||
/// stores it.
|
||||
fn block(name: &str, entities: Vec<Entity>) -> Arc<Block> {
|
||||
let mut b = Block::new(name);
|
||||
for e in entities {
|
||||
b.entities.push(e);
|
||||
}
|
||||
Arc::new(b)
|
||||
}
|
||||
|
||||
/// Layers 0, Defpoints, Used, Empty and InBlock; linetypes Continuous, DASHED and SPARE; text
|
||||
/// styles Standard, InBlockStyle and UnusedStyle; blocks WALLS (inserted) holding a line on
|
||||
/// InBlock and a text in InBlockStyle and an insert of INNER, INNER (used only inside WALLS) and
|
||||
/// the unreferenced OLD. Model space: a DASHED line and a text on Used, and WALLS.
|
||||
fn drawing() -> Session {
|
||||
let mut s = Session::new();
|
||||
let d = s.doc_mut().unwrap();
|
||||
d.layers.push(Layer::new("Used"));
|
||||
d.layers.push(Layer::new("Empty"));
|
||||
d.layers.push(Layer::new("InBlock"));
|
||||
for (name, dashes) in [("DASHED", [0.5, 0.25]), ("SPARE", [0.1, 0.1])] {
|
||||
d.linetypes.push(Linetype::simple(name, "", &dashes));
|
||||
}
|
||||
d.text_styles.push(TextStyle { name: "InBlockStyle".into(), ..TextStyle::default() });
|
||||
d.text_styles.push(TextStyle { name: "UnusedStyle".into(), ..TextStyle::default() });
|
||||
d.blocks.insert(
|
||||
"WALLS".into(),
|
||||
block("WALLS", vec![line(0x3000, "InBlock", "ByLayer"), text(0x3001, "InBlock", "InBlockStyle"), insert(0x3002, "INNER", "0")]),
|
||||
);
|
||||
d.blocks.insert("INNER".into(), block("INNER", vec![line(0x3010, "0", "ByLayer")]));
|
||||
d.blocks.insert("OLD".into(), block("OLD", vec![line(0x3020, "0", "ByLayer")]));
|
||||
d.model.push(line(0x3100, "Used", "DASHED"));
|
||||
d.model.push(text(0x3101, "Used", "Standard"));
|
||||
d.model.push(insert(0x3102, "WALLS", "0"));
|
||||
s
|
||||
}
|
||||
|
||||
/// Runs the `usage` command with `p` and unwraps the result, so a rejected call fails the test.
|
||||
fn usage(s: &mut Session, p: Value) -> Value {
|
||||
s.execute("usage", &p).unwrap()
|
||||
}
|
||||
|
||||
/// The report item of kind `ty` named `name`, failing with the whole item list when it is absent.
|
||||
fn item<'a>(r: &'a Value, ty: &str, name: &str) -> &'a Value {
|
||||
r["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["type"] == ty && i["name"] == name)
|
||||
.unwrap_or_else(|| panic!("no {ty} \"{name}\" in {}", r["items"]))
|
||||
}
|
||||
|
||||
/// The `unused` names of kind `kind` as plain strings, in the order USAGE returned them.
|
||||
fn names(r: &Value, kind: &str) -> Vec<String> {
|
||||
r["unused"][kind]["names"].as_array().unwrap().iter().map(|n| n.as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
/// Guards that each layer separates uses in model space and the layouts from uses inside block
|
||||
/// definitions, that Defpoints is empty yet kept by PURGE, and that the current layer is never
|
||||
/// offered as unused.
|
||||
#[test]
|
||||
fn layers_report_uses_and_where() {
|
||||
let mut s = drawing();
|
||||
let r = usage(&mut s, json!({}));
|
||||
assert_eq!(r["schema"], 1);
|
||||
|
||||
// A line and a text in model space.
|
||||
let used = item(&r, "layer", "Used");
|
||||
assert_eq!((used["entities"].clone(), used["blockEntities"].clone()), (json!(2), json!(0)));
|
||||
assert_eq!((used["used"].clone(), used["insideBlocksOnly"].clone()), (json!(true), json!(false)));
|
||||
assert_eq!((used["empty"].clone(), used["unused"].clone()), (json!(false), json!(false)));
|
||||
assert_eq!(used["current"], false);
|
||||
|
||||
// Only a line inside the inserted block WALLS.
|
||||
let inblock = item(&r, "layer", "InBlock");
|
||||
assert_eq!((inblock["entities"].clone(), inblock["blockEntities"].clone()), (json!(0), json!(2)));
|
||||
assert_eq!((inblock["used"].clone(), inblock["insideBlocksOnly"].clone()), (json!(true), json!(true)));
|
||||
|
||||
// Empty and purgeable.
|
||||
let empty = item(&r, "layer", "Empty");
|
||||
assert_eq!((empty["used"].clone(), empty["empty"].clone()), (json!(false), json!(true)));
|
||||
assert_eq!(empty["unused"], true);
|
||||
|
||||
// Defpoints is empty but PURGE keeps it.
|
||||
let defpoints = item(&r, "layer", "Defpoints");
|
||||
assert_eq!((defpoints["empty"].clone(), defpoints["unused"].clone()), (json!(true), json!(false)));
|
||||
|
||||
// The current layer is not offered as unused even with nothing on it.
|
||||
let current = &r["items"].as_array().unwrap()[0];
|
||||
assert_eq!(current["name"], "0");
|
||||
assert_eq!((current["used"].clone(), current["unused"].clone(), current["current"].clone()), (json!(true), json!(false), json!(true)));
|
||||
assert_eq!(names(&r, "layers"), ["Empty"]);
|
||||
}
|
||||
|
||||
/// Guards that a block's `used` follows reachability from model space — INNER, referenced only
|
||||
/// by the inserted WALLS, is used but inside blocks only — and that an unreferenced definition
|
||||
/// such as OLD is empty and purgeable.
|
||||
#[test]
|
||||
fn blocks_report_references_and_reachability() {
|
||||
let mut s = drawing();
|
||||
let r = usage(&mut s, json!({}));
|
||||
|
||||
let walls = item(&r, "block", "WALLS");
|
||||
assert_eq!((walls["entities"].clone(), walls["blockEntities"].clone()), (json!(1), json!(0)));
|
||||
assert_eq!((walls["used"].clone(), walls["insideBlocksOnly"].clone()), (json!(true), json!(false)));
|
||||
|
||||
// Referenced by WALLS only, which model space inserts.
|
||||
let inner = item(&r, "block", "INNER");
|
||||
assert_eq!((inner["entities"].clone(), inner["blockEntities"].clone()), (json!(0), json!(1)));
|
||||
assert_eq!((inner["used"].clone(), inner["insideBlocksOnly"].clone()), (json!(true), json!(true)));
|
||||
|
||||
let old = item(&r, "block", "OLD");
|
||||
assert_eq!((old["used"].clone(), old["empty"].clone(), old["unused"].clone()), (json!(false), json!(true), json!(true)));
|
||||
assert_eq!(names(&r, "blocks"), ["OLD"]);
|
||||
assert_eq!(r["summary"]["blocks"]["unused"], 1);
|
||||
}
|
||||
|
||||
/// Guards that text styles count the entities that use them and the dimension/multileader styles
|
||||
/// that name them, that ByLayer and ByBlock are values rather than uses of the linetype table,
|
||||
/// and that a layer's linetype counts as a reference to it.
|
||||
#[test]
|
||||
fn styles_and_linetypes_report_uses() {
|
||||
let mut s = drawing();
|
||||
let r = usage(&mut s, json!({}));
|
||||
|
||||
let standard = item(&r, "textStyle", "Standard");
|
||||
assert_eq!((standard["entities"].clone(), standard["blockEntities"].clone()), (json!(1), json!(0)));
|
||||
assert_eq!((standard["used"].clone(), standard["unused"].clone()), (json!(true), json!(false)));
|
||||
|
||||
let inblock = item(&r, "textStyle", "InBlockStyle");
|
||||
assert_eq!((inblock["entities"].clone(), inblock["blockEntities"].clone()), (json!(0), json!(1)));
|
||||
assert_eq!((inblock["used"].clone(), inblock["insideBlocksOnly"].clone()), (json!(true), json!(true)));
|
||||
|
||||
let unused = item(&r, "textStyle", "UnusedStyle");
|
||||
assert_eq!((unused["used"].clone(), unused["empty"].clone(), unused["unused"].clone()), (json!(false), json!(true), json!(true)));
|
||||
assert_eq!(names(&r, "textStyles"), ["UnusedStyle"]);
|
||||
|
||||
// An entity's explicit linetype.
|
||||
let dashed = item(&r, "linetype", "DASHED");
|
||||
assert_eq!((dashed["entities"].clone(), dashed["blockEntities"].clone()), (json!(1), json!(0)));
|
||||
assert_eq!((dashed["used"].clone(), dashed["unused"].clone()), (json!(true), json!(false)));
|
||||
|
||||
// ByLayer and ByBlock are values, not uses of the linetype table's entries.
|
||||
for special in ["ByLayer", "ByBlock"] {
|
||||
let it = item(&r, "linetype", special);
|
||||
assert_eq!((it["entities"].clone(), it["blockEntities"].clone()), (json!(0), json!(0)), "{special}");
|
||||
assert_eq!(it["unused"], false, "{special}");
|
||||
}
|
||||
|
||||
let spare = item(&r, "linetype", "SPARE");
|
||||
assert_eq!((spare["used"].clone(), spare["unused"].clone()), (json!(false), json!(true)));
|
||||
assert_eq!(names(&r, "linetypes"), ["SPARE"]);
|
||||
|
||||
// A layer's linetype is a reference to it; every layer here is Continuous.
|
||||
let continuous = item(&r, "linetype", "Continuous");
|
||||
assert_eq!((continuous["entities"].clone(), continuous["references"].clone()), (json!(0), json!(5)));
|
||||
assert_eq!((continuous["used"].clone(), continuous["unused"].clone()), (json!(true), json!(false)));
|
||||
}
|
||||
|
||||
/// Guards the promise that USAGE is read-only: the drawing is not copied and its uid, revision,
|
||||
/// dirty flag, undo stack and tables are exactly as they were.
|
||||
#[test]
|
||||
fn nothing_is_modified() {
|
||||
let mut s = drawing();
|
||||
let before = s.state().unwrap().doc.clone();
|
||||
let (uid, revision) = (s.state().unwrap().uid, s.state().unwrap().revision);
|
||||
let dirty = s.state().unwrap().is_dirty();
|
||||
let tables = |d: &Drawing| {
|
||||
(
|
||||
d.layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
|
||||
d.linetypes.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
|
||||
d.text_styles.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
|
||||
d.blocks.keys().cloned().collect::<Vec<_>>(),
|
||||
d.entity_count(),
|
||||
)
|
||||
};
|
||||
let before_tables = tables(&before);
|
||||
|
||||
let _ = usage(&mut s, json!({}));
|
||||
let st = s.state().unwrap();
|
||||
assert!(Arc::ptr_eq(&before, &st.doc), "USAGE copied the drawing");
|
||||
assert_eq!((st.uid, st.revision), (uid, revision));
|
||||
assert_eq!(tables(&st.doc), before_tables);
|
||||
assert!(st.undo.is_empty(), "USAGE pushed an undo step");
|
||||
assert_eq!(st.is_dirty(), dirty);
|
||||
}
|
||||
|
||||
/// Guards the paging envelope (`count`, `returned`, `offset`, `truncated`), the `type` filter and
|
||||
/// that a bad `type`, `limit` or `offset` is reported as an error rather than panicking.
|
||||
#[test]
|
||||
fn paging_and_filters() {
|
||||
let mut s = drawing();
|
||||
let all = usage(&mut s, json!({}));
|
||||
let total = all["count"].as_u64().unwrap();
|
||||
assert_eq!(total as usize, all["items"].as_array().unwrap().len());
|
||||
assert_eq!((all["returned"].clone(), all["truncated"].clone()), (json!(total), json!(false)));
|
||||
assert_eq!(all["offset"], 0);
|
||||
|
||||
let page = usage(&mut s, json!({ "limit": 2 }));
|
||||
assert_eq!((page["count"].clone(), page["returned"].clone(), page["truncated"].clone()), (json!(total), json!(2), json!(true)));
|
||||
let offset = usage(&mut s, json!({ "offset": 2, "limit": 2 }));
|
||||
assert_eq!((offset["returned"].clone(), offset["truncated"].clone()), (json!(2), json!(total > 4)));
|
||||
|
||||
let layers = usage(&mut s, json!({ "type": "layers" }));
|
||||
assert_eq!(layers["count"], 5);
|
||||
assert!(layers["items"].as_array().unwrap().iter().all(|i| i["type"] == "layer"));
|
||||
let styles = usage(&mut s, json!({ "type": "textstyles" }));
|
||||
assert_eq!(styles["count"], 3);
|
||||
let none = usage(&mut s, json!({ "type": "blocks", "offset": 100 }));
|
||||
assert_eq!((none["returned"].clone(), none["truncated"].clone()), (json!(0), json!(false)));
|
||||
|
||||
assert!(s.execute("usage", &json!({ "type": "nope" })).is_err());
|
||||
assert!(s.execute("usage", &json!({ "limit": "x" })).is_err());
|
||||
assert!(s.execute("usage", &json!({ "offset": -1 })).is_err());
|
||||
}
|
||||
|
||||
/// Guards that a drawing within the scan limit reports itself complete: `incomplete` is a real
|
||||
/// signal, false here and with no reason, so callers can tell complete counts from partial ones.
|
||||
#[test]
|
||||
fn a_complete_scan_is_marked_complete() {
|
||||
let mut s = drawing();
|
||||
let r = usage(&mut s, json!({}));
|
||||
assert_eq!(r["incomplete"], false);
|
||||
assert!(r.get("incompleteReason").is_none(), "a complete scan has no reason: {r}");
|
||||
assert!(!r["message"].as_str().unwrap().contains("incomplete"), "{}", r["message"]);
|
||||
}
|
||||
@@ -434,6 +434,88 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The curves with each open end that stops within `snap` of another curve moved onto that
|
||||
/// curve (which gains a vertex there), so the two meet exactly. A curve that is flattened
|
||||
/// into chords (an ellipse, a spline) lies up to the flattening tolerance off the true curve,
|
||||
/// so a line that ends on the true curve can stop just short of the chords.
|
||||
pub fn snap_ends(polys: &[Vec<Vec2>], snap: f64, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
let mut out: Vec<Vec<Vec2>> = polys.to_vec();
|
||||
if !(snap.is_finite() && snap > tol) {
|
||||
return out;
|
||||
}
|
||||
// build() refuses this input anyway; don't spend ends x segments work on it.
|
||||
let total: usize = polys.iter().map(|p| p.len().saturating_sub(1)).sum();
|
||||
if total > MAX_SEGMENTS {
|
||||
return out;
|
||||
}
|
||||
// (target polyline, segment index, parameter on it, the point) for every vertex to insert.
|
||||
let mut inserts: Vec<(usize, usize, f64, Vec2)> = Vec::new();
|
||||
let mut budget = MAX_GAP_PAIRS;
|
||||
'ends: for (i, pl) in polys.iter().enumerate() {
|
||||
let (Some(&first), Some(&last)) = (pl.first(), pl.last()) else { continue };
|
||||
if pl.len() < 2 || first.near(last, tol) {
|
||||
continue;
|
||||
}
|
||||
for at_end in [false, true] {
|
||||
let e = if at_end { last } else { first };
|
||||
if !e.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let mut best: Option<(f64, usize, usize, f64, Vec2)> = None;
|
||||
let mut touching = false;
|
||||
for (j, other) in polys.iter().enumerate() {
|
||||
if j == i {
|
||||
continue;
|
||||
}
|
||||
for (k, w) in other.windows(2).enumerate() {
|
||||
if budget == 0 {
|
||||
break 'ends;
|
||||
}
|
||||
budget -= 1;
|
||||
let [a, b] = w else { continue };
|
||||
if !(a.is_finite() && b.is_finite())
|
||||
|| e.x < a.x.min(b.x) - snap
|
||||
|| e.x > a.x.max(b.x) + snap
|
||||
|| e.y < a.y.min(b.y) - snap
|
||||
|| e.y > a.y.max(b.y) + snap
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let line = Line::new(*a, *b);
|
||||
let t = line.param_of(e).clamp(0.0, 1.0);
|
||||
let q = line.at(t);
|
||||
let d = q.dist(e);
|
||||
if d <= tol {
|
||||
touching = true;
|
||||
} else if d <= snap && best.is_none_or(|(bd, ..)| d < bd) {
|
||||
best = Some((d, j, k, t, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((_, j, k, t, q)) = best.filter(|_| !touching) else { continue };
|
||||
let Some(slot) = out.get_mut(i).and_then(|v| if at_end { v.last_mut() } else { v.first_mut() }) else { continue };
|
||||
*slot = q;
|
||||
let ends = polys.get(j).and_then(|o| o.get(k).zip(o.get(k + 1)));
|
||||
if let Some((&a, &b)) = ends
|
||||
&& !q.near(a, tol)
|
||||
&& !q.near(b, tol)
|
||||
{
|
||||
inserts.push((j, k, t, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Later segments and larger parameters first, so earlier indices stay valid.
|
||||
inserts.sort_by(|x, y| y.0.cmp(&x.0).then(y.1.cmp(&x.1)).then(y.2.total_cmp(&x.2)));
|
||||
for (j, k, _, q) in inserts {
|
||||
if let Some(v) = out.get_mut(j)
|
||||
&& k < v.len()
|
||||
{
|
||||
v.insert(k + 1, q);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The closed loop enclosing `p`, counter-clockwise, or `None`.
|
||||
pub fn enclosing_loop(polys: &[Vec<Vec2>], p: Vec2, tol: f64) -> Option<Vec<Vec2>> {
|
||||
enclosing_loop_gap(polys, p, tol, 0.0)
|
||||
@@ -721,4 +803,34 @@ mod tests {
|
||||
assert!(enclosing_loop_gap(&polys, p, 1e-9, f64::NAN).is_none());
|
||||
assert!(enclosing_loop_gap(&polys, p, 1e-9, f64::INFINITY).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_ending_just_short_of_a_chord_is_joined_to_it() {
|
||||
// A strip between two verticals and a flattened arc that bows below the ends of the
|
||||
// verticals by 0.05 (the verticals stop on the true curve, the chord is lower).
|
||||
let top = vec![Vec2::new(0.0, 10.0), Vec2::new(2.0, 10.0)];
|
||||
let left = vec![Vec2::new(0.0, 10.0), Vec2::new(0.0, 0.05)];
|
||||
let right = vec![Vec2::new(2.0, 10.0), Vec2::new(2.0, 0.05)];
|
||||
let chord = vec![Vec2::new(-5.0, 0.0), Vec2::new(5.0, 0.0)];
|
||||
let polys = vec![top, left, right, chord];
|
||||
let p = Vec2::new(1.0, 5.0);
|
||||
assert!(enclosing_loop(&polys, p, 1e-9).is_none());
|
||||
let snapped = snap_ends(&polys, 0.1, 1e-9);
|
||||
let l = enclosing_loop(&snapped, p, 1e-9).unwrap();
|
||||
assert!((shoelace(&l) - 20.0).abs() < 1e-6, "{}", shoelace(&l));
|
||||
// Too far away: left alone.
|
||||
assert_eq!(snap_ends(&polys, 0.01, 1e-9), polys);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_ends_skips_input_over_the_segment_cap() {
|
||||
let mut polys: Vec<Vec<Vec2>> = (0..=MAX_SEGMENTS)
|
||||
.map(|i| {
|
||||
let x = i as f64 * 10.0;
|
||||
vec![Vec2::new(x, 0.0), Vec2::new(x, 1.0)]
|
||||
})
|
||||
.collect();
|
||||
polys.push(vec![Vec2::new(0.0, 1.05), Vec2::new(5.0, 1.05)]);
|
||||
assert_eq!(snap_ends(&polys, 0.1, 1e-9), polys);
|
||||
}
|
||||
}
|
||||
|
||||
+414
-10
@@ -81,11 +81,25 @@ impl Spline {
|
||||
if !self.is_valid() {
|
||||
return self.control.first().copied().unwrap_or_default();
|
||||
}
|
||||
let n = self.control.len() - 1;
|
||||
let (lo, hi) = self.domain();
|
||||
self.eval_valid(t, lo, hi)
|
||||
}
|
||||
|
||||
/// `eval` on a spline `is_valid` has accepted, given its `domain()`, so sampling many points
|
||||
/// validates once (`is_valid` walks the knot vector).
|
||||
fn eval_valid(&self, t: f64, lo: f64, hi: f64) -> Vec2 {
|
||||
let n = self.control.len().saturating_sub(1);
|
||||
let t = t.clamp(lo, hi);
|
||||
let span = find_span(n, self.degree, t, &self.knots);
|
||||
let b = basis(span, t, self.degree, &self.knots);
|
||||
let (mut nb, mut left, mut right) = ([0.0; MAX_STACK_DEGREE + 1], [0.0; MAX_STACK_DEGREE + 1], [0.0; MAX_STACK_DEGREE + 1]);
|
||||
let heap;
|
||||
let b: &[f64] = if self.degree <= MAX_STACK_DEGREE {
|
||||
basis_into(span, t, self.degree, &self.knots, &mut nb, &mut left, &mut right);
|
||||
nb.get(..=self.degree).unwrap_or(&[])
|
||||
} else {
|
||||
heap = basis(span, t, self.degree, &self.knots);
|
||||
&heap
|
||||
};
|
||||
let mut p = Vec2::ZERO;
|
||||
let mut wsum = 0.0;
|
||||
for (k, nb) in b.iter().enumerate() {
|
||||
@@ -103,19 +117,105 @@ impl Spline {
|
||||
if !self.is_valid() {
|
||||
return self.control.clone();
|
||||
}
|
||||
let (lo, hi) = self.domain();
|
||||
// Control-polygon length guides the sample count.
|
||||
let poly: f64 = self.control.windows(2).map(|w| w.first().zip(w.get(1)).map(|(a, b)| a.dist(*b)).unwrap_or(0.0)).sum();
|
||||
let n = ((poly / tol.max(1e-9)).sqrt() * 2.0).clamp(8.0, 2000.0) as usize * self.degree.max(1);
|
||||
let n = n.min(4000);
|
||||
(0..=n).map(|i| self.eval(lo + (hi - lo) * i as f64 / n as f64)).collect()
|
||||
self.samples(self.sample_count(tol)).collect()
|
||||
}
|
||||
|
||||
/// How many segments a tessellation at `tol` takes: the control-polygon length guides it,
|
||||
/// capped at `MAX_SAMPLES`.
|
||||
fn sample_count(&self, tol: f64) -> usize {
|
||||
self.segments_for(self.polygon_length() / tol.max(1e-9))
|
||||
}
|
||||
|
||||
/// The segments for a control polygon `ratio` times the tolerance: dimensionless, so a
|
||||
/// spline and its scaled copy sample alike at any scale.
|
||||
fn segments_for(&self, ratio: f64) -> usize {
|
||||
let n = (ratio.sqrt() * 2.0).clamp(8.0, 2000.0) as usize * self.degree.max(1);
|
||||
n.min(MAX_SAMPLES)
|
||||
}
|
||||
|
||||
fn polygon_length(&self) -> f64 {
|
||||
self.control.windows(2).map(|w| w.first().zip(w.get(1)).map(|(a, b)| a.dist(*b)).unwrap_or(0.0)).sum()
|
||||
}
|
||||
|
||||
/// `n + 1` points evenly spaced in parameter over the domain (the spline must be valid).
|
||||
fn samples(&self, n: usize) -> impl Iterator<Item = Vec2> + '_ {
|
||||
let (lo, hi) = self.domain();
|
||||
(0..=n).map(move |i| self.eval_valid(lo + (hi - lo) * i as f64 / n as f64, lo, hi))
|
||||
}
|
||||
|
||||
/// The box round the curve, from as many samples as `tessellate` takes at a tolerance
|
||||
/// relative to the spline's own size (1e-3 of its control box's diagonal), spread over the
|
||||
/// knot spans in proportion to their length with at least eight per span (its ends included),
|
||||
/// so a narrow span, which one grid over the whole domain can step over entirely, is seen
|
||||
/// too. The box can fall short of an extreme by the sag between two samples: under 1e-3 of
|
||||
/// the spline's size where the budget sets the count, under 1% in a very narrow span held
|
||||
/// to the eight-segment floor, whatever the drawing's units (zoom extents and selection pre-filtering
|
||||
/// tolerate that; the sampled box was never exact). A fixed absolute tolerance sampled the
|
||||
/// 4000-point cap on every spline of a drawing in millimetres, a third of the time a large
|
||||
/// drawing took to open (#1076); a typical spline now takes a few hundred evaluations. The
|
||||
/// cap holds over all spans: the floor drops below eight per span when that would exceed it,
|
||||
/// and a spline with more spans than two each allow gets its control box, which contains the
|
||||
/// curve unless a weight is negative (then the grid). A degree-1 spline with positive weights
|
||||
/// is its control polygon, so its control box is exact; a degenerate spline gets its control
|
||||
/// box, which contains the curve (the box of the finite control points when one is not
|
||||
/// finite: `hull` skips it, but it would poison every sample).
|
||||
pub fn bounds(&self) -> Bounds2 {
|
||||
Bounds2::from_points(self.tessellate(1e-3))
|
||||
let hull = Bounds2::from_points(self.control.iter().copied());
|
||||
let diag = hull.min.dist(hull.max);
|
||||
let finite = self.control.iter().all(|c| c.is_finite()) && self.weights.iter().all(|w| w.is_finite());
|
||||
// The convex hull property, which makes the control box contain the curve and a
|
||||
// degree-1 spline its control polygon, needs every weight positive.
|
||||
let positive = self.weights.iter().all(|w| *w > 0.0);
|
||||
if !self.is_valid() || !finite {
|
||||
return hull;
|
||||
}
|
||||
// With a zero or negative weight even a degenerate control box is no bound (zero weights
|
||||
// evaluate to the origin), so only positive weights take these shortcuts.
|
||||
if positive && (self.degree == 1 || !(diag.is_finite() && diag > 0.0)) {
|
||||
return hull;
|
||||
}
|
||||
let spans = self.knot_spans().count();
|
||||
if spans == 0 {
|
||||
return hull;
|
||||
}
|
||||
if spans > MAX_SAMPLES / 2 {
|
||||
// Too many spans for two samples each: the control box contains the curve when no
|
||||
// weight is negative (the convex hull property), so it is the safe answer; a grid could
|
||||
// step over a narrow span. Negative weights void that property, so they keep the grid.
|
||||
if positive {
|
||||
return hull;
|
||||
}
|
||||
return Bounds2::from_points(self.samples(MAX_SAMPLES));
|
||||
}
|
||||
let (lo, hi) = self.domain();
|
||||
let floor = 8.min(MAX_SAMPLES / spans);
|
||||
// The span's share of the domain first: huge knots (1e308, from a hostile edit) would
|
||||
// overflow `budget * (b - a)` to infinity and the cast to a boundless count.
|
||||
let segments = |budget: usize, (a, b): (f64, f64)| floor.max((budget as f64 * ((b - a) / (hi - lo))) as usize).min(MAX_SAMPLES);
|
||||
let n = self.segments_for(self.polygon_length() / (diag * 1e-3));
|
||||
let budget = if self.knot_spans().map(|s| segments(n, s)).sum::<usize>() <= MAX_SAMPLES { n } else { MAX_SAMPLES - floor * spans };
|
||||
let mut out = Bounds2::EMPTY;
|
||||
for (a, b) in self.knot_spans() {
|
||||
let seg = segments(budget, (a, b));
|
||||
for i in 0..seg {
|
||||
out.add(self.eval_valid(a + (b - a) * i as f64 / seg as f64, lo, hi));
|
||||
}
|
||||
}
|
||||
out.add(self.eval_valid(hi, lo, hi));
|
||||
out
|
||||
}
|
||||
|
||||
/// The non-empty knot spans the curve is defined over, in parameter order.
|
||||
fn knot_spans(&self) -> impl Iterator<Item = (f64, f64)> + '_ {
|
||||
let knots = self.knots.get(self.degree..=self.control.len()).unwrap_or(&[]);
|
||||
knots.windows(2).filter_map(|w| w.first().zip(w.get(1)).map(|(a, b)| (*a, *b)).filter(|(a, b)| b > a))
|
||||
}
|
||||
}
|
||||
|
||||
/// Most segments `tessellate` and `bounds` evaluate for one spline (#268: a high degree on many
|
||||
/// control points took minutes).
|
||||
const MAX_SAMPLES: usize = 4000;
|
||||
|
||||
pub(crate) fn clamped_uniform_knots(n: usize, degree: usize) -> Vec<f64> {
|
||||
let mut k = vec![0.0; degree + 1];
|
||||
let inner = n.saturating_sub(degree + 1);
|
||||
@@ -151,10 +251,25 @@ pub(crate) fn find_span(n: usize, p: usize, u: f64, knots: &[f64]) -> usize {
|
||||
mid
|
||||
}
|
||||
|
||||
/// Highest degree `eval` works out on the stack. The DXF reader and the SPLINE command cap the
|
||||
/// degree at 10; degrees 11 up to `MAX_DEGREE` (a programmatic spline) take a heap buffer.
|
||||
pub(crate) const MAX_STACK_DEGREE: usize = 10;
|
||||
|
||||
/// The `p + 1` non-zero basis functions of degree `p` at `u` in `span`.
|
||||
pub(crate) fn basis(span: usize, u: f64, p: usize, knots: &[f64]) -> Vec<f64> {
|
||||
let mut n = vec![0.0; p + 1];
|
||||
let mut left = vec![0.0; p + 1];
|
||||
let mut right = vec![0.0; p + 1];
|
||||
basis_into(span, u, p, knots, &mut n, &mut left, &mut right);
|
||||
n
|
||||
}
|
||||
|
||||
/// `basis` into `n[..=p]`, with `left` and `right` as scratch; each must hold `p + 1` values or
|
||||
/// `n` stays as it was.
|
||||
fn basis_into(span: usize, u: f64, p: usize, knots: &[f64], n: &mut [f64], left: &mut [f64], right: &mut [f64]) {
|
||||
if n.len() <= p || left.len() <= p || right.len() <= p {
|
||||
return;
|
||||
}
|
||||
if let Some(x) = n.get_mut(0) {
|
||||
*x = 1.0;
|
||||
}
|
||||
@@ -171,7 +286,6 @@ pub(crate) fn basis(span: usize, u: f64, p: usize, knots: &[f64]) -> Vec<f64> {
|
||||
}
|
||||
n[j] = saved;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Gaussian elimination with partial pivoting.
|
||||
@@ -207,6 +321,296 @@ pub(crate) fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `basis` before it evaluated on the stack, kept as the reference.
|
||||
fn basis_ref(span: usize, u: f64, p: usize, knots: &[f64]) -> Vec<f64> {
|
||||
let mut n = vec![0.0; p + 1];
|
||||
let mut left = vec![0.0; p + 1];
|
||||
let mut right = vec![0.0; p + 1];
|
||||
n[0] = 1.0;
|
||||
for j in 1..=p {
|
||||
let k = |i: isize| knots.get(i.max(0) as usize).copied().unwrap_or(0.0);
|
||||
left[j] = u - k(span as isize + 1 - j as isize);
|
||||
right[j] = k(span as isize + j as isize) - u;
|
||||
let mut saved = 0.0;
|
||||
for r in 0..j {
|
||||
let den = right[r + 1] + left[j - r];
|
||||
let tmp = if den.abs() > 1e-300 { n[r] / den } else { 0.0 };
|
||||
n[r] = saved + right[r + 1] * tmp;
|
||||
saved = left[j - r] * tmp;
|
||||
}
|
||||
n[j] = saved;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// `eval` before it evaluated on the stack, kept as the reference.
|
||||
fn eval_ref(s: &Spline, t: f64) -> Vec2 {
|
||||
let n = s.control.len() - 1;
|
||||
let (lo, hi) = s.domain();
|
||||
let t = t.clamp(lo, hi);
|
||||
let span = find_span(n, s.degree, t, &s.knots);
|
||||
let b = basis_ref(span, t, s.degree, &s.knots);
|
||||
let mut p = Vec2::ZERO;
|
||||
let mut wsum = 0.0;
|
||||
for (k, nb) in b.iter().enumerate() {
|
||||
let idx = span + k - s.degree;
|
||||
let w = if s.weights.is_empty() { 1.0 } else { s.weights[idx] };
|
||||
p += s.control[idx] * (nb * w);
|
||||
wsum += nb * w;
|
||||
}
|
||||
if wsum.abs() > 1e-300 { p / wsum } else { p }
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random numbers in `0..1`.
|
||||
fn noise(seed: &mut u64) -> f64 {
|
||||
*seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(*seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
/// Splines of degree 1..=11 over 14 control points: clamped and unclamped (uniform) knots,
|
||||
/// unit and random weights.
|
||||
fn samples() -> Vec<Spline> {
|
||||
let mut seed = 7;
|
||||
let mut out = Vec::new();
|
||||
for degree in 1..=11 {
|
||||
let control: Vec<Vec2> = (0..14).map(|i| Vec2::new(i as f64 * 3.0 + noise(&mut seed), 10.0 * noise(&mut seed) - 5.0)).collect();
|
||||
let weights: Vec<f64> = (0..14).map(|_| 0.5 + 1.5 * noise(&mut seed)).collect();
|
||||
let clamped = Spline::from_control(control.clone(), degree);
|
||||
let uniform = Spline { knots: (0..control.len() + degree + 1).map(|i| i as f64 * 0.25).collect(), ..clamped.clone() };
|
||||
for base in [clamped, uniform] {
|
||||
out.push(Spline { weights: weights.clone(), ..base.clone() });
|
||||
out.push(base);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_basis_matches_the_heap_basis_bit_for_bit() {
|
||||
for s in samples() {
|
||||
assert!(s.is_valid());
|
||||
let (lo, hi) = s.domain();
|
||||
for i in -20..=1020 {
|
||||
let t = lo + (hi - lo) * i as f64 / 1000.0;
|
||||
let got = s.eval(t);
|
||||
let want = eval_ref(&s, t);
|
||||
assert_eq!(
|
||||
(got.x.to_bits(), got.y.to_bits()),
|
||||
(want.x.to_bits(), want.y.to_bits()),
|
||||
"degree {} at {t}: {got:?} vs {want:?}",
|
||||
s.degree
|
||||
);
|
||||
let span = find_span(s.control.len() - 1, s.degree, t.clamp(lo, hi), &s.knots);
|
||||
let b = basis(span, t.clamp(lo, hi), s.degree, &s.knots);
|
||||
let r = basis_ref(span, t.clamp(lo, hi), s.degree, &s.knots);
|
||||
assert!(b.iter().zip(&r).all(|(a, b)| a.to_bits() == b.to_bits()) && b.len() == r.len(), "degree {}", s.degree);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_into_needs_room_for_every_function() {
|
||||
let knots = clamped_uniform_knots(6, 3);
|
||||
let (mut n, mut left, mut right) = ([0.5; 3], [0.0; 4], [0.0; 4]);
|
||||
basis_into(3, 0.5, 3, &knots, &mut n, &mut left, &mut right);
|
||||
assert_eq!(n, [0.5; 3]);
|
||||
}
|
||||
|
||||
/// A cubic through 300 points of a sine wave, open or closed.
|
||||
fn wiggly(closed: bool) -> Spline {
|
||||
let fit: Vec<Vec2> = (0..300)
|
||||
.map(|i| {
|
||||
let x = i as f64 / 299.0;
|
||||
Vec2::new(x * 100.0, 10.0 * (x * 60.0).sin())
|
||||
})
|
||||
.collect();
|
||||
Spline::from_fit(&fit, closed)
|
||||
}
|
||||
|
||||
/// The box from 4000 evaluations, which `bounds` approximates.
|
||||
fn fine_box(s: &Spline) -> Bounds2 {
|
||||
uniform_box(s, MAX_SAMPLES)
|
||||
}
|
||||
|
||||
/// The box of `n + 1` samples evenly spaced in parameter over the domain, as `bounds` sampled
|
||||
/// before it spread its samples over the knot spans.
|
||||
fn uniform_box(s: &Spline, n: usize) -> Bounds2 {
|
||||
let (lo, hi) = s.domain();
|
||||
Bounds2::from_points((0..=n).map(|i| s.eval(lo + (hi - lo) * i as f64 / n as f64)))
|
||||
}
|
||||
|
||||
/// A degree-2 spline whose fourth control point, the only one off the x axis, is supported
|
||||
/// on the single knot span [0.5003, 0.5004) (double knots either side): the curve is flat
|
||||
/// except for a peak of 50 at t = 0.50035, inside a span of width 1e-4 that no point of a
|
||||
/// uniform grid of up to 4000 hits (0.50025 and 0.5005 are the nearest).
|
||||
fn narrow_span() -> Spline {
|
||||
let control = (0..7).map(|i| Vec2::new(i as f64, if i == 3 { 100.0 } else { 0.0 })).collect();
|
||||
Spline { degree: 2, knots: vec![0.0, 0.0, 0.0, 0.5003, 0.5003, 0.5004, 0.5004, 1.0, 1.0, 1.0], control, ..Spline::default() }
|
||||
}
|
||||
|
||||
/// A span held to the floor samples its peak off the grid: the sag stays under 1% of the
|
||||
/// spline's size, which is what `bounds` promises for it.
|
||||
#[test]
|
||||
fn an_off_grid_peak_in_a_narrow_span_is_within_a_percent() {
|
||||
let mut s = narrow_span();
|
||||
// y(s) = 200 s (1 - s) + 30 s² on the narrow span: its maximum is at s = 10 / 17, between
|
||||
// the samples of an eight-segment grid.
|
||||
s.control[4].y = 30.0;
|
||||
assert!(s.is_valid());
|
||||
let (a, b) = (0.5003, 0.5004);
|
||||
let peak = (0..=10_000).map(|i| s.eval(a + (b - a) * i as f64 / 10_000.0)).fold(Vec2::ZERO, |m, p| if p.y > m.y { p } else { m });
|
||||
assert!(peak.y > 58.0 && peak.y < 59.0, "{peak:?}");
|
||||
let hull = Bounds2::from_points(s.control.iter().copied());
|
||||
let diag = hull.min.dist(hull.max);
|
||||
let got = s.bounds();
|
||||
assert!(got.max.y <= peak.y + 1e-9 * diag && got.max.y >= peak.y - 1e-2 * diag, "{got:?} vs peak {peak:?}");
|
||||
// Four segments, the floor before, missed it by more than that.
|
||||
let four = (0..=4).map(|i| s.eval(a + (b - a) * i as f64 / 4.0)).fold(f64::MIN, |m, p| m.max(p.y));
|
||||
assert!(four < peak.y - 1e-2 * diag, "{four} vs {peak:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_see_an_extreme_inside_a_narrow_knot_span() {
|
||||
let s = narrow_span();
|
||||
assert!(s.is_valid());
|
||||
let (a, b) = (0.5003, 0.5004);
|
||||
let peak = (0..=1000).map(|i| s.eval(a + (b - a) * i as f64 / 1000.0)).fold(Vec2::ZERO, |m, p| if p.y > m.y { p } else { m });
|
||||
assert!((peak.x - 3.0).abs() < 1e-9 && (peak.y - 50.0).abs() < 1e-9, "{peak:?}");
|
||||
let hull = Bounds2::from_points(s.control.iter().copied());
|
||||
let diag = hull.min.dist(hull.max);
|
||||
// One uniform grid, at the count `bounds` used to take or at the cap, steps over the span
|
||||
// and sees only the flat parts of the curve.
|
||||
let old = s.segments_for(s.polygon_length() / (diag * 1e-3)).max((s.control.len() - s.degree) * 4).min(MAX_SAMPLES);
|
||||
for n in [old, MAX_SAMPLES] {
|
||||
let grid = uniform_box(&s, n);
|
||||
assert!(grid.max.y < 1e-9 && !grid.contains(peak), "{n} segments: {grid:?}");
|
||||
}
|
||||
let got = s.bounds();
|
||||
assert!(got.expand(1e-9 * diag).contains(peak), "{got:?} misses {peak:?}");
|
||||
assert!((got.max.y - peak.y).abs() <= 1e-9 * diag, "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_keep_their_cap_over_many_spans() {
|
||||
// 1500 spans: eight per span would pass the cap, so two; 2500 spans: more than the cap
|
||||
// allows two for, so the control box, which contains the curve. A smooth curve stays
|
||||
// within the margin either way.
|
||||
for n in [1502, 2502] {
|
||||
let control: Vec<Vec2> = (0..n).map(|i| Vec2::from_angle(i as f64 / n as f64 * std::f64::consts::TAU) * 50.0).collect();
|
||||
let hull = Bounds2::from_points(control.iter().copied());
|
||||
let s = Spline::from_control(control, 2);
|
||||
let (b, want) = (s.bounds(), fine_box(&s));
|
||||
if n > MAX_SAMPLES / 2 {
|
||||
assert_eq!(b, hull, "{n} control points");
|
||||
assert!(b.contains(want.min) && b.contains(want.max), "{b:?} vs {want:?}");
|
||||
}
|
||||
let diag = want.min.dist(want.max);
|
||||
let err = [b.min.x - want.min.x, b.min.y - want.min.y, b.max.x - want.max.x, b.max.y - want.max.y]
|
||||
.into_iter()
|
||||
.map(f64::abs)
|
||||
.fold(0.0, f64::max);
|
||||
assert!(err <= 1e-3 * diag, "{n} control points: {b:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_keep_their_cap_over_huge_knot_spans() {
|
||||
// Knots a hostile edit can produce (lengthening by 1e308): a span's count must not
|
||||
// overflow into a boundless loop or a sum that panics.
|
||||
let control = vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 1.0)];
|
||||
for knots in [
|
||||
vec![0.0, 0.0, 0.0, 1e308, 1.5e308, 1.5e308, 1.5e308],
|
||||
vec![0.0, 0.0, 0.0, f64::MAX, f64::MAX, f64::MAX, f64::MAX],
|
||||
vec![-1e308, -1e308, -1e308, 0.0, 1e308, 1e308, 1e308],
|
||||
] {
|
||||
let s = Spline { knots, ..Spline::from_control(control.clone(), 2) };
|
||||
assert!(s.is_valid());
|
||||
let b = s.bounds();
|
||||
assert!(b.is_empty() || Bounds2::from_points(control.iter().copied()).expand(1e-9).contains_box(&b), "{b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn scaled(s: &Spline, k: f64) -> Spline {
|
||||
Spline { control: s.control.iter().map(|c| *c * k).collect(), fit: s.fit.iter().map(|c| *c * k).collect(), ..s.clone() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_stay_within_a_small_margin_of_the_finely_sampled_box() {
|
||||
let mut all = samples();
|
||||
all.push(wiggly(false));
|
||||
all.push(wiggly(true));
|
||||
all.push(Spline::from_fit_points_closed(&[Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]));
|
||||
for s in all {
|
||||
// Whatever the drawing's units: the same relative margin at any scale, including
|
||||
// splines far smaller than any absolute tolerance floor.
|
||||
for k in [1e-12, 1e-9, 1e-6, 1.0, 1e6] {
|
||||
let s = scaled(&s, k);
|
||||
let (b, want) = (s.bounds(), fine_box(&s));
|
||||
let diag = want.min.dist(want.max);
|
||||
let err = [b.min.x - want.min.x, b.min.y - want.min.y, b.max.x - want.max.x, b.max.y - want.max.y]
|
||||
.into_iter()
|
||||
.map(f64::abs)
|
||||
.fold(0.0, f64::max);
|
||||
assert!(err <= 1e-3 * diag, "degree {} with {} control points at scale {k}: {b:?} vs {want:?}", s.degree, s.control.len());
|
||||
// The curve lies in its control points' convex hull (positive weights), up to rounding.
|
||||
assert!(Bounds2::from_points(s.control.iter().copied()).expand(1e-9 * diag).contains_box(&b), "degree {} at scale {k}", s.degree);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A negative weight voids the convex hull property: a degree-1 spline is then not its
|
||||
/// control polygon, so it is sampled like any other.
|
||||
#[test]
|
||||
fn a_degree_one_spline_with_a_negative_weight_is_sampled() {
|
||||
let mut s = Spline::from_control(vec![Vec2::new(1.0, 0.0), Vec2::new(2.0, 0.0)], 1);
|
||||
s.weights = vec![1.0, -0.5];
|
||||
assert!(s.is_valid());
|
||||
let far = s.eval(0.75);
|
||||
assert!((far.x - 4.0).abs() < 1e-9, "{far:?}");
|
||||
let b = s.bounds();
|
||||
assert!(b.contains(far), "{b:?} misses {far:?}");
|
||||
s.weights = vec![1.0, 0.5];
|
||||
assert_eq!(s.bounds(), Bounds2::new(Vec2::new(1.0, 0.0), Vec2::new(2.0, 0.0)));
|
||||
}
|
||||
|
||||
/// Zero weights evaluate to the origin, so a spline whose control points coincide is not its
|
||||
/// control box either; it is sampled.
|
||||
#[test]
|
||||
fn zero_weights_are_sampled_not_boxed() {
|
||||
let mut s = Spline::from_control(vec![Vec2::new(3.0, 4.0); 2], 1);
|
||||
s.weights = vec![0.0, 0.0];
|
||||
assert!(s.is_valid());
|
||||
assert_eq!(s.eval(0.5), Vec2::ZERO);
|
||||
let b = s.bounds();
|
||||
assert!(b.contains(Vec2::ZERO), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_splines_get_their_control_box() {
|
||||
let point = Spline::from_control(vec![Vec2::new(3.0, 4.0); 4], 3);
|
||||
assert_eq!(point.bounds(), Bounds2::new(Vec2::new(3.0, 4.0), Vec2::new(3.0, 4.0)));
|
||||
let invalid = Spline { knots: vec![0.0; 3], ..Spline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(5.0, -1.0)], 2) };
|
||||
assert!(!invalid.is_valid());
|
||||
assert_eq!(invalid.bounds(), Bounds2::new(Vec2::new(0.0, -1.0), Vec2::new(5.0, 2.0)));
|
||||
let hostile = Spline::from_control(vec![Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, f64::INFINITY), Vec2::new(2.0, 2.0)], 2);
|
||||
assert_eq!(hostile.bounds(), Bounds2::new(Vec2::new(2.0, 2.0), Vec2::new(2.0, 2.0)));
|
||||
// A non-finite control point among finite ones poisons every sample: the box of the
|
||||
// finite control points, not the empty box.
|
||||
for bad in [Vec2::new(f64::NAN, 5.0), Vec2::new(5.0, f64::NAN), Vec2::new(f64::INFINITY, 5.0), Vec2::new(5.0, f64::NEG_INFINITY)] {
|
||||
let s = Spline::from_control(vec![Vec2::ZERO, bad, Vec2::new(10.0, 10.0), Vec2::new(20.0, 0.0)], 3);
|
||||
assert!(s.is_valid());
|
||||
assert_eq!(s.bounds(), Bounds2::new(Vec2::ZERO, Vec2::new(20.0, 10.0)), "{bad:?}");
|
||||
}
|
||||
// A non-finite weight poisons the samples the same way.
|
||||
for bad in [f64::NAN, f64::INFINITY] {
|
||||
let mut s = Spline::from_control(vec![Vec2::ZERO, Vec2::new(5.0, 5.0), Vec2::new(10.0, 10.0), Vec2::new(20.0, 0.0)], 3);
|
||||
s.weights = vec![1.0, bad, 1.0, 1.0];
|
||||
assert!(s.is_valid());
|
||||
assert_eq!(s.bounds(), Bounds2::new(Vec2::ZERO, Vec2::new(20.0, 10.0)), "weight {bad:?}");
|
||||
}
|
||||
assert!(Spline::default().bounds().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_degree_spline_is_invalid_and_cheap_to_tessellate() {
|
||||
// A HATCH/DXF spline edge can declare a very high degree with matching knots. Such a
|
||||
|
||||
@@ -285,7 +285,7 @@ pub(crate) fn arrow_block_name(name: &str) -> Option<(String, Arrowhead)> {
|
||||
return None;
|
||||
}
|
||||
let kind = Arrowhead::parse(n);
|
||||
let norm: String = n.trim_start_matches('_').chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
||||
let norm: String = n.trim_start_matches('_').chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
|
||||
if kind == Arrowhead::ClosedFilled && !matches!(norm.as_str(), "closedfilled" | "filled") {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -455,6 +455,25 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) {
|
||||
EntityKind::Unknown(u) => Some(up(&u.dxf_type)),
|
||||
_ => None,
|
||||
}));
|
||||
// A proxy's class is the one its group 91 numbers in the file's CLASSES: keep it too, and the
|
||||
// file's class order, by which saving renumbers the proxy.
|
||||
let class_order: Vec<String> =
|
||||
classes.iter().filter(|(k, _, _)| k == "CLASS").take(MAX_OBJECTS).map(|(_, _, t)| up(&val(t, 1).unwrap_or_default())).collect();
|
||||
let mut proxies = false;
|
||||
let proxy_tags = f.objects.iter().filter(|o| is_proxy(&o.kind)).map(|o| &o.tags);
|
||||
let proxy_tags = proxy_tags.chain(all_entities(d).filter_map(|e| match &e.kind {
|
||||
EntityKind::Unknown(u) if is_proxy(&u.dxf_type) => Some(&u.tags),
|
||||
_ => None,
|
||||
}));
|
||||
for tags in proxy_tags {
|
||||
proxies = true;
|
||||
if let Some(name) = class_index(class_number(tags.iter().map(|r| (r.code, r.value.as_str())))).and_then(|i| class_order.get(i)) {
|
||||
types.insert(name.clone());
|
||||
}
|
||||
}
|
||||
if proxies {
|
||||
f.class_order = class_order;
|
||||
}
|
||||
for (kind, _, t) in &classes {
|
||||
if kind == "CLASS" && val(t, 1).is_some_and(|n| types.contains(&up(&n))) && f.classes.len() < MAX_OBJECTS {
|
||||
f.classes.push(t.iter().map(raw).collect());
|
||||
@@ -492,6 +511,45 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Proxy records (DXF Reference, ACAD_PROXY_OBJECT and ACAD_PROXY_ENTITY): another application's
|
||||
/// objects and entities, whose class is named by number in group 91.
|
||||
fn is_proxy(kind: &str) -> bool {
|
||||
matches!(up(kind).as_str(), "ACAD_PROXY_OBJECT" | "ACAD_PROXY_ENTITY")
|
||||
}
|
||||
|
||||
/// Where a proxy's class number is: its first group 91 outside the `102 {NAME` … `102 }` groups
|
||||
/// (an application's can hold a 91 of its own) and before any xdata.
|
||||
fn class_group<S: AsRef<str>>(groups: impl IntoIterator<Item = (i32, S)>) -> Option<usize> {
|
||||
let mut in_group = false;
|
||||
for (i, (code, value)) in groups.into_iter().enumerate() {
|
||||
match code {
|
||||
1000.. => return None,
|
||||
102 => in_group = value.as_ref().trim().starts_with('{'),
|
||||
91 if !in_group => return Some(i),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A proxy's class number (see [`class_group`]).
|
||||
fn class_number<S: AsRef<str>>(groups: impl IntoIterator<Item = (i32, S)> + Clone) -> Option<i64> {
|
||||
let i = class_group(groups.clone())?;
|
||||
groups.into_iter().nth(i).and_then(|(_, v)| v.as_ref().trim().parse().ok())
|
||||
}
|
||||
|
||||
/// The place in CLASSES a class number names (500 is the first class).
|
||||
fn class_index(number: Option<i64>) -> Option<usize> {
|
||||
usize::try_from(number?.checked_sub(500)?).ok()
|
||||
}
|
||||
|
||||
/// Set a proxy's class number (see [`class_group`]).
|
||||
fn set_class_number(tags: &mut [Tag], n: i64) {
|
||||
if let Some(t) = class_group(tags.iter().map(|t| (t.code, t.str()))).and_then(|i| tags.get_mut(i)) {
|
||||
*t = Tag::i(91, n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Most groups of a data store section kept (a damaged file's can be endless).
|
||||
const MAX_DATA_STORE_GROUPS: usize = 1 << 26;
|
||||
|
||||
@@ -775,8 +833,28 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing) {
|
||||
roles.entry(k).or_insert(h);
|
||||
}
|
||||
let mut written: HashSet<String> = recs.iter().filter_map(|r| head(body(r)).handle).collect();
|
||||
// The written CLASSES as numbered (500 for the first): the writer's own, then the kept ones it
|
||||
// doesn't write (appended below, in this order). A kept proxy's group 91 is renumbered to its
|
||||
// class's place here; a proxy whose class isn't written is dropped (#1812).
|
||||
let class_names: HashSet<String> = recs.iter().filter(|r| r.kind == "CLASS").filter_map(|r| val(body(r), 1)).map(|n| up(&n)).collect();
|
||||
let appended = f.classes.iter().filter_map(|c| c.iter().find(|t| t.code == 1)).map(|n| up(&n.value)).filter(|n| !class_names.contains(n));
|
||||
let mut numbers: HashMap<String, i64> = HashMap::new();
|
||||
for (i, n) in recs.iter().filter(|r| r.kind == "CLASS").filter_map(|r| val(body(r), 1)).map(|n| up(&n)).chain(appended).enumerate() {
|
||||
numbers.entry(n).or_insert(500_i64.saturating_add(i64::try_from(i).unwrap_or(i64::MAX)));
|
||||
}
|
||||
let renumber = |old: Option<i64>| class_index(old).and_then(|i| f.class_order.get(i)).and_then(|n| numbers.get(n).copied());
|
||||
// Entities of types CADCraft doesn't model, placed among the written ones.
|
||||
let planned = plan_unknown(d, &recs, tags, &roles);
|
||||
let mut planned = plan_unknown(d, &recs, tags, &roles);
|
||||
for list in planned.values_mut() {
|
||||
list.retain_mut(|(_, t)| {
|
||||
if !t.first().is_some_and(|z| is_proxy(&z.str())) {
|
||||
return true;
|
||||
}
|
||||
let Some(n) = renumber(class_number(t.iter().map(|x| (x.code, x.str())))) else { return false };
|
||||
set_class_number(t, n);
|
||||
true
|
||||
});
|
||||
}
|
||||
for list in planned.values() {
|
||||
for (_, t) in list {
|
||||
written.extend(head(t.get(1..).unwrap_or(&[])).handle);
|
||||
@@ -813,6 +891,9 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing) {
|
||||
.filter_map(|o| Some((o.handle.hex(), head(&o.tags.iter().take_while(|t| t.code < 1000).map(tag).collect::<Vec<_>>()).owner?)))
|
||||
.collect();
|
||||
let mut alive_memo: HashMap<String, bool> = HashMap::new();
|
||||
for o in f.objects.iter().filter(|o| is_proxy(&o.kind) && renumber(class_number(o.tags.iter().map(|r| (r.code, r.value.as_str())))).is_none()) {
|
||||
alive_memo.insert(o.handle.hex(), false);
|
||||
}
|
||||
for o in &f.objects {
|
||||
let mut path: Vec<String> = Vec::new();
|
||||
let mut cur = o.handle.hex();
|
||||
@@ -850,7 +931,6 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing) {
|
||||
}
|
||||
let entities: HashSet<String> = all_entities(d).map(|e| e.handle.hex()).filter(|h| written.contains(h)).collect();
|
||||
let map = Map { f, alive, raw: objs.keys().cloned().collect(), roles, entities };
|
||||
let class_names: HashSet<String> = recs.iter().filter(|r| r.kind == "CLASS").filter_map(|r| val(body(r), 1)).map(|n| up(&n)).collect();
|
||||
let mut out: Vec<Tag> = Vec::with_capacity(tags.len() + f.objects.iter().map(|o| o.tags.len() + 1).sum::<usize>());
|
||||
let mut block = String::new();
|
||||
for r in &recs {
|
||||
@@ -866,7 +946,16 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing) {
|
||||
}
|
||||
if r.kind == "ENDSEC" && r.sec == "OBJECTS" {
|
||||
for o in f.objects.iter().filter(|o| map.alive.contains_key(&o.handle.hex())) {
|
||||
map.object(o, &mut out);
|
||||
let number = is_proxy(&o.kind).then(|| renumber(class_number(o.tags.iter().map(|r| (r.code, r.value.as_str()))))).flatten();
|
||||
match number {
|
||||
Some(n) => {
|
||||
let mut tags: Vec<Tag> = o.tags.iter().map(tag).collect();
|
||||
set_class_number(&mut tags, n);
|
||||
let o = ForeignObject { tags: tags.iter().map(raw).collect(), ..o.clone() };
|
||||
map.object(&o, &mut out);
|
||||
}
|
||||
None => map.object(o, &mut out),
|
||||
}
|
||||
}
|
||||
}
|
||||
for (sec, t) in planned.get(&r.start).into_iter().flatten() {
|
||||
@@ -913,8 +1002,7 @@ fn plan_unknown(d: &Drawing, recs: &[Rec], tags: &[Tag], roles: &HashMap<Foreign
|
||||
};
|
||||
let br = |name: &str| roles.get(&("BLOCK_RECORD".to_string(), up(name))).cloned();
|
||||
let first_at = |s: &cadcraft_doc::EntityStore| s.iter().find_map(|e| at.get(&e.handle.hex()).copied());
|
||||
let mut layouts: Vec<&cadcraft_doc::Layout> = d.layouts.iter().collect();
|
||||
layouts.sort_by_key(|l| l.tab_order);
|
||||
let layouts = d.paper_space_layouts();
|
||||
// (entities, owner, paper space, end of the space, section)
|
||||
let mut stores: Vec<(&cadcraft_doc::EntityStore, Option<String>, bool, Option<usize>, &str)> = Vec::new();
|
||||
let model_end = layouts.first().and_then(|l| first_at(&l.entities)).or(ents_end);
|
||||
|
||||
+87
-10
@@ -1795,7 +1795,12 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
|
||||
d.layouts.push(Layout::new("Layout1", 1));
|
||||
paper_block_for.insert("*PAPER_SPACE".into(), "Layout1".into());
|
||||
}
|
||||
let first_layout = d.layouts.first().map(|l| l.name.clone()).unwrap_or_else(|| "Layout1".into());
|
||||
// The layout in `*Paper_Space` is the active one, kept when it isn't the first tab (the first
|
||||
// is active by default); paper-space entities without an owner go there.
|
||||
let active = paper_block_for.get("*PAPER_SPACE").cloned();
|
||||
let first_tab = d.layouts.iter().min_by_key(|l| l.tab_order).map(|l| l.name.clone());
|
||||
d.active_layout = active.clone().filter(|a| Some(a) != first_tab.as_ref());
|
||||
let first_layout = active.or_else(|| d.layouts.first().map(|l| l.name.clone())).unwrap_or_else(|| "Layout1".into());
|
||||
// Blocks: model/paper space blocks hold entities in newer files.
|
||||
for (name, base, description, ents, xref) in blocks {
|
||||
let up = name.to_ascii_uppercase();
|
||||
@@ -1816,8 +1821,13 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
|
||||
continue;
|
||||
}
|
||||
// Generated geometry we rebuild on save: table blocks, and standard arrowhead blocks
|
||||
// nothing inserts.
|
||||
if rx.table_blocks.contains(&up) || (crate::dxf_ext::is_standard_arrow(&name) && !rx.inserted.contains(&up)) {
|
||||
// nothing inserts whose content is the one CADCraft writes. An arrowhead block with other
|
||||
// content (AutoCAD's `_Dot`, a user's redefinition) is kept, so dimensions keep it.
|
||||
let generated_arrow = || {
|
||||
let own: Vec<&Entity> = ents.iter().map(|(_, _, e)| e).collect();
|
||||
crate::dxf_ext::is_standard_arrow(&name) && !rx.inserted.contains(&up) && crate::dxf_write::is_generated_arrow(&name, &own)
|
||||
};
|
||||
if rx.table_blocks.contains(&up) || generated_arrow() {
|
||||
continue;
|
||||
}
|
||||
let mut b = Block::new(&name);
|
||||
@@ -2143,7 +2153,12 @@ impl Objects {
|
||||
}
|
||||
}
|
||||
|
||||
/// Associativity from a DIMASSOC object (files from other writers).
|
||||
/// Associativity from a DIMASSOC object (files from other writers). The dimension kind decides
|
||||
/// which definition points the references name, in the order AutoCAD writes them: `p13`/`p14`
|
||||
/// for linear, aligned and arc-length dimensions, both ends of both lines (`p13`..`p16`) for
|
||||
/// angular dimensions, the three points of an angular-3-point dimension, `p13` for ordinate
|
||||
/// dimensions, and for radial or diametric dimensions the single circle reference
|
||||
/// ([`radial_links`]). CADCraft's own xdata links, when present, win.
|
||||
fn dimassoc(d: &mut Drawing, tags: &[Tag]) {
|
||||
let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbDimAssoc").unwrap_or(0);
|
||||
let tail = tags.get(start..).unwrap_or(&[]);
|
||||
@@ -2152,10 +2167,25 @@ fn dimassoc(d: &mut Drawing, tags: &[Tag]) {
|
||||
if !dm.assoc.is_empty() {
|
||||
return;
|
||||
}
|
||||
// A radial or diametric dimension names its circle once, by its centre.
|
||||
if matches!(dm.kind, DimKind::Radius | DimKind::Diameter) {
|
||||
let links = radial_links(d, dm, tail);
|
||||
if !links.is_empty() {
|
||||
let _ = d.modify_entity(dim_h, |e| {
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||||
dm.assoc = links;
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
let slots: &[&str] = match dm.kind {
|
||||
DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"],
|
||||
DimKind::Angular => &["p13", "p14", "p15", "p16"],
|
||||
DimKind::Angular3P => &["p13", "p14", "p15"],
|
||||
DimKind::ArcLength => &["p13", "p14"],
|
||||
DimKind::Ordinate { .. } => &["p13"],
|
||||
_ => return,
|
||||
DimKind::Radius | DimKind::Diameter => return,
|
||||
};
|
||||
let flags = T(tail).i(90).unwrap_or(0);
|
||||
let used: Vec<&str> = slots.iter().enumerate().filter(|(i, _)| flags & (1 << i) != 0).map(|(_, s)| *s).collect();
|
||||
@@ -2184,9 +2214,51 @@ fn dimassoc(d: &mut Drawing, tags: &[Tag]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Associations of a radial or diametric dimension from its one standard reference: the circle
|
||||
/// or arc it measures, by its centre (osnap 3). The definition points are then on that circle,
|
||||
/// so the dimension follows its radius or diameter.
|
||||
fn radial_links(d: &Drawing, dm: &Dimension, tail: &[Tag]) -> Vec<DimAssoc> {
|
||||
let starts: Vec<usize> = tail.iter().enumerate().filter(|(_, x)| x.code == 1 && x.str() == "AcDbOsnapPointRef").map(|(i, _)| i).take(8).collect();
|
||||
for (k, s) in starts.iter().enumerate() {
|
||||
let end = starts.get(k + 1).copied().unwrap_or(tail.len());
|
||||
let r = T(tail.get(s + 1..end).unwrap_or(&[]));
|
||||
if !matches!(r.i(72), Some(3 | 4)) {
|
||||
continue;
|
||||
}
|
||||
let Some(handle) = r.s(331).and_then(|h| Handle::parse_hex(&h)) else { continue };
|
||||
let Some(centre) = circle_centre(d, handle) else { continue };
|
||||
let on = |point: &str, p: Vec3| {
|
||||
let angle = centre.angle_to(p.xy());
|
||||
angle.is_finite().then(|| DimAssoc { point: point.into(), handle, snap: AssocSnap::OnCircle { angle } })
|
||||
};
|
||||
let links = match dm.kind {
|
||||
DimKind::Radius => on("p15", dm.p15).map(|p15| vec![DimAssoc { point: "defpt".into(), handle, snap: AssocSnap::Center }, p15]),
|
||||
DimKind::Diameter => [on("defpt", dm.defpt), on("p15", dm.p15)].into_iter().collect::<Option<Vec<_>>>(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(links) = links {
|
||||
return links;
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The planar centre of a circle or arc, or `None` for any other entity: the point a radial or
|
||||
/// diametric dimension's single DIMASSOC reference names.
|
||||
fn circle_centre(d: &Drawing, h: Handle) -> Option<Vec2> {
|
||||
match &d.entity(h)?.kind {
|
||||
EntityKind::Circle(c) => Some(c.center.xy()),
|
||||
EntityKind::Arc(a) => Some(a.center.xy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A CADCraft snap from a DIMASSOC object snap type and point, and for a polyline the segment
|
||||
/// the point is an end of: AutoCAD writes no usable point there (10/20 are 0, #1049), the segment
|
||||
/// and the osnap (1 its end, 13 its start) name the vertex.
|
||||
/// and the osnap (1 its end, 13 its start) name the vertex. For a line or arc the osnap alone
|
||||
/// says which end: 13 is the start and 1 the end, whatever the stored point (the point is
|
||||
/// 0,0,0 for curves, and choosing by distance gave the wrong end for arcs). A point entity has
|
||||
/// only one position, so both codes mean it.
|
||||
fn infer_snap(d: &Drawing, osnap: i64, main: Handle, other: Option<Handle>, at: Vec2, segment: Option<i64>) -> Option<AssocSnap> {
|
||||
let e = d.entity(main)?;
|
||||
if let (EntityKind::LwPolyline(p), Some(k), 1 | 13) = (&e.kind, segment, osnap) {
|
||||
@@ -2198,16 +2270,21 @@ fn infer_snap(d: &Drawing, osnap: i64, main: Handle, other: Option<Handle>, at:
|
||||
}
|
||||
Some(match osnap {
|
||||
1 | 13 => match &e.kind {
|
||||
// AutoCAD names a curve's start with 13 and its end with 1 (the stored point is 0,0,0).
|
||||
// Other writers use 1 for either end of a line with the point stored: take the nearer.
|
||||
EntityKind::Line(l) => {
|
||||
if osnap == 13 || l.a.xy().dist(at) <= l.b.xy().dist(at) {
|
||||
if osnap == 13 || (at != Vec2::ZERO && l.a.xy().dist(at) <= l.b.xy().dist(at)) {
|
||||
AssocSnap::Start
|
||||
} else {
|
||||
AssocSnap::End
|
||||
}
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
if osnap == 13 || g.start_point().dist(at) <= g.end_point().dist(at) { AssocSnap::Start } else { AssocSnap::End }
|
||||
EntityKind::Arc(_) => {
|
||||
if osnap == 13 {
|
||||
AssocSnap::Start
|
||||
} else {
|
||||
AssocSnap::End
|
||||
}
|
||||
}
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let index = p.vertices.iter().enumerate().min_by(|x, y| x.1.p.dist(at).total_cmp(&y.1.p.dist(at))).map(|(i, _)| i)?;
|
||||
|
||||
@@ -1424,7 +1424,7 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec<Entity> {
|
||||
attach: 5,
|
||||
rotation: g.text_angle,
|
||||
style: style.text_style.clone(),
|
||||
contents: g.value.clone(),
|
||||
contents: g.mtext_contents(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
@@ -1514,6 +1514,16 @@ fn arrow_block_entities(kind: cadcraft_render::Arrowhead) -> Vec<Entity> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the entities of the block `name` are the arrowhead block CADCraft writes for that
|
||||
/// standard arrow name ([`arrow_block_entities`]): the same lines and solids with the same
|
||||
/// properties (layer 0, ByBlock). Any other content (AutoCAD's own `_Dot`, a user's
|
||||
/// redefinition, an older CADCraft's geometry) is the file's.
|
||||
pub(crate) fn is_generated_arrow(name: &str, entities: &[&Entity]) -> bool {
|
||||
let Some((_, kind)) = dxf_ext::arrow_block_name(name) else { return false };
|
||||
let ours = arrow_block_entities(kind);
|
||||
ours.len() == entities.len() && ours.iter().zip(entities).all(|(a, b)| a.kind == b.kind && a.common == b.common)
|
||||
}
|
||||
|
||||
/// Whether a DIMASSOC point reference to `k` names a point of it without naming a segment:
|
||||
/// true for objects made of one curve. Polylines are referenced by segment ([`polyline_vertex_ref`]);
|
||||
/// 3D polylines and multilines can't be yet, and references to them are left out, as AutoCAD
|
||||
@@ -1549,20 +1559,34 @@ pub(crate) fn polyline_vertex_ref(p: &LwPolyline, index: usize) -> Option<(i64,
|
||||
}
|
||||
|
||||
/// Standard DIMASSOC point references of an associative dimension: the extension-line
|
||||
/// origins of linear/aligned dimensions and the feature point of ordinate dimensions, linked
|
||||
/// origins of linear/aligned dimensions, both ends of both lines of an angular dimension, the
|
||||
/// feature point of ordinate dimensions and the circle of a radial/diametric dimension, linked
|
||||
/// to objects that exist ([`whole_object_ref`]). CADCraft's exact links travel in xdata as well.
|
||||
fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec<OsnapRef>) {
|
||||
if matches!(dm.kind, DimKind::Radius | DimKind::Diameter) {
|
||||
return radial_ref(d, &dm.assoc);
|
||||
}
|
||||
let slots: &[&str] = match dm.kind {
|
||||
DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"],
|
||||
DimKind::Angular => &["p13", "p14", "p15", "p16"],
|
||||
DimKind::Angular3P => &["p13", "p14", "p15"],
|
||||
// Saved as an aligned dimension (`dim_xdata` marks it): its extension origins are the arc ends.
|
||||
DimKind::ArcLength => &["p13", "p14"],
|
||||
DimKind::Ordinate { .. } => &["p13"],
|
||||
_ => &[],
|
||||
DimKind::Radius | DimKind::Diameter => &[],
|
||||
};
|
||||
let mut flags = 0;
|
||||
let mut refs = Vec::new();
|
||||
for (slot, name) in slots.iter().enumerate() {
|
||||
let Some(a) = dm.assoc.iter().find(|a| a.point == *name) else { continue };
|
||||
let Some(ent) = d.entity(a.handle) else { continue };
|
||||
let point = if *name == "p13" { dm.p13 } else { dm.p14 };
|
||||
let point = match *name {
|
||||
"p13" => dm.p13,
|
||||
"p14" => dm.p14,
|
||||
"p15" => dm.p15,
|
||||
"p16" => dm.p16,
|
||||
_ => continue,
|
||||
};
|
||||
if let EntityKind::LwPolyline(p) = &ent.kind {
|
||||
let index = match &a.snap {
|
||||
AssocSnap::Vertex { index } => Some(*index),
|
||||
@@ -1579,7 +1603,10 @@ fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec<OsnapRef>) {
|
||||
continue;
|
||||
}
|
||||
let (osnap, param, other) = match &a.snap {
|
||||
AssocSnap::Start | AssocSnap::End | AssocSnap::Vertex { .. } => (1, 0.0, None),
|
||||
// AutoCAD marks a curve's start with osnap 13 and its end with osnap 1 (group 72).
|
||||
AssocSnap::Start => (if matches!(ent.kind, EntityKind::Point(_)) { 1 } else { 13 }, 0.0, None),
|
||||
AssocSnap::End => (1, 1.0, None),
|
||||
AssocSnap::Vertex { .. } => (1, 0.0, None),
|
||||
AssocSnap::Mid => (2, 0.0, None),
|
||||
AssocSnap::Center => (if matches!(ent.kind, EntityKind::Point(_)) { 4 } else { 3 }, 0.0, None),
|
||||
AssocSnap::OnCircle { angle } => (10, *angle, None),
|
||||
@@ -1596,6 +1623,27 @@ fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec<OsnapRef>) {
|
||||
(flags, refs)
|
||||
}
|
||||
|
||||
/// The single standard reference of a radial or diametric dimension: the circle or arc it
|
||||
/// measures, by its centre, with associativity flag 2. AutoCAD writes radial and diametric
|
||||
/// dimensions that way: the one reference names the geometry, whose radius or diameter the
|
||||
/// dimension then follows. Returns no flags and no references when the association does not name
|
||||
/// a circle or arc, so such a dimension is saved unassociated rather than pointing at nothing.
|
||||
fn radial_ref(d: &Drawing, assoc: &[DimAssoc]) -> (i64, Vec<OsnapRef>) {
|
||||
for a in assoc {
|
||||
let Some(ent) = d.entity(a.handle) else { continue };
|
||||
let centre = match &ent.kind {
|
||||
EntityKind::Circle(c) => c.center.xy(),
|
||||
EntityKind::Arc(arc) => arc.center.xy(),
|
||||
_ => continue,
|
||||
};
|
||||
if !centre.is_finite() {
|
||||
continue;
|
||||
}
|
||||
return (2, vec![OsnapRef { osnap: 3, main: a.handle, segment: 0, param: 0.0, point: centre.to3(0.0), other: None }]);
|
||||
}
|
||||
(0, Vec::new())
|
||||
}
|
||||
|
||||
/// Layouts with viewports but without the paper-space viewport (id 1, the sheet itself), keyed
|
||||
/// by their first viewport. AutoCAD and DWG take a layout's first viewport as its paper-space
|
||||
/// viewport (DWG does not store viewport ids; readers number them in order), so a layout made in
|
||||
@@ -1619,6 +1667,15 @@ fn paper_views(d: &Drawing) -> HashMap<Handle, usize> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A layout's entities with its paper-space viewport (id 1) first. DWG stores no viewport ids and
|
||||
/// readers number a layout's viewports in order, so the sheet must lead even when the draw order
|
||||
/// (SORTENTS) put it behind a model viewport; otherwise that viewport becomes the sheet.
|
||||
fn sheet_first(l: &Layout) -> Vec<&Entity> {
|
||||
let sheet = |e: &&Entity| matches!(&e.kind, EntityKind::Viewport(v) if v.id == 1);
|
||||
let all = || l.entities.iter().map(|e| e.as_ref());
|
||||
all().filter(sheet).chain(all().filter(|e| !sheet(e))).collect()
|
||||
}
|
||||
|
||||
/// The paper-space viewport (id 1) of the layout whose first viewport is `e`, if it needs one
|
||||
/// ([`paper_views`]): the whole sheet, viewed 1:1. Marked with CADCraft xdata so that our reader
|
||||
/// leaves it implicit again.
|
||||
@@ -1752,8 +1809,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
|
||||
reactors: hatch_reactors(d),
|
||||
};
|
||||
// Pre-allocate handles for block records.
|
||||
let mut paper_layouts: Vec<&Layout> = d.layouts.iter().collect();
|
||||
paper_layouts.sort_by_key(|l| l.tab_order);
|
||||
let paper_layouts = d.paper_space_layouts();
|
||||
let ms_br = w.h();
|
||||
// Xrefs are saved as references: without their objects and the symbols that came with them.
|
||||
let saved_view = crate::dxf_xref::saved_view(d);
|
||||
@@ -2331,7 +2387,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
|
||||
if i == 0 {
|
||||
block(&mut w, n, "", h, Vec3::ZERO, 0, &mut std::iter::empty(), true);
|
||||
} else {
|
||||
block(&mut w, n, "", h, Vec3::ZERO, 0, &mut l.entities.iter().map(|e| e.as_ref()), true);
|
||||
block(&mut w, n, "", h, Vec3::ZERO, 0, &mut sheet_first(l).into_iter(), true);
|
||||
}
|
||||
}
|
||||
for (n, h, b) in &user_blocks {
|
||||
@@ -2362,7 +2418,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
|
||||
entity(&mut w, d, e, &ms_br, false, &cx);
|
||||
}
|
||||
if let Some((_, h, l)) = ps_brs.first() {
|
||||
for e in l.entities.iter() {
|
||||
for e in sheet_first(l) {
|
||||
entity(&mut w, d, e, h, true, &cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::*;
|
||||
use cadcraft_doc::{Circle, Common, Drawing, Ellipse, EntityKind, Layer, Line, LwPolyline, Space};
|
||||
use cadcraft_geom::{Mat3, PolyVertex, Spline, Vec2, Vec3};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::XmlVersion;
|
||||
@@ -527,7 +527,7 @@ impl Parser {
|
||||
} else if *rx > 0.0 && *ry > 0.0 {
|
||||
self.flush_run(&mut run, false)?;
|
||||
self.flush_ctrl(&mut ctrl)?;
|
||||
for p in flatten_arc(&m, cur_user, *end, *rx, *ry, *rot, *large, *sweep)? {
|
||||
for p in flatten_arc(&m, cur_user, *end, (*rx, *ry), *rot, *large, *sweep)? {
|
||||
if run.is_empty() {
|
||||
run.push(PolyVertex::new(cur));
|
||||
}
|
||||
@@ -620,8 +620,15 @@ fn viewport(e: &BytesStart) -> Result<Mat3> {
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
let w_mm = attr(e, b"width").as_deref().and_then(parse_length_mm);
|
||||
let h_mm = attr(e, b"height").as_deref().and_then(parse_length_mm);
|
||||
// A bare or `px` root size is in CSS pixels, not millimetres.
|
||||
let root_mm = |name: &[u8]| {
|
||||
let raw = attr(e, name)?;
|
||||
let mm = parse_length_mm(&raw)?;
|
||||
let unit = raw.trim().trim_start_matches(|c: char| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')).trim();
|
||||
Some(if matches!(unit, "" | "px") { mm * 25.4 / 96.0 } else { mm })
|
||||
};
|
||||
let w_mm = root_mm(b"width");
|
||||
let h_mm = root_mm(b"height");
|
||||
let unit = match (vb, w_mm, h_mm) {
|
||||
(Some((_, _, vw, _)), Some(w), _) if w > 0.0 => w / vw,
|
||||
(Some((_, _, _, vh)), _, Some(h)) if h > 0.0 => h / vh,
|
||||
@@ -756,17 +763,33 @@ enum Tok {
|
||||
struct Tokenizer<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
/// Start of the most recently returned token.
|
||||
tok_start: usize,
|
||||
}
|
||||
|
||||
impl<'a> Tokenizer<'a> {
|
||||
fn new(s: &'a str) -> Self {
|
||||
Tokenizer { s: s.as_bytes(), i: 0 }
|
||||
Tokenizer { s: s.as_bytes(), i: 0, tok_start: 0 }
|
||||
}
|
||||
|
||||
/// Read an arc flag: a single `0` or `1` character, which needs no separator after it.
|
||||
fn next_flag(&mut self) -> Option<f64> {
|
||||
let mut nums = Numbers { s: self.s, i: self.i };
|
||||
nums.skip_seps();
|
||||
let v = match self.s.get(nums.i)? {
|
||||
b'0' => 0.0,
|
||||
b'1' => 1.0,
|
||||
_ => return None,
|
||||
};
|
||||
self.i = nums.i + 1;
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn next_tok(&mut self) -> Option<Tok> {
|
||||
let mut nums = Numbers { s: self.s, i: self.i };
|
||||
nums.skip_seps();
|
||||
self.i = nums.i;
|
||||
self.tok_start = self.i;
|
||||
let c = self.s.get(self.i).copied()?;
|
||||
if c.is_ascii_alphabetic() {
|
||||
self.i += 1;
|
||||
@@ -843,7 +866,18 @@ fn parse_path(d: &str) -> Result<Vec<Subpath>> {
|
||||
_ => return Err(IoError::Format(format!("SVG: unknown path command `{}`", cmd as char))),
|
||||
};
|
||||
let mut args = [0.0f64; 7];
|
||||
for slot in args.iter_mut().take(count) {
|
||||
for (idx, slot) in args.iter_mut().enumerate().take(count) {
|
||||
if abs == b'A' && (idx == 3 || idx == 4) {
|
||||
// Flags are single characters; re-scan a pending number token from its start.
|
||||
if matches!(lookahead, Some(Tok::Num(_))) {
|
||||
lookahead = None;
|
||||
tk.i = tk.tok_start;
|
||||
}
|
||||
if lookahead.is_none() {
|
||||
*slot = tk.next_flag().ok_or_else(|| IoError::Format("SVG: path command is missing coordinates".into()))?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
*slot = next_num(&mut tk, &mut lookahead)?;
|
||||
}
|
||||
let point = |x: f64, y: f64| if rel { cur + Vec2::new(x, y) } else { Vec2::new(x, y) };
|
||||
@@ -970,7 +1004,7 @@ fn pick_sweep(ccw: f64, large: bool, sweep: bool) -> f64 {
|
||||
|
||||
/// Flatten a general elliptical arc (any `rx`/`ry`/rotation) from `p0` to `p1` in user
|
||||
/// space, then map every sample through `m`.
|
||||
fn flatten_arc(m: &Mat3, p0: Vec2, p1: Vec2, rx: f64, ry: f64, rot_deg: f64, large: bool, sweep: bool) -> Result<Vec<Vec2>> {
|
||||
fn flatten_arc(m: &Mat3, p0: Vec2, p1: Vec2, (rx, ry): (f64, f64), rot_deg: f64, large: bool, sweep: bool) -> Result<Vec<Vec2>> {
|
||||
let (c, theta, delta) = elliptical_params(p0, p1, rx, ry, rot_deg.to_radians(), large, sweep)
|
||||
.ok_or_else(|| IoError::Format("SVG: elliptical arc cannot be converted".into()))?;
|
||||
let axes = Mat3::rotate(rot_deg.to_radians());
|
||||
@@ -1030,3 +1064,25 @@ fn elliptical_params(p0: Vec2, p1: Vec2, rx: f64, ry: f64, phi: f64, large: bool
|
||||
let ccw = angle(ux, uy, vx, vy).rem_euclid(std::f64::consts::TAU);
|
||||
Some((center, theta, pick_sweep(ccw, large, sweep)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn arc_of(d: &str) -> (bool, bool, Vec2) {
|
||||
let subs = parse_path(d).unwrap_or_else(|e| panic!("{d}: {e:?}"));
|
||||
match &subs[0].segs[0] {
|
||||
Seg::Arc { large, sweep, end, .. } => (*large, *sweep, *end),
|
||||
_ => panic!("not an arc"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_arc_flags_read_as_single_characters() {
|
||||
let spaced = arc_of("M10 10 a20 20 0 0 1 20 20");
|
||||
assert_eq!(arc_of("M10 10 a20 20 0 01 20 20"), spaced);
|
||||
assert_eq!(arc_of("M10 10 a20 20 0 0120 20"), spaced);
|
||||
assert_eq!(arc_of("M10 10 a20 20 0 1 0 20 20"), (true, false, spaced.2));
|
||||
assert!(arc_of("M10 10 a20 20 0 10-20 20").0);
|
||||
}
|
||||
}
|
||||
|
||||
+211
-1
@@ -876,6 +876,62 @@ fn user_block_arrowheads_are_referenced_not_regenerated() {
|
||||
assert_eq!(back.blocks.len(), d.blocks.len());
|
||||
}
|
||||
|
||||
/// Every standard arrowhead CADCraft generates reads back as its own geometry, through DXF and
|
||||
/// DWG, so reopening a drawing CADCraft wrote never turns them into user blocks.
|
||||
#[test]
|
||||
fn generated_arrowhead_blocks_are_not_kept_as_user_blocks() {
|
||||
let mut d = Drawing::new_metric();
|
||||
for (i, (_, name)) in crate::dxf_ext::ARROWS.iter().enumerate() {
|
||||
d.dim_styles.push(DimStyle { name: format!("S{i}"), arrow_block: (*name).into(), ..DimStyle::default() });
|
||||
}
|
||||
let dxf = write_dxf(&d);
|
||||
for (_, name) in crate::dxf_ext::ARROWS.iter().filter(|(k, _)| *k != cadcraft_render::Arrowhead::ClosedFilled) {
|
||||
assert!(dxf.contains(&format!("\r\n{name}\r\n")), "{name} is written");
|
||||
}
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
let from_dwg = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
for (back, what) in [(read_dxf(dxf.as_bytes()).unwrap(), "DXF"), (from_dwg, "DWG")] {
|
||||
assert!(back.blocks.keys().all(|k| !k.starts_with('_')), "{what}: {:?}", back.blocks.keys().collect::<Vec<_>>());
|
||||
assert!(roundtrip(&back).blocks.keys().all(|k| !k.starts_with('_')), "{what}: second save");
|
||||
}
|
||||
}
|
||||
|
||||
/// A file's own arrowhead block with a standard name (AutoCAD's `_Dot` is a closed polyline
|
||||
/// with width, not CADCraft's disc of solids) is kept when only a dimension style names it, and
|
||||
/// saved as it was, through DXF and DWG, instead of being replaced by CADCraft's geometry.
|
||||
#[test]
|
||||
fn file_arrowhead_blocks_with_standard_names_are_kept() {
|
||||
let mut d = Drawing::new_metric();
|
||||
let donut = LwPolyline {
|
||||
vertices: vec![
|
||||
PolyVertex { p: Vec2::new(-0.25, 0.0), bulge: 1.0, start_width: 0.5, end_width: 0.5 },
|
||||
PolyVertex { p: Vec2::new(0.25, 0.0), bulge: 1.0, start_width: 0.5, end_width: 0.5 },
|
||||
],
|
||||
closed: true,
|
||||
const_width: 0.0,
|
||||
elevation: 0.0,
|
||||
plinegen: false,
|
||||
};
|
||||
for (name, kind) in [("_Dot", EntityKind::LwPolyline(donut)), ("_DotSmall", EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 0.0625 }))] {
|
||||
let mut b = Block::new(name);
|
||||
b.entities.push(Entity::new(Handle(0), kind));
|
||||
d.blocks.insert(name.into(), std::sync::Arc::new(b));
|
||||
}
|
||||
d.dim_styles.push(DimStyle { name: "Dots".into(), arrow_block1: "_Dot".into(), arrow_block2: "_DotSmall".into(), ..DimStyle::default() });
|
||||
let kinds = |d: &Drawing, name: &str| d.block(name).map(|b| b.entities.iter().map(|e| e.kind.clone()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
let dxf = write_dxf(&d);
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
let from_dwg = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
for (back, what) in [(read_dxf(dxf.as_bytes()).unwrap(), "DXF"), (from_dwg, "DWG")] {
|
||||
for name in ["_Dot", "_DotSmall"] {
|
||||
assert_eq!(kinds(&back, name), kinds(&d, name), "{what}: {name}");
|
||||
}
|
||||
let again = write_dxf(&back);
|
||||
assert!(!again.contains("\nSOLID\r"), "{what}: no arrowhead geometry of CADCraft's is written");
|
||||
assert_eq!(kinds(&roundtrip(&back), "_Dot"), kinds(&d, "_Dot"), "{what}: a second save keeps it too");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_overrides_roundtrip_as_dstyle_xdata() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
@@ -1017,6 +1073,11 @@ fn damaged_dwg_opens_partially_and_says_what_it_left_out() {
|
||||
assert!(partial > 0, "no damaged copy read partially");
|
||||
}
|
||||
|
||||
/// A dimension's standard DIMASSOC objects carry the same associations as CADCraft's xdata, so
|
||||
/// a file from another writer (xdata stripped) still follows its geometry: a linear dimension's
|
||||
/// two origins, a polyline's vertex named by its segment (#1049), and since #1651 a radial
|
||||
/// dimension's circle, whose centre comes back as a `Center` link and its rim point as
|
||||
/// `OnCircle`.
|
||||
#[test]
|
||||
fn dimension_associativity_roundtrips() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
@@ -1088,7 +1149,10 @@ fn dimension_associativity_roundtrips() {
|
||||
assert_eq!(fa.len(), 2);
|
||||
assert_eq!(fa[1], ali.assoc[1], "the polyline vertex, named by its segment (#1049)");
|
||||
assert!(matches!(fa[0].snap, AssocSnap::OnCircle { angle } if angle.abs() < 1e-9));
|
||||
assert!(assoc_of(&foreign, hr).is_empty(), "radial links are CADCraft-only");
|
||||
let fr = assoc_of(&foreign, hr);
|
||||
assert_eq!(fr.len(), 2, "a radial dimension's circle comes from the standard DIMASSOC");
|
||||
assert_eq!(fr[0], rad.assoc[0], "the centre");
|
||||
assert!(matches!(fr[1].snap, AssocSnap::OnCircle { angle } if angle.is_finite()));
|
||||
}
|
||||
|
||||
/// #1049: a polyline vertex is referenced as AutoCAD does: an end of one of its segments,
|
||||
@@ -2218,6 +2282,18 @@ fn svg_viewbox_scaling_and_mm_units() {
|
||||
assert!((c.center.x - 10.0).abs() < 1e-9 && (c.center.y - 5.0).abs() < 1e-9, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_viewbox_with_px_or_unitless_root_size_uses_reference_pixel() {
|
||||
for w in ["800", "800px"] {
|
||||
let svg = format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="600" viewBox="0 0 400 300"><line x1="0" y1="0" x2="400" y2="0"/></svg>"#
|
||||
);
|
||||
let d = read(svg.as_bytes(), "vb.svg").unwrap();
|
||||
let l = first(&d, |k| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None });
|
||||
assert!((l.b.x - 800.0 * 25.4 / 96.0).abs() < 1e-9, "{w}: {l:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_inkscape_layers_are_kept() {
|
||||
let svg = format!(
|
||||
@@ -2539,3 +2615,137 @@ fn drawing_without_insunits_stays_unitless() {
|
||||
assert_eq!(insunits(&write_dxf(&Drawing::new_metric())), Some("4".into()));
|
||||
assert_eq!(insunits(&write_dxf(&Drawing::new_imperial())), Some("1".into()));
|
||||
}
|
||||
|
||||
/// DXF text as (code, value) pairs.
|
||||
fn dxf_pairs(text: &str) -> Vec<(String, String)> {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
lines.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0].trim().to_string(), c[1].trim().to_string())).collect()
|
||||
}
|
||||
|
||||
/// The records of type `kind` in DXF pairs, each as its groups after the type.
|
||||
fn dxf_records<'a>(pairs: &'a [(String, String)], kind: &str) -> Vec<&'a [(String, String)]> {
|
||||
let mut out = Vec::new();
|
||||
for (i, p) in pairs.iter().enumerate() {
|
||||
if p.0 == "0" && p.1 == kind {
|
||||
let rest = &pairs[i + 1..];
|
||||
let end = rest.iter().position(|q| q.0 == "0").unwrap_or(rest.len());
|
||||
out.push(&rest[..end]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn group<'a>(rec: &'a [(String, String)], code: &str) -> Option<&'a str> {
|
||||
rec.iter().find(|p| p.0 == code).map(|p| p.1.as_str())
|
||||
}
|
||||
|
||||
/// #1812: a file whose CLASSES start with VISUALSTYLE (500) and WIDGETX (501), with proxy objects
|
||||
/// of both classes and of a number past its last class, and a proxy entity of WIDGETX.
|
||||
fn proxy_file() -> Vec<u8> {
|
||||
let text = String::from_utf8(write(&Drawing::new_metric(), "p.dxf").unwrap()).unwrap();
|
||||
let mut pairs = dxf_pairs(&text);
|
||||
let p = |c: &str, v: &str| (c.to_string(), v.to_string());
|
||||
let class = |name: &str, cpp: &str| {
|
||||
vec![p("0", "CLASS"), p("1", name), p("2", cpp), p("3", "ObjectDBX Classes"), p("90", "4095"), p("280", "1"), p("281", "0")]
|
||||
};
|
||||
let classes = pairs.iter().position(|q| q.0 == "2" && q.1 == "CLASSES").unwrap() + 1;
|
||||
let mut injected = class("VISUALSTYLE", "AcDbVisualStyle");
|
||||
injected.extend(class("WIDGETX", "AcDbWidgetX"));
|
||||
pairs.splice(classes..classes, injected);
|
||||
let objects = pairs.iter().position(|q| q.0 == "2" && q.1 == "OBJECTS").unwrap();
|
||||
let nod = objects + 1;
|
||||
let nod_handle = pairs[nod + 1].1.clone();
|
||||
let after = nod + pairs[nod..].iter().position(|q| q.0 == "281").unwrap() + 1;
|
||||
pairs.splice(after..after, [p("3", "ACAD_VISUALSTYLE"), p("350", "AAA0")]);
|
||||
let proxy = |h: &str, class: &str, data: &str| {
|
||||
vec![
|
||||
p("0", "ACAD_PROXY_OBJECT"),
|
||||
p("5", h),
|
||||
// An application's 102 group before the proxy's own groups holds a 91 of its own.
|
||||
p("102", "{MYAPP"),
|
||||
p("91", "7"),
|
||||
p("102", "}"),
|
||||
p("330", "AAA0"),
|
||||
p("100", "AcDbProxyObject"),
|
||||
p("90", "499"),
|
||||
p("91", class),
|
||||
p("95", "0"),
|
||||
p("70", "0"),
|
||||
p("92", "8"),
|
||||
p("310", data),
|
||||
p("94", "0"),
|
||||
]
|
||||
};
|
||||
let mut objs = vec![
|
||||
p("0", "DICTIONARY"),
|
||||
p("5", "AAA0"),
|
||||
p("330", &nod_handle),
|
||||
p("100", "AcDbDictionary"),
|
||||
p("281", "1"),
|
||||
p("3", "Realistic"),
|
||||
p("350", "AAA1"),
|
||||
p("3", "Widget"),
|
||||
p("350", "AAA2"),
|
||||
p("3", "Stray"),
|
||||
p("350", "AAA3"),
|
||||
];
|
||||
objs.extend(proxy("AAA1", "500", "AA"));
|
||||
objs.extend(proxy("AAA2", "501", "BB"));
|
||||
objs.extend(proxy("AAA3", "537", "CC"));
|
||||
let end = objects + pairs[objects..].iter().position(|q| q.0 == "0" && q.1 == "ENDSEC").unwrap();
|
||||
pairs.splice(end..end, objs);
|
||||
let ms = {
|
||||
let recs = dxf_records(&pairs, "BLOCK_RECORD");
|
||||
group(recs.iter().find(|r| group(r, "2") == Some("*Model_Space")).unwrap(), "5").unwrap().to_string()
|
||||
};
|
||||
let ents = pairs.iter().position(|q| q.0 == "2" && q.1 == "ENTITIES").unwrap() + 1;
|
||||
pairs.splice(
|
||||
ents..ents,
|
||||
[
|
||||
p("0", "ACAD_PROXY_ENTITY"),
|
||||
p("5", "AAA4"),
|
||||
p("330", &ms),
|
||||
p("100", "AcDbEntity"),
|
||||
p("8", "0"),
|
||||
p("100", "AcDbProxyEntity"),
|
||||
p("90", "498"),
|
||||
p("91", "501"),
|
||||
p("92", "0"),
|
||||
p("93", "0"),
|
||||
p("94", "0"),
|
||||
p("95", "0"),
|
||||
p("70", "0"),
|
||||
],
|
||||
);
|
||||
pairs.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect::<String>().into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxies_keep_their_class_when_classes_are_rewritten() {
|
||||
let d = read(&proxy_file(), "p.dxf").unwrap();
|
||||
let text = String::from_utf8(write(&d, "out.dxf").unwrap()).unwrap();
|
||||
let pairs = dxf_pairs(&text);
|
||||
let classes: Vec<String> = dxf_records(&pairs, "CLASS").iter().map(|r| group(r, "1").unwrap().to_string()).collect();
|
||||
let class_of = |n: &str| classes.get(n.parse::<usize>().unwrap() - 500).cloned();
|
||||
// The proxy's own 91 (after its subclass marker); the application group's stays as it was.
|
||||
let own_91 = |r: &[(String, String)]| r.iter().skip_while(|q| q.1 != "AcDbProxyObject").find(|q| q.0 == "91").map(|q| q.1.clone()).unwrap();
|
||||
let objects: Vec<(String, Option<String>)> =
|
||||
dxf_records(&pairs, "ACAD_PROXY_OBJECT").iter().map(|r| (group(r, "310").unwrap().to_string(), class_of(&own_91(r)))).collect();
|
||||
assert!(dxf_records(&pairs, "ACAD_PROXY_OBJECT").iter().all(|r| group(r, "91") == Some("7")), "the application's 91 changed");
|
||||
assert_eq!(
|
||||
objects,
|
||||
[("AA".to_string(), Some("VISUALSTYLE".to_string())), ("BB".to_string(), Some("WIDGETX".to_string()))],
|
||||
"classes {classes:?}"
|
||||
);
|
||||
let entities: Vec<Option<String>> = dxf_records(&pairs, "ACAD_PROXY_ENTITY").iter().map(|r| class_of(group(r, "91").unwrap())).collect();
|
||||
assert_eq!(entities, [Some("WIDGETX".to_string())]);
|
||||
// The proxy whose number named no class is gone, with its dictionary entry.
|
||||
assert!(!text.contains("\nStray\n"));
|
||||
// And it stays so through another save.
|
||||
let again = String::from_utf8(write(&read(text.as_bytes(), "out.dxf").unwrap(), "again.dxf").unwrap()).unwrap();
|
||||
let pairs = dxf_pairs(&again);
|
||||
let classes: Vec<String> = dxf_records(&pairs, "CLASS").iter().map(|r| group(r, "1").unwrap().to_string()).collect();
|
||||
let names: Vec<String> =
|
||||
dxf_records(&pairs, "ACAD_PROXY_OBJECT").iter().map(|r| classes[own_91(r).parse::<usize>().unwrap() - 500].clone()).collect();
|
||||
assert_eq!(names, ["VISUALSTYLE", "WIDGETX"]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//! #1651: angular, diameter and radius dimensions are saved with a DIMASSOC object, as linear
|
||||
//! and ordinate dimensions already are, so AutoCAD follows their geometry. The standard objects
|
||||
//! alone (CADCraft xdata stripped) must associate the same points, in DXF and in DWG.
|
||||
|
||||
use cadcraft_doc::{AssocSnap, Circle, Common, DimAssoc, DimKind, Dimension, Drawing, EntityKind, Handle, Line, Space};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{Version, read, read_dxf, write_dxf};
|
||||
|
||||
/// A dimension entity of the given kind with all its definition points and associations, for
|
||||
/// adding straight to a drawing; the style and text are left at defaults since only the
|
||||
/// geometry and associations are under test.
|
||||
fn dim(kind: DimKind, defpt: Vec3, p13: Vec3, p14: Vec3, p15: Vec3, p16: Vec3, assoc: Vec<DimAssoc>) -> EntityKind {
|
||||
EntityKind::Dimension(Dimension {
|
||||
kind,
|
||||
defpt,
|
||||
text_mid: Vec3::ZERO,
|
||||
p13,
|
||||
p14,
|
||||
p15,
|
||||
p16,
|
||||
text: String::new(),
|
||||
style: "Standard".into(),
|
||||
measurement: 0.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: None,
|
||||
overrides: Default::default(),
|
||||
assoc,
|
||||
marks: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The drawing of the issue's repro: a circle with a diameter and a radius dimension, two lines
|
||||
/// with an angular dimension between them, and a line with a linear dimension. The associations
|
||||
/// are the ones the commands make (`assoc::radial_assoc` and `assoc::reassociate`).
|
||||
struct Case {
|
||||
d: Drawing,
|
||||
circle: Handle,
|
||||
l1: Handle,
|
||||
l2: Handle,
|
||||
hd: Handle,
|
||||
hr: Handle,
|
||||
ha: Handle,
|
||||
hl: Handle,
|
||||
}
|
||||
|
||||
fn case() -> Case {
|
||||
let mut d = Drawing::new_metric();
|
||||
let c = Common::default;
|
||||
let circle = d.add(&Space::Model, c(), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 30.0 })).unwrap();
|
||||
let l1 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::new(100.0, 0.0, 0.0), b: Vec3::new(200.0, 0.0, 0.0) })).unwrap();
|
||||
let l2 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::new(100.0, 0.0, 0.0), b: Vec3::new(180.0, 60.0, 0.0) })).unwrap();
|
||||
let l3 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::new(0.0, -80.0, 0.0), b: Vec3::new(100.0, -80.0, 0.0) })).unwrap();
|
||||
|
||||
let on = |point: &str, angle: f64| DimAssoc { point: point.into(), handle: circle, snap: AssocSnap::OnCircle { angle } };
|
||||
let diameter = dim(
|
||||
DimKind::Diameter,
|
||||
Vec3::new(30.0, 0.0, 0.0),
|
||||
Vec3::ZERO,
|
||||
Vec3::ZERO,
|
||||
Vec3::new(-30.0, 0.0, 0.0),
|
||||
Vec3::ZERO,
|
||||
vec![on("defpt", 0.0), on("p15", std::f64::consts::PI)],
|
||||
);
|
||||
let side = 30.0 / std::f64::consts::SQRT_2;
|
||||
let radius = dim(
|
||||
DimKind::Radius,
|
||||
Vec3::ZERO,
|
||||
Vec3::ZERO,
|
||||
Vec3::ZERO,
|
||||
Vec3::new(-side, -side, 0.0),
|
||||
Vec3::ZERO,
|
||||
vec![DimAssoc { point: "defpt".into(), handle: circle, snap: AssocSnap::Center }, on("p15", 225f64.to_radians())],
|
||||
);
|
||||
let angular = dim(
|
||||
DimKind::Angular3P,
|
||||
Vec3::new(160.0, 20.0, 0.0),
|
||||
Vec3::new(200.0, 0.0, 0.0),
|
||||
Vec3::new(180.0, 60.0, 0.0),
|
||||
Vec3::new(100.0, 0.0, 0.0),
|
||||
Vec3::ZERO,
|
||||
vec![
|
||||
DimAssoc { point: "p13".into(), handle: l1, snap: AssocSnap::End },
|
||||
DimAssoc { point: "p14".into(), handle: l2, snap: AssocSnap::End },
|
||||
DimAssoc { point: "p15".into(), handle: l1, snap: AssocSnap::Intersection { other: l2 } },
|
||||
],
|
||||
);
|
||||
let linear = dim(
|
||||
DimKind::Linear { rotation: 0.0 },
|
||||
Vec3::new(50.0, -100.0, 0.0),
|
||||
Vec3::new(0.0, -80.0, 0.0),
|
||||
Vec3::new(100.0, -80.0, 0.0),
|
||||
Vec3::ZERO,
|
||||
Vec3::ZERO,
|
||||
vec![
|
||||
DimAssoc { point: "p13".into(), handle: l3, snap: AssocSnap::Start },
|
||||
DimAssoc { point: "p14".into(), handle: l3, snap: AssocSnap::End },
|
||||
],
|
||||
);
|
||||
let hd = d.add(&Space::Model, Common::default(), diameter).unwrap();
|
||||
let hr = d.add(&Space::Model, Common::default(), radius).unwrap();
|
||||
let ha = d.add(&Space::Model, Common::default(), angular).unwrap();
|
||||
let hl = d.add(&Space::Model, Common::default(), linear).unwrap();
|
||||
Case { d, circle, l1, l2, hd, hr, ha, hl }
|
||||
}
|
||||
|
||||
/// The DXF tag pairs, `(code, value)`.
|
||||
fn pairs(text: &str) -> Vec<(i32, String)> {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut out = Vec::new();
|
||||
for chunk in lines.chunks(2) {
|
||||
let [code, value] = chunk else { break };
|
||||
let Ok(c) = code.trim().parse::<i32>() else { continue };
|
||||
out.push((c, value.trim().to_string()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The records of a DXF (0-group name and the groups that follow it).
|
||||
fn records(text: &str) -> Vec<Vec<(i32, String)>> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<Vec<(i32, String)>> = None;
|
||||
for (c, v) in pairs(text) {
|
||||
if c == 0 {
|
||||
if let Some(r) = cur.take() {
|
||||
out.push(r);
|
||||
}
|
||||
cur = Some(vec![(c, v)]);
|
||||
} else if let Some(r) = cur.as_mut() {
|
||||
r.push((c, v));
|
||||
}
|
||||
}
|
||||
if let Some(r) = cur {
|
||||
out.push(r);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The DIMASSOC object of a dimension, if the file has one.
|
||||
fn dimassoc(text: &str, dim: Handle) -> Option<Vec<(i32, String)>> {
|
||||
let hex = dim.hex();
|
||||
records(text)
|
||||
.into_iter()
|
||||
.filter(|r| r.first().is_some_and(|(c, v)| *c == 0 && v == "DIMASSOC"))
|
||||
.find(|r| r.iter().rfind(|(c, _)| *c == 330).is_some_and(|(_, v)| v.eq_ignore_ascii_case(&hex)))
|
||||
}
|
||||
|
||||
/// The AcDbOsnapPointRef blocks of a DIMASSOC record.
|
||||
fn point_refs(rec: &[(i32, String)]) -> Vec<Vec<(i32, String)>> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<Vec<(i32, String)>> = None;
|
||||
for (c, v) in rec {
|
||||
if *c == 1 && v == "AcDbOsnapPointRef" {
|
||||
if let Some(r) = cur.take() {
|
||||
out.push(r);
|
||||
}
|
||||
cur = Some(Vec::new());
|
||||
} else if let Some(r) = cur.as_mut() {
|
||||
r.push((*c, v.clone()));
|
||||
}
|
||||
}
|
||||
if let Some(r) = cur {
|
||||
out.push(r);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The first integer value of a group code in a record (72 the osnap type, 90 the flags).
|
||||
fn int(rec: &[(i32, String)], code: i32) -> Option<i64> {
|
||||
rec.iter().find(|(c, _)| *c == code).and_then(|(_, v)| v.parse().ok())
|
||||
}
|
||||
|
||||
/// The first string value of a group code in a record (331 the referenced entity, 332 the other
|
||||
/// entity of an intersection).
|
||||
fn string(rec: &[(i32, String)], code: i32) -> Option<&str> {
|
||||
rec.iter().find(|(c, _)| *c == code).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// A DXF without CADCraft's xdata, as a file from another writer (whose DIMASSOC objects must
|
||||
/// stand on their own).
|
||||
fn without_cadcraft_xdata(text: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut skipping = false;
|
||||
for (c, v) in pairs(text) {
|
||||
if c == 1001 {
|
||||
skipping = v == "CADCRAFT";
|
||||
} else if c < 1000 {
|
||||
skipping = false;
|
||||
}
|
||||
if !skipping {
|
||||
out.push_str(&format!("{c}\n{v}\n"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The associations a read-back dimension carries, or none if the handle is not a dimension.
|
||||
fn assoc_of(d: &Drawing, h: Handle) -> Vec<DimAssoc> {
|
||||
match d.entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Dimension(dm)) => dm.assoc.clone(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two angles name the same direction, allowing for a whole turn and rounding.
|
||||
fn same_angle(a: f64, b: f64) -> bool {
|
||||
let turn = std::f64::consts::TAU;
|
||||
let d = (a - b).rem_euclid(turn);
|
||||
d.min(turn - d) < 1e-6
|
||||
}
|
||||
|
||||
/// The associations the standard objects give, without CADCraft's xdata.
|
||||
fn assert_standard_assoc(c: &Case, d: &Drawing, what: &str) {
|
||||
let dia = assoc_of(d, c.hd);
|
||||
assert_eq!(dia.len(), 2, "{what}: diameter defpt and p15");
|
||||
assert!(dia.iter().all(|a| a.handle == c.circle), "{what}: diameter follows the circle: {dia:?}");
|
||||
assert!(matches!(dia[0].snap, AssocSnap::OnCircle { angle } if same_angle(angle, 0.0)), "{what}: {:?}", dia[0]);
|
||||
assert!(matches!(dia[1].snap, AssocSnap::OnCircle { angle } if same_angle(angle, std::f64::consts::PI)), "{what}: {:?}", dia[1]);
|
||||
|
||||
let rad = assoc_of(d, c.hr);
|
||||
assert_eq!(rad.len(), 2, "{what}: radius defpt and p15");
|
||||
assert_eq!(rad[0].handle, c.circle, "{what}: radius centre: {rad:?}");
|
||||
assert!(matches!(rad[0].snap, AssocSnap::Center), "{what}: {:?}", rad[0]);
|
||||
assert!(matches!(rad[1].snap, AssocSnap::OnCircle { angle } if same_angle(angle, 225f64.to_radians())), "{what}: {:?}", rad[1]);
|
||||
|
||||
let ang = assoc_of(d, c.ha);
|
||||
assert_eq!(ang.len(), 3, "{what}: angular leg points and vertex");
|
||||
assert_eq!((ang[0].handle, &ang[0].snap), (c.l1, &AssocSnap::End), "{what}: {ang:?}");
|
||||
assert_eq!((ang[1].handle, &ang[1].snap), (c.l2, &AssocSnap::End), "{what}: {ang:?}");
|
||||
assert_eq!(ang[2].handle, c.l1, "{what}: the vertex is the lines' intersection: {ang:?}");
|
||||
assert!(matches!(ang[2].snap, AssocSnap::Intersection { other } if other == c.l2), "{what}: {ang:?}");
|
||||
|
||||
let lin = assoc_of(d, c.hl);
|
||||
assert_eq!(lin.len(), 2, "{what}: linear is unchanged");
|
||||
assert_eq!((lin[0].handle, &lin[0].snap), (lin[1].handle, &AssocSnap::Start), "{what}: {lin:?}");
|
||||
assert!(matches!(lin[1].snap, AssocSnap::End), "{what}: {lin:?}");
|
||||
}
|
||||
|
||||
/// The writer half of #1651: the DXF of the repro's drawing has one DIMASSOC per dimension, with
|
||||
/// the references AutoCAD writes for each kind -- one centre reference (flag 2) for radius and
|
||||
/// diameter, three for an angular dimension (both leg ends and the lines' intersection), and the
|
||||
/// two origins of the linear control. Guards the kind-to-slot table and the radial/diametric
|
||||
/// branch in `std_assoc_refs`, which used to emit no reference for those kinds at all.
|
||||
#[test]
|
||||
fn angular_diameter_and_radius_dimensions_are_saved_with_dimassoc() {
|
||||
let c = case();
|
||||
let text = write_dxf(&c.d);
|
||||
|
||||
let dia = dimassoc(&text, c.hd).unwrap_or_else(|| panic!("diameter dimension {} has a DIMASSOC", c.hd.hex()));
|
||||
let rad = dimassoc(&text, c.hr).unwrap_or_else(|| panic!("radius dimension {} has a DIMASSOC", c.hr.hex()));
|
||||
let ang = dimassoc(&text, c.ha).unwrap_or_else(|| panic!("angular dimension {} has a DIMASSOC", c.ha.hex()));
|
||||
assert!(dimassoc(&text, c.hl).is_some(), "the linear control still has one");
|
||||
|
||||
// Radial and diametric dimensions associate their circle once, by its centre, as AutoCAD
|
||||
// writes them (90 = 2).
|
||||
for (dm, what) in [(&dia, "diameter"), (&rad, "radius")] {
|
||||
let refs = point_refs(dm);
|
||||
assert_eq!(int(dm, 90), Some(2), "{what}: associativity flag");
|
||||
assert_eq!(refs.len(), 1, "{what}: one reference");
|
||||
assert_eq!(int(&refs[0], 72), Some(3), "{what}: a centre snap");
|
||||
assert!(string(&refs[0], 331).is_some_and(|h| h.eq_ignore_ascii_case(&c.circle.hex())), "{what}: to the circle");
|
||||
}
|
||||
|
||||
// The angular dimension associates its two leg points and their intersection (90 = 7).
|
||||
let refs = point_refs(&ang);
|
||||
assert_eq!(int(&ang, 90), Some(7), "angular: three references");
|
||||
assert_eq!(refs.len(), 3, "angular: three references");
|
||||
for (i, (osnap, line)) in [(1, c.l1), (1, c.l2), (6, c.l1)].into_iter().enumerate() {
|
||||
assert_eq!(int(&refs[i], 72), Some(osnap), "angular ref {i}");
|
||||
assert!(string(&refs[i], 331).is_some_and(|h| h.eq_ignore_ascii_case(&line.hex())), "angular ref {i}: to its line");
|
||||
}
|
||||
assert!(string(&refs[2], 332).is_some_and(|h| h.eq_ignore_ascii_case(&c.l2.hex())), "angular: the intersection's other line");
|
||||
}
|
||||
|
||||
/// The reader half of #1651, and the one that matters for interchange: with CADCraft's xdata
|
||||
/// stripped, the standard DIMASSOC objects alone must rebuild the same associations, both from
|
||||
/// DXF and through the DWG bridge. Guards the dimension-kind slot table and the radial centre
|
||||
/// reference in `dxf_read::dimassoc`, plus the curve start/end osnap semantics in `infer_snap`.
|
||||
#[test]
|
||||
fn standard_dimassoc_associates_in_dxf_and_dwg() {
|
||||
let c = case();
|
||||
let plain = without_cadcraft_xdata(&write_dxf(&c.d));
|
||||
|
||||
let back = read_dxf(plain.as_bytes()).unwrap();
|
||||
assert_standard_assoc(&c, &back, "DXF");
|
||||
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg_as(plain.as_bytes(), Version::R2018.code()).unwrap();
|
||||
let back = read(&dwg, "case.dwg").unwrap();
|
||||
assert_standard_assoc(&c, &back, "DWG");
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! A drawing whose active layout is not the first tab keeps it active when saved: its block is
|
||||
//! the one written as `*Paper_Space` (the DWG header's paper space block), the others
|
||||
//! `*Paper_SpaceN`. Saving used to make the first tab the active one again.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, Layout, Line, Space};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
|
||||
fn three_sheets() -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
d.layouts = vec![Layout::new("S1", 1), Layout::new("S2", 2), Layout::new("S3", 3)];
|
||||
let line = |a: f64| EntityKind::Line(Line { a: Vec3::new(a, 0.0, 0.0), b: Vec3::new(a + 10.0, 10.0, 0.0) });
|
||||
d.add(&Space::Paper("S1".into()), Common::default(), line(0.0)).unwrap();
|
||||
d.add(&Space::Paper("S2".into()), Common::default(), line(20.0)).unwrap();
|
||||
d.add(&Space::Paper("S2".into()), Common::default(), line(40.0)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The layout the DWG marks active: the one whose block the header names as paper space.
|
||||
fn dwg_active_layout(dwg: &[u8]) -> String {
|
||||
let doc = acadrust::DwgReader::from_stream(Cursor::new(dwg.to_vec())).read().unwrap();
|
||||
let ps = doc.header.paper_space_block_handle;
|
||||
doc.objects
|
||||
.iter()
|
||||
.find_map(|(_, o)| match o {
|
||||
acadrust::objects::ObjectType::Layout(l) if l.block_record == ps => Some(l.name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn dwg(d: &Drawing) -> Vec<u8> {
|
||||
write_with(d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_active_layout_stays_active_through_dwg_saves() {
|
||||
let mut d = three_sheets();
|
||||
d.active_layout = Some("S2".into());
|
||||
let first = dwg(&d);
|
||||
assert_eq!(dwg_active_layout(&first), "S2");
|
||||
|
||||
// Opened again and saved again, as a drawing from another program would be.
|
||||
let back = read(&first, "case.dwg").unwrap();
|
||||
assert_eq!(back.active_layout.as_deref(), Some("S2"));
|
||||
let count = |d: &Drawing, n: &str| d.layouts.iter().find(|l| l.name == n).map_or(0, |l| l.entities.len());
|
||||
assert_eq!((count(&back, "S1"), count(&back, "S2"), count(&back, "S3")), (1, 2, 0));
|
||||
let mut tabs: Vec<(u32, &str)> = back.layouts.iter().map(|l| (l.tab_order, l.name.as_str())).collect();
|
||||
tabs.sort();
|
||||
assert_eq!(tabs.iter().map(|t| t.1).collect::<Vec<_>>(), ["S1", "S2", "S3"], "the tab order is kept");
|
||||
|
||||
let second = dwg(&back);
|
||||
assert_eq!(dwg_active_layout(&second), "S2");
|
||||
let again = read(&second, "case.dwg").unwrap();
|
||||
assert_eq!(again.active_layout.as_deref(), Some("S2"));
|
||||
assert_eq!((count(&again, "S1"), count(&again, "S2"), count(&again, "S3")), (1, 2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_active_layout_survives_dxf_too() {
|
||||
let mut d = three_sheets();
|
||||
d.active_layout = Some("S3".into());
|
||||
let dxf = write_with(&d, "case.dxf", &SaveOptions::default()).unwrap();
|
||||
let back = read(&dxf, "case.dxf").unwrap();
|
||||
assert_eq!(back.active_layout.as_deref(), Some("S3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_an_active_layout_the_first_tab_is_active() {
|
||||
let mut d = three_sheets();
|
||||
d.active_layout = Some("gone".into());
|
||||
assert_eq!(dwg_active_layout(&dwg(&d)), "S1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_active_layout_name_is_matched_ignoring_case() {
|
||||
let mut d = three_sheets();
|
||||
d.active_layout = Some("s2".into());
|
||||
assert_eq!(dwg_active_layout(&dwg(&d)), "S2");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! A polyline's vertices come back in the order its own vertex list stores them, not sorted by
|
||||
//! handle (#1856). A vertex inserted into an existing polyline gets a newer, higher handle, so a
|
||||
//! handle-ordered read silently reorders the outline (wrong shape, longer length).
|
||||
//!
|
||||
//! The DWG is written here with acadrust, giving the second 3D-polyline vertex the highest
|
||||
//! handle: exactly what an editing program produces when it inserts a vertex.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use acadrust::entities::{EntityType, Polyline3D, Vertex3DPolyline};
|
||||
use acadrust::types::{DxfVersion, Handle, Vector3};
|
||||
use cadcraft_doc::EntityKind;
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
/// A drawing with one 6-vertex 3D polyline whose vertex handles are not ascending: the second
|
||||
/// vertex is the newest (highest) handle.
|
||||
fn dwg_with_out_of_order_vertex_handles(version: DxfVersion) -> Vec<u8> {
|
||||
let mut doc = acadrust::CadDocument::with_version(version);
|
||||
let handles = [0x113C, 0x1141, 0x113D, 0x113E, 0x113F, 0x1140];
|
||||
let points = [(0.0, 0.0, 0.0), (10.0, 0.0, 1.0), (10.0, 10.0, 2.0), (0.0, 10.0, 3.0), (0.0, 20.0, 4.0), (10.0, 20.0, 5.0)];
|
||||
let mut polyline = Polyline3D::new();
|
||||
polyline.vertices = handles
|
||||
.iter()
|
||||
.zip(points)
|
||||
.map(|(handle, (x, y, z))| Vertex3DPolyline {
|
||||
handle: Handle::new(*handle),
|
||||
layer: "0".to_string(),
|
||||
position: Vector3::new(x, y, z),
|
||||
flags: 32,
|
||||
})
|
||||
.collect();
|
||||
doc.add_entity(EntityType::Polyline3D(polyline)).expect("add the polyline");
|
||||
acadrust::DwgWriter::write_to_vec(&doc).expect("write the DWG")
|
||||
}
|
||||
|
||||
/// Reads `dwg` through `cadcraft_io::read` and returns the points of its only 3D polyline, in the
|
||||
/// order the reader produced them; panics if the drawing cannot be read or holds no 3D polyline,
|
||||
/// since either would mean the test is no longer exercising vertex order.
|
||||
fn polyline3d_points(dwg: &[u8]) -> Vec<Vec3> {
|
||||
let drawing = cadcraft_io::read(dwg, "x.dwg").expect("read the DWG");
|
||||
let polyline = drawing.model.iter().find_map(|e| if let EntityKind::Polyline3d(p) = &e.kind { Some(p) } else { None });
|
||||
polyline.expect("one 3D polyline").points.clone()
|
||||
}
|
||||
|
||||
/// Regression guard for #1856, run for both DWG versions the writer emits (AC1018 and AC1032): the
|
||||
/// reader must keep a polyline's vertices in the order the entity's own vertex list lists them,
|
||||
/// even when a mid-outline vertex carries the highest handle. Ordering by handle would move the
|
||||
/// second point to the end, changing the outline's shape and length.
|
||||
#[test]
|
||||
fn dwg_read_keeps_polyline_vertex_order() {
|
||||
let expected = vec![
|
||||
Vec3::new(0.0, 0.0, 0.0),
|
||||
Vec3::new(10.0, 0.0, 1.0),
|
||||
Vec3::new(10.0, 10.0, 2.0),
|
||||
Vec3::new(0.0, 10.0, 3.0),
|
||||
Vec3::new(0.0, 20.0, 4.0),
|
||||
Vec3::new(10.0, 20.0, 5.0),
|
||||
];
|
||||
for version in [DxfVersion::AC1018, DxfVersion::AC1032] {
|
||||
let dwg = dwg_with_out_of_order_vertex_handles(version);
|
||||
assert_eq!(polyline3d_points(&dwg), expected, "{version:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! #1813: AutoCAD always writes an `AcDbVariableDictionary` in the named object dictionary;
|
||||
//! ezdxf, and CADCraft's own DXF writer, do not. Vectric Aspire 10.5 reads an AC1018 DWG without
|
||||
//! it as empty ("No data was imported..."), though it reads the same drawing as DXF (and as an
|
||||
//! AC1032 DWG) fine. The DWG bridge adds the dictionary AutoCAD writes, with its usual variables,
|
||||
//! when the DXF has none.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, LwPolyline, Space};
|
||||
use cadcraft_geom::{PolyVertex, Vec2};
|
||||
use cadcraft_io::{SaveOptions, Version, write_with};
|
||||
|
||||
/// ezdxf's rectangle: one closed LWPOLYLINE in model space.
|
||||
fn rectangle() -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
let corners = [(0.0, 0.0), (4.0, 0.0), (4.0, 2.0), (0.0, 2.0)];
|
||||
let pl = LwPolyline {
|
||||
vertices: corners.iter().map(|(x, y)| PolyVertex::new(Vec2::new(*x, *y))).collect(),
|
||||
closed: true,
|
||||
const_width: 0.0,
|
||||
elevation: 0.0,
|
||||
plinegen: false,
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), EntityKind::LwPolyline(pl)).expect("add the rectangle");
|
||||
d
|
||||
}
|
||||
|
||||
/// The drawing variables in the named object dictionary, with their values, or nothing when the
|
||||
/// dictionary is missing. Dictionary keys are case-insensitive in AutoCAD; its own spelling is
|
||||
/// `AcDbVariableDictionary` (the DXF Reference calls it the ACDBVARIABLEDICTIONARY).
|
||||
fn variable_dictionary(doc: &acadrust::CadDocument) -> Option<Vec<(String, String)>> {
|
||||
let acadrust::objects::ObjectType::Dictionary(root) = doc.objects.get(&doc.header.named_objects_dict_handle)? else {
|
||||
return None;
|
||||
};
|
||||
let acadrust::objects::ObjectType::Dictionary(vars) = doc.objects.get(&root.get("AcDbVariableDictionary")?)? else {
|
||||
return None;
|
||||
};
|
||||
Some(
|
||||
vars.entries
|
||||
.iter()
|
||||
.filter_map(|(name, handle)| match doc.objects.get(handle) {
|
||||
Some(acadrust::objects::ObjectType::DictionaryVariable(v)) => Some((name.clone(), v.value.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Read a DWG back with acadrust's reader, so the assertions inspect the file as another
|
||||
/// application would rather than through CADCraft's own importer.
|
||||
fn read_dwg(dwg: &[u8]) -> acadrust::CadDocument {
|
||||
acadrust::DwgReader::from_stream(Cursor::new(dwg)).read().expect("read the DWG back")
|
||||
}
|
||||
|
||||
/// The value of the variable called `name`, matched case-insensitively as AutoCAD matches
|
||||
/// dictionary keys, or nothing when the dictionary has no such entry.
|
||||
fn value<'a>(vars: &'a [(String, String)], name: &str) -> Option<&'a str> {
|
||||
vars.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// #1813's regression: a DXF written without an `AcDbVariableDictionary` (as ezdxf and CADCraft
|
||||
/// write them) must gain AutoCAD's dictionary when exported as an AC1018 DWG, so readers that
|
||||
/// require it see the drawing's contents; the added objects must not break CADCraft's own read.
|
||||
#[test]
|
||||
fn an_ac1018_dwg_gets_the_variable_dictionary_autocad_always_writes() {
|
||||
let d = rectangle();
|
||||
let dxf = write_with(&d, "rect.dxf", &SaveOptions { version: Some(Version::R2000), binary: false }).expect("write the DXF");
|
||||
let text = String::from_utf8_lossy(&dxf).to_ascii_lowercase();
|
||||
assert!(!text.contains("acdbvariabledictionary"), "this DXF is one without the dictionary, like ezdxf's");
|
||||
|
||||
let dwg = write_with(&d, "rect.dwg", &SaveOptions { version: Some(Version::R2004), binary: false }).expect("write the DWG");
|
||||
assert!(dwg.starts_with(b"AC1018"), "the DWG is AutoCAD 2004");
|
||||
let doc = read_dwg(&dwg);
|
||||
let vars = variable_dictionary(&doc).expect("AC1018 DWG: the named object dictionary has an AcDbVariableDictionary");
|
||||
|
||||
// The variables AutoCAD keeps there, by name (Aspire needs at least the dictionary; these
|
||||
// are the ones the reference application writes).
|
||||
for (name, want) in [("CMLEADERSTYLE", "Standard"), ("CTABLESTYLE", "Standard"), ("CANNOSCALE", "1:1"), ("DIMASSOC", "2"), ("HIDETEXT", "1")] {
|
||||
assert_eq!(value(&vars, name), Some(want), "{name} in {vars:?}");
|
||||
}
|
||||
assert!(value(&vars, "LAYEREVAL").is_some() && value(&vars, "LAYERNOTIFY").is_some(), "{vars:?}");
|
||||
|
||||
// The added objects don't disturb CADCraft's own read of the file: the rectangle is there.
|
||||
let back = cadcraft_io::read(&dwg, "rect.dwg").expect("CADCraft reads its own 2004 DWG");
|
||||
assert_eq!(back.model.len(), 1);
|
||||
}
|
||||
|
||||
/// The dictionary must carry the drawing's own `$DIMASSOC` and `$HIDETEXT`, not AutoCAD's
|
||||
/// defaults: a drawing that turned text hiding off or stores dimensions non-associatively keeps
|
||||
/// those settings for every consumer that reads them from the dictionary.
|
||||
#[test]
|
||||
fn the_dictionary_keeps_the_drawings_dimassoc_and_hidetext() {
|
||||
let dxf = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DIMASSOC\n70\n1\n9\n$HIDETEXT\n70\n0\n0\nENDSEC\n0\nEOF\n";
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1018").expect("write the DWG");
|
||||
let doc = read_dwg(&dwg);
|
||||
let vars = variable_dictionary(&doc).expect("AC1018 DWG: the named object dictionary has an AcDbVariableDictionary");
|
||||
assert_eq!(value(&vars, "DIMASSOC"), Some("1"), "DIMASSOC in {vars:?}");
|
||||
assert_eq!(value(&vars, "HIDETEXT"), Some("0"), "HIDETEXT in {vars:?}");
|
||||
// The DWG's own header settings match the dictionary, so both kinds of consumer agree.
|
||||
assert_eq!(doc.header.dimension_associativity, 1);
|
||||
assert_eq!(doc.header.hide_text, 0);
|
||||
}
|
||||
|
||||
/// A header variable holding a value the variable doesn't accept is written with AutoCAD's
|
||||
/// default, as a hostile file can put any integer there.
|
||||
#[test]
|
||||
fn impossible_header_values_fall_back_to_the_defaults() {
|
||||
let dxf = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DIMASSOC\n70\n9\n9\n$HIDETEXT\n70\n7\n0\nENDSEC\n0\nEOF\n";
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1018").expect("write the DWG");
|
||||
let doc = read_dwg(&dwg);
|
||||
let vars = variable_dictionary(&doc).expect("AC1018 DWG: the named object dictionary has an AcDbVariableDictionary");
|
||||
assert_eq!(value(&vars, "DIMASSOC"), Some("2"), "DIMASSOC in {vars:?}");
|
||||
assert_eq!(value(&vars, "HIDETEXT"), Some("1"), "HIDETEXT in {vars:?}");
|
||||
}
|
||||
|
||||
/// The AC1032 (AutoCAD 2018) path is deliberately unchanged: those DWGs read in Vectric Aspire
|
||||
/// without the dictionary, so no dictionary is injected and the existing output stays as it was.
|
||||
#[test]
|
||||
fn an_ac1032_dwg_stays_as_it_was() {
|
||||
// 2018 DWGs read fine in Aspire without the dictionary, so the latest format is unchanged.
|
||||
let d = rectangle();
|
||||
let dwg = write_with(&d, "rect.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).expect("write the DWG");
|
||||
assert!(dwg.starts_with(b"AC1032"));
|
||||
let doc = read_dwg(&dwg);
|
||||
assert_eq!(variable_dictionary(&doc), None, "no dictionary is added to a 2018 DWG");
|
||||
}
|
||||
|
||||
/// A drawing that already has the dictionary must keep its own entries and values: conversion
|
||||
/// only supplies AutoCAD's dictionary when it is absent, rather than merging defaults into one
|
||||
/// that a producer such as ezdxf's `add_dict_var` already wrote.
|
||||
#[test]
|
||||
fn a_variable_dictionary_the_drawing_has_is_left_alone() {
|
||||
// A drawing that already has the dictionary keeps its own values and entries (here only
|
||||
// CMLEADERSTYLE, as ezdxf's add_dict_var("CMLEADERSTYLE", "Standard") writes it): a DWG
|
||||
// export must not add defaults next to them.
|
||||
let mut source = acadrust::CadDocument::new();
|
||||
source.version = acadrust::DxfVersion::AC1015;
|
||||
assert!(source.set_drawing_variable("CMLEADERSTYLE", "Notes"));
|
||||
let dxf = acadrust::DxfWriter::new(&source).write_to_vec().expect("write a DXF with the dictionary");
|
||||
|
||||
let dwg = cadcraft_dwg::dxf_to_dwg_as(&dxf, "AC1018").expect("write the DWG");
|
||||
let doc = read_dwg(&dwg);
|
||||
let vars = variable_dictionary(&doc).expect("the drawing's dictionary is kept");
|
||||
assert_eq!(vars.len(), 1, "{vars:?}");
|
||||
assert_eq!(value(&vars, "CMLEADERSTYLE"), Some("Notes"), "{vars:?}");
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! The anonymous `*D` block written for a dimension (what other programs draw) holds its text at
|
||||
//! the height it is drawn at: the dimension's overrides and DIMSCALE (0 = the drawing's) count
|
||||
//! (issue #367).
|
||||
//! (issue #367). Angular text also keeps its decimal separator in the stored block (#1646).
|
||||
|
||||
use cadcraft_doc::{Common, DimKind, Dimension, Drawing, EntityKind, Space};
|
||||
use cadcraft_doc::{Common, DimKind, DimStyle, Dimension, Drawing, EntityKind, Space};
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
/// The group 40 (height) of the first MTEXT in the BLOCKS section.
|
||||
@@ -51,3 +51,79 @@ fn dimension_block_text_has_the_drawn_height() {
|
||||
let zero = height_written(0.0, serde_json::json!({}));
|
||||
assert!(near(zero, 0.18), "DIMSCALE 0 in model space is scale 1, not ~0: {zero}");
|
||||
}
|
||||
|
||||
fn angular_drawing(separator_override: Option<&str>) -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
d.dim_styles.push(DimStyle { name: "Comma".into(), decimal_separator: ",".into(), angular_decimals: 1, ..DimStyle::default() });
|
||||
let angle = 39.4_f64.to_radians();
|
||||
let mut dm = Dimension {
|
||||
kind: DimKind::Angular3P,
|
||||
defpt: Vec3::new(50.0 * (angle / 2.0).cos(), 50.0 * (angle / 2.0).sin(), 0.0),
|
||||
text_mid: Vec3::ZERO,
|
||||
p13: Vec3::new(100.0, 0.0, 0.0),
|
||||
p14: Vec3::new(100.0 * angle.cos(), 100.0 * angle.sin(), 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: String::new(),
|
||||
style: "Comma".into(),
|
||||
measurement: 0.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: None,
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
};
|
||||
if let Some(separator) = separator_override {
|
||||
dm.overrides.insert("DIMDSEP".into(), serde_json::json!(separator));
|
||||
}
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dxf_angular_block_text_keeps_the_decimal_separator() {
|
||||
for (separator_override, expected) in [(None, "39,4°"), (Some("."), "39.4°")] {
|
||||
let dxf = cadcraft_io::write_dxf(&angular_drawing(separator_override));
|
||||
let stored = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
|
||||
// Inspect the saved block's MTEXT, without regenerating the dimension's text.
|
||||
let texts: Vec<&str> = stored
|
||||
.blocks
|
||||
.values()
|
||||
.filter(|b| b.name.starts_with("*D"))
|
||||
.flat_map(|b| b.entities.iter())
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::MText(t) => Some(t.contents.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(texts, [expected], "DIMDSEP override: {separator_override:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn dwg_angular_block_text_keeps_the_decimal_separator() {
|
||||
use std::io::Cursor;
|
||||
|
||||
use acadrust::entities::EntityType;
|
||||
use cadcraft_io::{SaveOptions, Version};
|
||||
|
||||
for (separator_override, expected) in [(None, "39,4°"), (Some("."), "39.4°")] {
|
||||
let dwg =
|
||||
cadcraft_io::write_with(&angular_drawing(separator_override), "angle.dwg", &SaveOptions { version: Some(Version::R2018), binary: false })
|
||||
.unwrap();
|
||||
// DWG consumers use the stored *D geometry: read it directly, without CADCraft rendering it.
|
||||
let stored = acadrust::DwgReader::from_stream(Cursor::new(dwg)).read().unwrap();
|
||||
let texts: Vec<&str> = stored
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
EntityType::MText(t) if stored.block_records.iter().any(|b| b.handle == t.common.owner_handle && b.name.starts_with("*D")) => {
|
||||
Some(t.value.as_str())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(texts, [expected], "DIMDSEP override: {separator_override:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,3 +138,24 @@ fn viewport_ids_survive_dwg_and_the_paper_viewport_stays_implicit() {
|
||||
assert!(!text.contains("PAPERVIEW"));
|
||||
assert_eq!(viewport_ids(&cadcraft_io::read_dxf(text.as_bytes()).unwrap().layouts[0]), [1, 2]);
|
||||
}
|
||||
|
||||
/// The paper-space viewport (id 1) of a drawing from another program can come after a model
|
||||
/// viewport in the layout, e.g. when the draw order (SORTENTS) put it behind. DWG numbers in
|
||||
/// order, so the writer puts it first: otherwise the model viewport became the sheet on reopen.
|
||||
#[test]
|
||||
fn a_paper_viewport_behind_a_model_viewport_stays_id_1_through_dwg() {
|
||||
let mut d = Drawing::new_metric();
|
||||
let names: Vec<String> = d.layouts.iter().map(|l| l.name.clone()).collect();
|
||||
for name in &names {
|
||||
let space = Space::Paper(name.clone());
|
||||
let model = d.add(&space, Common::default(), viewport(2)).unwrap();
|
||||
let sheet = d.add(&space, Common::default(), viewport(1)).unwrap();
|
||||
let back = through_dwg(&d);
|
||||
let l = back.layouts.iter().find(|l| &l.name == name).unwrap();
|
||||
let id = |h: Handle| match &l.entities.get(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Viewport(v)) => Some(v.id),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!((id(model), id(sheet)), (Some(2), Some(1)), "layout {name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::{Headless, Server};
|
||||
|
||||
/// Send one JSON-RPC request to the server and return the parsed reply; fails the test if the
|
||||
/// server does not answer or answers with invalid JSON.
|
||||
fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
|
||||
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
|
||||
let r = s.handle_line(&line).expect("reply");
|
||||
serde_json::from_str(&r).unwrap()
|
||||
}
|
||||
|
||||
/// Call an MCP tool and return its decoded result: JSON text is parsed, an image is passed
|
||||
/// through, and a tool error fails the test with the reported message.
|
||||
fn tool(s: &mut Server, name: &str, args: Value) -> Value {
|
||||
let r = call(s, 99, "tools/call", json!({"name": name, "arguments": args}));
|
||||
let c = &r["result"]["content"][0];
|
||||
@@ -17,6 +21,8 @@ fn tool(s: &mut Server, name: &str, args: Value) -> Value {
|
||||
if c["type"] == "text" { serde_json::from_str(c["text"].as_str().unwrap()).unwrap_or(Value::Null) } else { c.clone() }
|
||||
}
|
||||
|
||||
/// The MCP handshake works: `initialize` names the server, the `initialized` notification is
|
||||
/// accepted, `tools/list` offers the command line, and an unknown method is -32601.
|
||||
#[test]
|
||||
fn lifecycle_and_tools() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
@@ -30,6 +36,8 @@ fn lifecycle_and_tools() {
|
||||
assert_eq!(bad["error"]["code"], -32601);
|
||||
}
|
||||
|
||||
/// Guards that an agent can complete a drawing (plate, holes, layer, text) using only the MCP
|
||||
/// tools, and verify it only through MCP: counts, selection queries, undo and a PNG render.
|
||||
#[test]
|
||||
fn agent_draws_a_plate_with_holes() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
@@ -70,6 +78,8 @@ fn agent_draws_a_plate_with_holes() {
|
||||
assert!(img["data"].as_str().unwrap().len() > 100);
|
||||
}
|
||||
|
||||
/// Guards that bad input is reported, not fatal: a refused command and a screenshot without a
|
||||
/// UI are `isError` results, and malformed JSON is a parse error (-32700).
|
||||
#[test]
|
||||
fn errors_are_tool_errors_not_crashes() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
@@ -111,6 +121,19 @@ fn bad_tool_calls_are_invalid_params() {
|
||||
assert_eq!(s.handle_line("[]").map(|r| serde_json::from_str::<Value>(&r).unwrap()["error"]["code"].clone()), Some(json!(-32600)));
|
||||
}
|
||||
|
||||
/// `open` reads both DXF and DWG, so `tools/list` must advertise both (#1875).
|
||||
#[test]
|
||||
fn open_tool_description_names_dxf_and_dwg() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
let tools = call(&mut s, 1, "tools/list", json!({}));
|
||||
let open = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "open").unwrap();
|
||||
let desc = open["description"].as_str().unwrap();
|
||||
assert!(desc.contains("DXF"), "`open` must still name DXF: {desc}");
|
||||
assert!(desc.contains("DWG"), "`open` reads DWG too: {desc}");
|
||||
}
|
||||
|
||||
/// Guards that `query_entities` advertises its `selected` filter and honours it (active-space
|
||||
/// selection only, both ways) without changing the drawing.
|
||||
#[test]
|
||||
fn query_entities_selection_filter_is_discoverable_and_forwarded() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
@@ -149,6 +172,8 @@ fn query_entities_pages_and_windows_by_geometry() {
|
||||
assert_eq!(r["count"], 1, "only the circle is entirely inside: {r}");
|
||||
}
|
||||
|
||||
/// Guards that the `list_commands` filter matches ids, labels and aliases but not parameter
|
||||
/// docs, so searching `circle` does not return QSELECT merely for mentioning circles.
|
||||
#[test]
|
||||
fn list_commands_filter_matches_names_not_docs() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
@@ -160,6 +185,8 @@ fn list_commands_filter_matches_names_not_docs() {
|
||||
assert!(r.as_array().unwrap().iter().any(|c| c["id"] == "pline"), "aliases and ids match");
|
||||
}
|
||||
|
||||
/// Guards that `find_text` returns TEXT and MTEXT with their placement, strips MTEXT formatting,
|
||||
/// and filters by text, layer and window; a windowed render keeps that area's aspect ratio.
|
||||
#[test]
|
||||
fn find_text_reads_texts_and_render_shows_one_area() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
@@ -199,6 +226,8 @@ fn find_text_reads_texts_and_render_shows_one_area() {
|
||||
assert_eq!((w, h), (1600, 800));
|
||||
}
|
||||
|
||||
/// Guards a lazy Remote bridge: it serves tools without the app, reports how to start it
|
||||
/// (`--control`), and reaches the app once that app starts listening on the port.
|
||||
#[test]
|
||||
fn a_lazy_bridge_starts_without_the_app_and_says_how_to_open_it() {
|
||||
// Nothing listens on a port that was just freed.
|
||||
|
||||
@@ -4,6 +4,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
/// The `tools/list` payload: one definition (name, description, input schema) per MCP tool,
|
||||
/// including the UI-only tools when `has_ui` says a window is available.
|
||||
pub fn tool_definitions(has_ui: bool) -> Value {
|
||||
let mut tools = vec![
|
||||
json!({"name": "command_line", "description": "Type one line at the CADCraft command line, exactly as a user would at an AutoCAD-style prompt. Starts commands by name or alias (LINE, L, CIRCLE, C, PLINE, RECTANG, MOVE, TRIM, OFFSET, ZOOM…) and answers the active prompt: points `x,y`, relative `@dx,dy`, polar `@dist<angle`, distances, keywords (e.g. `c` for Close), empty text = Enter. Spaces act as Enter, so `circle 0,0 5` works in one call. Returns the new prompt and command-line output, and `error` when the line was refused.",
|
||||
@@ -30,7 +32,7 @@ pub fn tool_definitions(has_ui: bool) -> Value {
|
||||
"limit": {"type": "integer", "minimum": 0}, "offset": {"type": "integer", "minimum": 0}}}}),
|
||||
json!({"name": "new_drawing", "description": "Create a new drawing (imperial by default, or metric).",
|
||||
"inputSchema": {"type": "object", "properties": {"metric": {"type": "boolean"}}}}),
|
||||
json!({"name": "open", "description": "Open a DXF file.", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}),
|
||||
json!({"name": "open", "description": "Open a DXF or DWG file.", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}),
|
||||
json!({"name": "save", "description": "Save the drawing (DXF or DWG by extension). A .svg, .png or .pdf path exports instead: the drawing keeps its name and unsaved changes.", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}}}),
|
||||
json!({"name": "cancel", "description": "Press Escape: cancel the running command (or clear the selection).", "inputSchema": {"type": "object", "properties": {}}}),
|
||||
];
|
||||
@@ -42,10 +44,12 @@ pub fn tool_definitions(has_ui: bool) -> Value {
|
||||
json!({ "tools": tools })
|
||||
}
|
||||
|
||||
/// Wrap a JSON value as a successful MCP result containing pretty-printed text.
|
||||
fn text(v: &Value) -> Value {
|
||||
json!({"content": [{"type": "text", "text": serde_json::to_string_pretty(v).unwrap_or_default()}]})
|
||||
}
|
||||
|
||||
/// Wrap an error message as an MCP tool error result (`isError`), not a protocol error.
|
||||
fn err_text(e: &str) -> Value {
|
||||
json!({"content": [{"type": "text", "text": e}], "isError": true})
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ fn keys(v: &Value) -> Vec<String> {
|
||||
/// The `cmdline.state` fields plus `extra`.
|
||||
fn want(b: &mut Headless, extra: &[&str]) -> Vec<String> {
|
||||
let mut k = keys(&b.call("cmdline.state", json!({})).unwrap());
|
||||
k.extend(extra.iter().map(|s| s.to_string()));
|
||||
k.extend(extra.iter().map(ToString::to_string));
|
||||
k.sort();
|
||||
k
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ fn keys(v: &Value) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn want(extra: &[&str]) -> Vec<String> {
|
||||
let mut k: Vec<String> = STATE_KEYS.iter().chain(extra).map(|s| s.to_string()).collect();
|
||||
let mut k: Vec<String> = STATE_KEYS.iter().chain(extra).map(ToString::to_string).collect();
|
||||
k.sort();
|
||||
k
|
||||
}
|
||||
|
||||
+113
-6
@@ -10,7 +10,7 @@ use cadcraft_doc::{DimKind, DimStyle, Dimension};
|
||||
use cadcraft_fonts::{MTextParams, TextFont};
|
||||
use cadcraft_geom::{Arc, Vec2, ccw_sweep};
|
||||
|
||||
use crate::units::{NumFormat, format_angle, format_decimal, format_linear};
|
||||
use crate::units::{NumFormat, format_angle_with_separator, format_decimal, format_linear};
|
||||
|
||||
/// Most DIMBREAK gaps drawn per dimension.
|
||||
const MAX_BREAKS: usize = 1000;
|
||||
@@ -53,6 +53,22 @@ pub struct DimGeometry {
|
||||
}
|
||||
|
||||
impl DimGeometry {
|
||||
/// `value` as the contents of a plain MTEXT (the dimension's `*D` block, its EXPLODE
|
||||
/// pieces): the diameter sign as `%%c`, not the decoded ⌀ (U+2300), which no SHX font has,
|
||||
/// so other programs plot nothing for it, while every font draws `%%c` (#1645). A `%`
|
||||
/// that would run into a code is written `%%%`.
|
||||
pub fn mtext_contents(&self) -> String {
|
||||
let mut out = String::with_capacity(self.value.len() + 2);
|
||||
let mut chars = self.value.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'⌀' => out.push_str("%%c"),
|
||||
'%' if matches!(chars.peek(), Some('%' | '⌀')) => out.push_str("%%%"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn dim(&mut self, l: Vec<Vec2>) {
|
||||
if l.len() >= 2 {
|
||||
self.lines.push(l);
|
||||
@@ -141,7 +157,7 @@ pub enum Arrowhead {
|
||||
impl Arrowhead {
|
||||
/// Parse a DIMBLK value ("" / "_DOT" / "Architectural tick" / "closed blank"…).
|
||||
pub fn parse(name: &str) -> Arrowhead {
|
||||
let n: String = name.trim().trim_start_matches('_').chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
||||
let n: String = name.trim().trim_start_matches('_').chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
|
||||
match n.as_str() {
|
||||
"" | "closedfilled" | "filled" => Arrowhead::ClosedFilled,
|
||||
"closedblank" | "blank" => Arrowhead::ClosedBlank,
|
||||
@@ -623,6 +639,8 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font
|
||||
let text_outside = !text_in;
|
||||
text_angle = if text_outside { if st.text_outside_horizontal { 0.0 } else { along_ang } } else { inside_ang };
|
||||
let half = extent(text_angle);
|
||||
// DIMTMOVE 1 / 2: text that doesn't fit is moved off the dimension line, which stays put.
|
||||
let moved = text_outside && st.text_move != 0;
|
||||
// DIMSOXD: arrowheads that don't fit inside are left out.
|
||||
let outside_arrows = !arrows_inside && !st.suppress_outside_arrows;
|
||||
// Text centre along the dimension line.
|
||||
@@ -638,7 +656,11 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font
|
||||
// Perpendicular offset per DIMTAD.
|
||||
let side_out = if (on - p1).dot(n) >= 0.0 { n } else { -n };
|
||||
let tr = Vec2::from_angle(text_angle);
|
||||
let off_along = |m: Vec2| gap + (tw * tr.dot(m).abs() + tht * tr.perp().dot(m).abs()) / 2.0;
|
||||
let off_along = |m: Vec2| {
|
||||
let glyph_extent = tw * tr.dot(m).abs() / 2.0 + tht * tr.perp().dot(m).abs() * if st.text_above == 1 { 0.6 } else { 0.5 };
|
||||
// Empirical #2004 match: 0.595× height with SHX and 0.601× with TrueType.
|
||||
gap + glyph_extent
|
||||
};
|
||||
let above = if text_angle.abs() < 1e-12 && n.y.abs() > 1e-9 {
|
||||
if n.y > 0.0 { n } else { -n }
|
||||
} else if text_angle.abs() < 1e-12 {
|
||||
@@ -662,6 +684,12 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font
|
||||
text_angle = readable(e.angle());
|
||||
let outward = if just == 3 { -u } else { u };
|
||||
text_mid = f + e * (st.ext_extend * k + gap + tw / 2.0) + outward * (gap + tht / 2.0);
|
||||
} else if moved {
|
||||
// Over the middle of the line, clear of the arrowheads, on the side away from the
|
||||
// measured points; with DIMTMOVE 1 it starts there and a leader joins it.
|
||||
let mid = f1.mid(f2);
|
||||
let lift = off_along(side_out) + 2.0 * asz.max(tsz);
|
||||
text_mid = if st.text_move == 1 { mid + side_out * lift + u * half } else { mid + side_out * (lift + off_along(side_out)) };
|
||||
} else if !d.user_text_pos {
|
||||
text_mid = along_c + offset;
|
||||
}
|
||||
@@ -722,7 +750,7 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font
|
||||
to_text(&mut g, if outside_arrows { f2 + u * stub } else { f2 });
|
||||
}
|
||||
// DIMTMOVE 1: a leader from the dimension line to text moved off it.
|
||||
if d.user_text_pos && st.text_move == 1 && has_text {
|
||||
if (d.user_text_pos || moved) && st.text_move == 1 && has_text {
|
||||
let base = f1.mid(f2);
|
||||
let off = (text_mid - base).dot(n).abs();
|
||||
if off > tht / 2.0 + gap + 1e-9 {
|
||||
@@ -875,7 +903,7 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font
|
||||
g.measurement = sweep;
|
||||
// DIMAZIN 1 / 2 suppress leading / trailing zeros (DIMZIN's bits 4 / 8).
|
||||
let azin = (st.angular_zero_suppression & 3) * 4;
|
||||
let txt = format_angle(sweep, st.angular_unit, st.angular_decimals, azin);
|
||||
let txt = format_angle_with_separator(sweep, st.angular_unit, st.angular_decimals, azin, &st.decimal_separator);
|
||||
// DIMPOST is a linear-dimension prefix/suffix; angles have their own format only.
|
||||
g.mtext = inspected(apply_override(&txt, d), d);
|
||||
let mut pts = Vec::new();
|
||||
@@ -1172,7 +1200,7 @@ mod tests {
|
||||
// Centred text breaks the dimension line in two.
|
||||
assert_eq!(centred.lines_of(LineRole::Dim).count(), 2);
|
||||
let above = dimension_geometry(&d, &st_with(|s| s.text_above = 1), 1.0);
|
||||
assert!((above.text_pos.y - (5.0 + 0.09 + 0.09)).abs() < 1e-6, "{}", above.text_pos.y);
|
||||
assert!((above.text_pos.y - (5.0 + 0.09 + 0.18 * 0.6)).abs() < 1e-6, "{}", above.text_pos.y);
|
||||
assert_eq!(above.lines_of(LineRole::Dim).count(), 1);
|
||||
let below = dimension_geometry(&d, &st_with(|s| s.text_above = 4), 1.0);
|
||||
assert!(below.text_pos.y < 5.0);
|
||||
@@ -1184,6 +1212,31 @@ mod tests {
|
||||
assert!(outside2.text_pos.y < 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimtad_one_uses_autocad_text_clearance() {
|
||||
let d = lin((0.0, 0.10), (0.0, 0.70), (0.30, 0.40), std::f64::consts::FRAC_PI_2);
|
||||
let st = st_with(|s| {
|
||||
s.scale = 50.0;
|
||||
s.text_height = 0.0025;
|
||||
s.text_gap = 0.0008;
|
||||
s.arrow_size = 0.0015;
|
||||
s.text_above = 1;
|
||||
s.text_inside_horizontal = false;
|
||||
s.decimals = 2;
|
||||
});
|
||||
let g = dimension_geometry(&d, &st, 50.0);
|
||||
// Half the text height (0.0625) plus DIMGAP (0.04) put the centre at 0.1025;
|
||||
// AutoCAD 2026 recomputes it at about 0.115 from the line (#2004).
|
||||
assert!(
|
||||
(0.30 - g.text_pos.x - 0.115).abs() < 0.001,
|
||||
"text x={} offset={} angle={} text={}",
|
||||
g.text_pos.x,
|
||||
0.30 - g.text_pos.x,
|
||||
g.text_angle,
|
||||
g.value
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_alignment_vertical_dimension() {
|
||||
let d = lin((0.0, 0.0), (0.0, 10.0), (5.0, 0.0), std::f64::consts::FRAC_PI_2);
|
||||
@@ -1211,6 +1264,37 @@ mod tests {
|
||||
assert!(g.text_pos.x > 0.5, "text beyond the second extension line: {}", g.text_pos.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_does_not_fit_follows_dimtmove_2() {
|
||||
// Vertical, measured points left of the line: the text goes over the middle of the
|
||||
// line, on the side away from them, and the line is not carried out to it.
|
||||
let d = lin((0.0, 0.0), (0.0, 0.5), (5.0, 0.0), std::f64::consts::FRAC_PI_2);
|
||||
let g = dimension_geometry(&d, &st_with(|s| s.text_move = 2), 1.0);
|
||||
assert!((g.text_pos.y - 0.25).abs() < 1e-9 && g.text_pos.x > 5.2, "{:?}", g.text_pos);
|
||||
assert!(g.lines_of(LineRole::Dim).flatten().all(|p| p.y <= 0.5 + 1e-9 && p.x <= 5.0 + 1e-9), "no leader, no line out to the text");
|
||||
// Horizontal, measured points above the line: below it.
|
||||
let d = lin((0.0, 10.0), (0.5, 10.0), (0.0, 5.0), 0.0);
|
||||
let g = dimension_geometry(&d, &st_with(|s| s.text_move = 2), 1.0);
|
||||
assert!((g.text_pos.x - 0.25).abs() < 1e-9 && g.text_pos.y < 4.8, "{:?}", g.text_pos);
|
||||
assert!(g.lines_of(LineRole::Dim).flatten().all(|p| p.x <= 0.5 + 1e-9 && p.y >= 5.0 - 1e-9), "no leader, no line out to the text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_does_not_fit_follows_dimtmove_1() {
|
||||
// Moved off the line with a leader from the middle of the dimension line.
|
||||
let d = lin((0.0, 0.0), (0.5, 0.0), (0.0, 5.0), 0.0);
|
||||
let g = dimension_geometry(&d, &st_with(|s| s.text_move = 1), 1.0);
|
||||
assert!(g.text_pos.y > 5.2, "{:?}", g.text_pos);
|
||||
let mid = Vec2::new(0.25, 5.0);
|
||||
assert!(g.lines_of(LineRole::Dim).any(|l| l.len() == 2 && l[0].dist(mid) < 1e-9 && l[1].y > 5.1), "leader");
|
||||
// Text placed with DIMTEDIT stays where it is.
|
||||
let mut placed = d.clone();
|
||||
placed.user_text_pos = true;
|
||||
placed.text_mid = Vec3::new(3.0, 7.0, 0.0);
|
||||
let g = dimension_geometry(&placed, &st_with(|s| s.text_move = 1), 1.0);
|
||||
assert!(g.text_pos.dist(Vec2::new(3.0, 7.0)) < 1e-9, "{:?}", g.text_pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerance_and_limits() {
|
||||
let d = lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0);
|
||||
@@ -1313,6 +1397,7 @@ mod tests {
|
||||
d.defpt = Vec3::new(-3.0, 0.0, 0.0);
|
||||
let g = dimension_geometry(&d, &st_with(|s| s.decimals = 1), 1.0);
|
||||
assert_eq!(g.value, "⌀6.0");
|
||||
assert_eq!(g.mtext_contents(), "%%c6.0", "the *D block's text (#1645)");
|
||||
let mut a = lin((10.0, 0.0), (0.0, 10.0), (3.0, 3.0), 0.0);
|
||||
a.kind = DimKind::Angular3P;
|
||||
a.p15 = Vec3::ZERO;
|
||||
@@ -1362,6 +1447,28 @@ mod tests {
|
||||
let g = dimension_geometry(&d, &DimStyle::default(), 1.0);
|
||||
assert!(g.lines.is_empty() && g.fills.is_empty(), "{:?}", g.lines);
|
||||
}
|
||||
|
||||
/// #1645: the MTEXT contents give back the dimension's text when decoded, with the diameter
|
||||
/// sign as `%%c` (no SHX font has U+2300) and a `%` that would start a code escaped.
|
||||
#[test]
|
||||
fn mtext_contents_write_the_diameter_sign_as_a_code() {
|
||||
let with = |v: &str| DimGeometry { value: v.into(), ..DimGeometry::default() };
|
||||
for (value, contents) in [
|
||||
("⌀60", "%%c60"),
|
||||
("2x ⌀8.5 THRU", "2x %%c8.5 THRU"),
|
||||
("R3.0", "R3.0"),
|
||||
("36.9° ±0.1", "36.9° ±0.1"),
|
||||
("50%", "50%"),
|
||||
("5%⌀6", "5%%%%%c6"),
|
||||
("%%", "%%%%"),
|
||||
("", ""),
|
||||
] {
|
||||
let g = with(value);
|
||||
assert_eq!(g.mtext_contents(), contents, "{value:?}");
|
||||
assert_eq!(decode_value(&g.mtext_contents()), value, "{value:?} reads back");
|
||||
assert!(!g.mtext_contents().contains('⌀'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1531,7 +1531,9 @@ fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> {
|
||||
top: ctx.top,
|
||||
frozen: ctx.frozen,
|
||||
vp_colors: ctx.vp_colors,
|
||||
lt_factor: ctx.lt_factor,
|
||||
// The reference's own linetype scale (group 48) multiplies that of the objects inside,
|
||||
// as LTSCALE does for the drawing (tests/block_ltscale.rs).
|
||||
lt_factor: ctx.lt_factor * if e.common.ltscale.is_finite() && e.common.ltscale > 0.0 { e.common.ltscale } else { 1.0 },
|
||||
block_pstyle: ctx.block_pstyle,
|
||||
// The reference's own, set by the caller (`Builder::transparency` while it draws).
|
||||
block_transparency: ctx.block_transparency,
|
||||
|
||||
@@ -188,8 +188,13 @@ pub fn format_linear(v: f64, f: &NumFormat) -> (String, String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an angle (radians, non-negative sweep) per DIMAUNIT / DIMADEC.
|
||||
/// Format an angle (radians, non-negative sweep) per DIMAUNIT / DIMADEC, with a decimal point.
|
||||
pub fn format_angle(rad: f64, unit: u8, decimals: u8, zin: u8) -> String {
|
||||
format_angle_with_separator(rad, unit, decimals, zin, ".")
|
||||
}
|
||||
|
||||
/// Format an angle with the dimension style's DIMDSEP separator.
|
||||
pub(crate) fn format_angle_with_separator(rad: f64, unit: u8, decimals: u8, zin: u8, separator: &str) -> String {
|
||||
let rad = if rad.is_finite() { rad } else { 0.0 };
|
||||
let deg = rad.to_degrees();
|
||||
match unit {
|
||||
@@ -209,9 +214,9 @@ pub fn format_angle(rad: f64, unit: u8, decimals: u8, zin: u8) -> String {
|
||||
_ => format!("{d}°{m}'{s}\""),
|
||||
}
|
||||
}
|
||||
2 => format!("{}g", format_decimal(deg / 0.9, decimals, zin, ".")),
|
||||
3 => format!("{}r", format_decimal(rad, decimals, zin, ".")),
|
||||
_ => format!("{}°", format_decimal(deg, decimals, zin, ".")),
|
||||
2 => format!("{}g", format_decimal(deg / 0.9, decimals, zin, separator)),
|
||||
3 => format!("{}r", format_decimal(rad, decimals, zin, separator)),
|
||||
_ => format!("{}°", format_decimal(deg, decimals, zin, separator)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! The linetype scale of a block reference (DXF group 48 on the INSERT) multiplies the linetype
|
||||
//! scale of the objects in the block, as LTSCALE does for the whole drawing: a DASHED line inside
|
||||
//! a block inserted at linetype scale 2 has dashes twice as long.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Line, Space, library};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_render::{Kind, Options, build};
|
||||
|
||||
/// A block «B» with a 100-unit DASHED line (pattern 0.5 on, 0.25 off), inserted once at the
|
||||
/// origin with the given linetype scale; the line's own linetype `own` (DASHED or BYBLOCK).
|
||||
fn drawing(ltscale: f64, own: &str) -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
d.linetypes.extend(library::standard_linetypes().into_iter().filter(|l| l.name == "DASHED"));
|
||||
let mut blk = Block::new("B");
|
||||
let mut line = Entity::new(Handle(1), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(100.0, 0.0, 0.0) }));
|
||||
line.common.linetype = own.into();
|
||||
blk.entities.push(line);
|
||||
d.blocks.insert("B".into(), Arc::new(blk));
|
||||
let reference = Common { linetype: "DASHED".into(), ltscale, ..Default::default() };
|
||||
let ins = Insert {
|
||||
block: "B".into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: vec![],
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
d.add(&Space::Model, reference, EntityKind::Insert(ins)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The lengths of the dashes drawn for the reference, all but the last one (which may be cut).
|
||||
fn dashes(d: &Drawing) -> Vec<f64> {
|
||||
let l = build(d, &Space::Model, &Options::default());
|
||||
let mut out: Vec<f64> =
|
||||
l.prims.iter().filter(|p| p.kind == Kind::Polyline).map(|p| l.points(p).windows(2).map(|w| w[0].dist(w[1])).sum()).collect();
|
||||
out.pop();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_linetype_scale_of_a_block_reference_scales_the_dashes_inside() {
|
||||
for own in ["DASHED", "BYBLOCK"] {
|
||||
let one = dashes(&drawing(1.0, own));
|
||||
let two = dashes(&drawing(2.0, own));
|
||||
assert!(!one.is_empty() && one.iter().all(|l| (l - 0.5).abs() < 1e-6), "{own}: {one:?}");
|
||||
assert!(!two.is_empty() && two.iter().all(|l| (l - 1.0).abs() < 1e-6), "{own}: {two:?}");
|
||||
}
|
||||
}
|
||||
@@ -72,3 +72,59 @@ fn dimpost_is_not_added_to_angular_dimensions() {
|
||||
a.text = "<> TYP".into();
|
||||
assert_eq!(text(&a, serde_json::json!({"DIMPOST": "<>mm"})), "90° TYP");
|
||||
}
|
||||
|
||||
fn angular(radians: f64) -> Dimension {
|
||||
let mut d = linear(0.0);
|
||||
d.kind = DimKind::Angular3P;
|
||||
d.p13 = Vec3::new(10.0, 0.0, 0.0);
|
||||
d.p14 = Vec3::new(10.0 * radians.cos(), 10.0 * radians.sin(), 0.0);
|
||||
d.defpt = Vec3::new(5.0 * (radians / 2.0).cos(), 5.0 * (radians / 2.0).sin(), 0.0);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angular_dimensions_use_the_style_decimal_separator() {
|
||||
for (unit, radians, decimals, dot, comma) in
|
||||
[(0, 39.4_f64.to_radians(), 1, "39.4°", "39,4°"), (2, std::f64::consts::FRAC_PI_4, 1, "50.0g", "50,0g"), (3, 1.25, 2, "1.25r", "1,25r")]
|
||||
{
|
||||
let three_point = angular(radians);
|
||||
let mut two_line = three_point.clone();
|
||||
two_line.kind = DimKind::Angular;
|
||||
two_line.p16 = three_point.defpt;
|
||||
two_line.p15 = three_point.p14;
|
||||
two_line.p14 = three_point.p13;
|
||||
two_line.p13 = Vec3::ZERO;
|
||||
two_line.defpt = Vec3::ZERO;
|
||||
for d in [three_point, two_line] {
|
||||
let vars = serde_json::json!({"DIMAUNIT": unit, "DIMADEC": decimals});
|
||||
assert_eq!(text(&d, vars.clone()), dot);
|
||||
let mut vars = vars;
|
||||
vars["DIMDSEP"] = serde_json::json!(",");
|
||||
assert_eq!(text(&d, vars), comma);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angular_decimal_separator_respects_overrides_and_literal_text() {
|
||||
let mut d = angular(39.4_f64.to_radians());
|
||||
d.text = "<> at 1.5".into();
|
||||
d.overrides.insert("DIMDSEP".into(), serde_json::json!(","));
|
||||
assert_eq!(text(&d, serde_json::json!({"DIMADEC": 1, "DIMDSEP": "."})), "39,4° at 1.5");
|
||||
d.overrides.insert("DIMDSEP".into(), serde_json::json!("."));
|
||||
assert_eq!(text(&d, serde_json::json!({"DIMADEC": 1, "DIMDSEP": ","})), "39.4° at 1.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angular_zero_suppression_works_with_a_decimal_comma() {
|
||||
let d = angular(0.5_f64.to_radians());
|
||||
for (zin, expected) in [(0, "0,500°"), (1, ",500°"), (2, "0,5°"), (3, ",5°")] {
|
||||
assert_eq!(text(&d, serde_json::json!({"DIMADEC": 3, "DIMAZIN": zin, "DIMDSEP": ","})), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angular_decimal_separator_keeps_dms_notation() {
|
||||
let d = angular(45.5125_f64.to_radians());
|
||||
assert_eq!(text(&d, serde_json::json!({"DIMAUNIT": 1, "DIMADEC": 3, "DIMDSEP": ","})), "45°30'45\"");
|
||||
}
|
||||
|
||||
@@ -1347,6 +1347,14 @@ fn wheel_zoom_points(events: &[egui::Event], opts: &egui::InputOptions, page: f3
|
||||
if points.is_finite() { points } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Draw the drawing area and handle its input (zoom and pan, picking, the crosshair) for this
|
||||
/// frame.
|
||||
///
|
||||
/// It also owns the cursor-visibility contract: the OS cursor is hidden only while the pointer is
|
||||
/// over the drawing area *and* the canvas is the topmost layer under it, so the drawn crosshair
|
||||
/// replaces the pointer over the drawing alone. A menu bar, an open menu (including its frame, not
|
||||
/// just its items), a tooltip or a floating palette above the canvas keeps the normal OS cursor
|
||||
/// visible (#1701), and so do the menus painted on the canvas itself (see [`over_canvas_menu`]).
|
||||
pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
let full = ui.available_rect_before_wrap();
|
||||
@@ -1381,7 +1389,17 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
i.pointer.button_double_clicked(egui::PointerButton::Primary),
|
||||
)
|
||||
});
|
||||
let inside = hover_pos.is_some_and(|p| rect.contains(p)) && resp.hovered();
|
||||
// The drawing area alone: a menu popup's frame (between its items and its border), a tooltip
|
||||
// or a floating palette lives on a layer above the canvas, where `hovered()` alone still
|
||||
// counts the canvas. Over those the OS cursor stays visible and the crosshair stays put, as
|
||||
// over the menu bar (#1701).
|
||||
let inside = hover_pos.is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id()) && resp.hovered());
|
||||
// The menus the canvas paints itself (selection cycling's rows, Dynamic Input's options) are
|
||||
// menus too, but they share the canvas's layer: `layer_id_at` reports the drawing under them,
|
||||
// so only their rectangles tell them apart from it ([`over_canvas_menu`]). They are excluded
|
||||
// from the cursor contract below, though not from the canvas's input: a click on a row is
|
||||
// taken by the row.
|
||||
let on_menu = hover_pos.is_some_and(|p| over_canvas_menu(app, p));
|
||||
// Whether Ctrl is held right now. Written every frame (not just while a command prompts), so
|
||||
// previews that read `Session::ctrl_down` always see the current state. The engine
|
||||
// deliberately does not carry modifiers through `Input`, which is UI-agnostic (see PR
|
||||
@@ -1766,10 +1784,11 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}));
|
||||
ensure_list(app, &xf_paper, &views, ui.ctx());
|
||||
let sheet = app.canvas.list.as_ref().and_then(|l| l.sheet);
|
||||
let bg = match sheet {
|
||||
let surround = Color32::from_rgb(0x50, 0x57, 0x63);
|
||||
let (bg, paper_rect) = match sheet {
|
||||
Some(sh) => {
|
||||
// Paper space: grey surround, the sheet with a shadow, the printable area dashed.
|
||||
painter.rect_filled(rect, 0.0, Color32::from_rgb(0x50, 0x57, 0x63));
|
||||
painter.rect_filled(rect, 0.0, surround);
|
||||
let a = xf_paper.to_screen(Vec2::new(0.0, sh.size.y));
|
||||
let b = xf_paper.to_screen(Vec2::new(sh.size.x, 0.0));
|
||||
let paper = Rect::from_two_pos(a, b);
|
||||
@@ -1781,12 +1800,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
);
|
||||
let pts = [pa.left_top(), pa.right_top(), pa.right_bottom(), pa.left_bottom(), pa.left_top()];
|
||||
painter.extend(Shape::dashed_line(&pts, Stroke::new(1.0, Color32::from_gray(150)), 4.0, 4.0));
|
||||
Rgb(t.paper.r(), t.paper.g(), t.paper.b())
|
||||
(Rgb(t.paper.r(), t.paper.g(), t.paper.b()), Some(paper))
|
||||
}
|
||||
None => {
|
||||
blockedit::wash(app, &painter, rect);
|
||||
draw_grid(app, &painter, &xf);
|
||||
Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b())
|
||||
(Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b()), None)
|
||||
}
|
||||
};
|
||||
let sel = app.session.selection();
|
||||
@@ -1918,10 +1937,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let gs = app.session.settings.gripsize as f32;
|
||||
for h in sel.iter().take(200) {
|
||||
if let Some(e) = st.doc.entity(*h) {
|
||||
for (gi, g) in e.kind.grips().into_iter().enumerate() {
|
||||
let grips = e.kind.grips();
|
||||
let count = grips.len();
|
||||
for (gi, g) in grips.into_iter().enumerate() {
|
||||
let sp = xf.to_screen(g);
|
||||
if rect.contains(sp) {
|
||||
let r = Rect::from_center_size(sp, vec2(gs * 2.0, gs * 2.0));
|
||||
let r = grip_rect(&e.kind, count, gi, sp, gs);
|
||||
let hot = app.canvas.hot_grips.contains(&(*h, gi));
|
||||
let warm = !hot && hover_pos.is_some_and(|hp| r.expand(2.0).contains(hp));
|
||||
painter.rect_filled(
|
||||
@@ -2006,24 +2027,28 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
crate::cmdline::show(app, ui, full);
|
||||
}
|
||||
// Crosshair cursor.
|
||||
if inside {
|
||||
if inside && !on_menu {
|
||||
// Hide the OS cursor under the drawn crosshair, unless assistive tech needs it (#39).
|
||||
ui.ctx().set_cursor_icon(if app.ui.system_cursor { egui::CursorIcon::Crosshair } else { egui::CursorIcon::None });
|
||||
if let Some(hp) = hover_pos {
|
||||
draw_crosshair(app, &painter, rect, hp, selecting);
|
||||
// Use the painted sheet even in MSPACE, where the editing transform follows a
|
||||
// model viewport. The whole cursor takes one colour from its centre.
|
||||
let ink = paper_rect.map_or(t.crosshair, |paper| crate::theme::crosshair_color(if paper.contains(hp) { t.paper } else { surround }));
|
||||
draw_crosshair(app, &painter, rect, hp, selecting, ink);
|
||||
if app.canvas.cycle_badge && app.canvas.cycle.is_none() {
|
||||
cycle_badge(&painter, hp);
|
||||
}
|
||||
}
|
||||
if app.session.settings.dynmode
|
||||
&& let Some(hp) = hover_pos
|
||||
{
|
||||
// A stretched grip shows its dimensions instead of the pointer boxes; a prompt asking
|
||||
// for a distance shows its one field and keeps the prompt and its options.
|
||||
let fields = crate::grip_dyn::draw(app, &painter);
|
||||
if !fields || app.session.current_prompt().is_some_and(|p| p.distance) {
|
||||
dynamic_input(app, &painter, hp, fields);
|
||||
}
|
||||
}
|
||||
if inside
|
||||
&& app.session.settings.dynmode
|
||||
&& let Some(hp) = hover_pos
|
||||
{
|
||||
// A stretched grip shows its dimensions instead of the pointer boxes; a prompt asking for
|
||||
// a distance shows its one field and keeps the prompt and its options.
|
||||
let fields = crate::grip_dyn::draw(app, &painter);
|
||||
if !fields || app.session.current_prompt().is_some_and(|p| p.distance) {
|
||||
dynamic_input(app, &painter, hp, fields);
|
||||
}
|
||||
}
|
||||
// Over the crosshair, so the row under the pointer reads clearly.
|
||||
@@ -2170,6 +2195,15 @@ fn cycle_badge(p: &egui::Painter, at: Pos2) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `p` lies on a menu the canvas paints itself — the selection cycling list's rows or the
|
||||
/// Dynamic Input options menu's rows. They are menus, so the OS cursor stays visible over them, but
|
||||
/// they share the canvas's layer: `layer_id_at` reports the drawing, and only their rectangles can
|
||||
/// tell them apart from it (#1701).
|
||||
fn over_canvas_menu(app: &CadApp, p: Pos2) -> bool {
|
||||
app.canvas.cycle.as_ref().is_some_and(|c| c.rows.iter().any(|r| r.expand(2.0).contains(p)))
|
||||
|| app.cmd.option_rows.iter().any(|(r, _)| r.expand(2.0).contains(p))
|
||||
}
|
||||
|
||||
/// The selection cycling list beside the click that opened it: a row per object, its type and
|
||||
/// layer, the row under the pointer highlighted; the rows are kept for clicks.
|
||||
fn cycle_list(app: &mut CadApp, p: &egui::Painter) {
|
||||
@@ -2278,8 +2312,8 @@ fn pickbox_half(pickbox: f64) -> Option<f32> {
|
||||
(half.is_finite() && half >= 1.0).then_some(half)
|
||||
}
|
||||
|
||||
fn draw_crosshair(app: &CadApp, p: &egui::Painter, rect: Rect, at: Pos2, pickbox: bool) {
|
||||
let st = Stroke::new(1.0, Tokens::get().crosshair);
|
||||
fn draw_crosshair(app: &CadApp, p: &egui::Painter, rect: Rect, at: Pos2, pickbox: bool, ink: Color32) {
|
||||
let st = Stroke::new(1.0, ink);
|
||||
let len = (rect.height() * app.session.settings.cursorsize as f32 / 100.0).max(12.0);
|
||||
let pb = if pickbox { pickbox_half(app.session.settings.pickbox) } else { None };
|
||||
let gap = pb.unwrap_or(0.0);
|
||||
@@ -2636,11 +2670,18 @@ fn grip_menu(app: &mut CadApp, resp: &egui::Response) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Polyline segment midpoint grips are rectangular; vertex grips remain square.
|
||||
fn grip_rect(kind: &cadcraft_doc::EntityKind, count: usize, index: usize, center: Pos2, size: f32) -> Rect {
|
||||
// The grip list interleaves vertices and available segment midpoints, then any remaining vertices.
|
||||
let midpoint = matches!(kind, cadcraft_doc::EntityKind::LwPolyline(p) if index % 2 == 1 && index / 2 < count.saturating_sub(p.vertices.len()));
|
||||
Rect::from_center_size(center, vec2(size * 2.0, if midpoint { size } else { size * 2.0 }))
|
||||
}
|
||||
|
||||
/// The grip of a selected object under the cursor, if any: (object, grip index).
|
||||
fn grip_at(app: &CadApp, xf: &Xf, hover: Option<Pos2>) -> Option<(Handle, usize)> {
|
||||
let hp = hover?;
|
||||
let st = app.session.state().ok()?;
|
||||
let gs = app.session.settings.gripsize as f32 + 3.0;
|
||||
let gs = app.session.settings.gripsize as f32;
|
||||
let sel = app.session.selection();
|
||||
if !app.session.shows_grips(sel.len()) {
|
||||
return None;
|
||||
@@ -2650,9 +2691,11 @@ fn grip_at(app: &CadApp, xf: &Xf, hover: Option<Pos2>) -> Option<(Handle, usize)
|
||||
if st.doc.layer(&e.common.layer).is_some_and(|l| l.locked) {
|
||||
continue;
|
||||
}
|
||||
for (i, g) in e.kind.grips().into_iter().enumerate() {
|
||||
let grips = e.kind.grips();
|
||||
let count = grips.len();
|
||||
for (i, g) in grips.into_iter().enumerate() {
|
||||
let sp = xf.to_screen(g);
|
||||
if (sp.x - hp.x).abs() <= gs && (sp.y - hp.y).abs() <= gs {
|
||||
if grip_rect(&e.kind, count, i, sp, gs).expand(3.0).contains(hp) {
|
||||
return Some((*h, i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,12 +198,15 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
|ui: &mut egui::Ui, label: &str, dirty: bool, active: bool, closable: bool, w: f32, id: egui::Id| -> (bool, bool, egui::Response) {
|
||||
let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, r.height()));
|
||||
let resp = ui.interact(tr, id, Sense::click());
|
||||
// The close button takes the tab's hover, so `resp.hovered()` would make the tab and
|
||||
// its X flicker while the pointer is on the X.
|
||||
let hovered = ui.rect_contains_pointer(tr);
|
||||
ui.painter().rect_filled(
|
||||
tr,
|
||||
0.0,
|
||||
if active {
|
||||
t.tab_active
|
||||
} else if resp.hovered() {
|
||||
} else if hovered {
|
||||
t.chrome
|
||||
} else {
|
||||
t.chrome_dark
|
||||
@@ -223,7 +226,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let resp = if cut { resp.on_hover_text(format!("{label}{mark}")) } else { resp };
|
||||
let mut close = false;
|
||||
if closable && (active || resp.hovered()) {
|
||||
if closable && (active || hovered) {
|
||||
let cr = Rect::from_center_size(pos2(tr.right() - 11.0, tr.center().y), vec2(12.0, 12.0));
|
||||
let cresp = ui.interact(cr, id.with("x"), Sense::click());
|
||||
icons::paint(ui.painter(), cr, Icon::Close, false);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user