Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor fdb6b6e1f1 ci: pin full shared workflow commit 2026-11-15 03:28:51 +00:00
Dylan M. Taylor b0f91a8116 ci: validate FreeBSD pkgconfig sysroot fix 2026-11-15 03:28:22 +00:00
FredAppsandFredApps 67514021ae Release: CADCraft v0.21.0 on main (#3172)
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-14 21:45:54 +01:00
Zinadin Zidan 8e69890223 DXF: write a MINSERT with the AcDbMInsertBlock marker (#3127) (#3131)
An INSERT repeated in rows or columns (groups 70/71 above 1) was written with
the subclass marker AcDbBlockReference, the marker of a single block
reference. Readers that go by the marker, BricsCAD among them, then drew only
the first copy of the array. The writer now marks such a reference
AcDbMInsertBlock, as AutoCAD does, and a plain INSERT keeps
AcDbBlockReference. The reader does not look at the marker, so CADCraft reads
both back as before.
2026-11-14 15:22:22 +01:00
Dylan M. Taylor fbb083803b Merge pull request #3143 from storytold/codex/craft-dwg-cutover
Move CADCraft DWG bridge to craft-libs
2026-11-13 20:49:19 -05:00
Dylan M. Taylor 54dfb2ee00 Preserve CADCraft DWG diagnostics 2026-11-14 00:32:49 +00:00
FredAppsandFredApps fa7a97728b Naming: use Additional Tools for the utility menu (#2945)
* Naming: use Additional Tools for the utility menu

* test(naming): guard neutral display labels

* test(ui): handle escaped literals in feature-name guard

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-14 01:18:12 +01:00
Dylan M. Taylor 2a9fc8f241 Use shared DWG bridge in CADCraft 2026-11-13 23:50:21 +00:00
Dimitrios Vidalis ddfa833bc2 Grips: hidden objects paint no grips and take no grip hits (#3048)
* ui: omit hidden objects from grip painting and hit tests

Respect the drawing's existing visibility predicate when painting selection
grips and finding the grip under the pointer. A hidden handle restored through
another selection path must not show or activate grips.

The visibility predicate also suppresses grips for entities on off or frozen
layers, not only objects hidden with HIDEOBJECTS.

The frame regression uses ordinary model geometry and HIDEOBJECTS, with an
on-screen selected-object control.

* Grips: a hidden object takes no hot-grip stretch and no dynamic grips

Clicking a hot grip stretches every hot-grip object, and the set can still hold
an object that was hidden after it became hot (its grips are no longer painted,
but the stretch still moved it). `click_grip` now drops hidden objects from the
hot-grip set first.

Dynamic-block grips come from the selection, which Undo or another selection
source can restore with a hidden block reference in it; `dyngrips::of_selection`
now skips hidden references as ordinary grips already do.

Adds a test for each, a doc comment on the test helpers, and `cadcraft-io` as a
dev-dependency of the UI crate so the dynamic grips test can read the synthetic
dynamic-block fixture of the io tests.

* Grips: a hidden object in the selection takes no part in a grip edit

A grip edit took the whole selection as its targets. A hidden object that was back in the
selection (Undo, sssetfirst, a control call) was stretched with a visible object's grip that
shared its point, and the Move, Rotate, Scale and Mirror modes transformed it.

The grip edit now leaves out what the drawing does not show when a grip becomes hot: a hidden
object's own grip is never made hot, the object is not among the targets, and the typed GRIP
command does not find its grips. A grip edit with no grip left does not start.
2026-11-13 14:12:27 +01:00
FredApps bf581c2376 CI: use the shared git-sources check (#3089)
nix.yml ran its own copy of the git-sources job; it now calls
craft-common-actions git-sources.yml. release.yml's copy is removed:
the shared release.yml runs the same check before any build.
2026-11-13 14:05:25 +01:00
69 changed files with 879 additions and 3330 deletions
+3 -2
View File
@@ -26,7 +26,8 @@ permissions:
jobs:
freebsd:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
# Diagnostic-only pin to validate the shared FreeBSD sysroot change.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@25a199081a84a25cb43f860659a93cfad84686a1
with:
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
architectures: '["aarch64"]'
+2 -40
View File
@@ -23,43 +23,5 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/git-sources.yml@main
+1 -43
View File
@@ -28,51 +28,9 @@ jobs:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
release:
needs: [git-sources, credits]
needs: credits
permissions:
# The reusable release workflow tags and uploads the draft release.
contents: write
Generated
+25 -23
View File
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.20.1"
version = "0.21.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -645,7 +645,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.20.1"
version = "0.21.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -661,14 +661,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.20.1"
version = "0.21.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.20.1"
version = "0.21.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -678,7 +678,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.20.1"
version = "0.21.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -687,17 +687,9 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "cadcraft-dwg"
version = "0.20.1"
dependencies = [
"acadrust",
"log",
]
[[package]]
name = "cadcraft-engine"
version = "0.20.1"
version = "0.21.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -718,7 +710,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.20.1"
version = "0.21.0"
dependencies = [
"cadcraft-geom",
"craft-dxf",
@@ -731,22 +723,22 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.20.1"
version = "0.21.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.20.1"
version = "0.21.0"
dependencies = [
"acadrust",
"cadcraft-color",
"cadcraft-doc",
"cadcraft-dwg",
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"craft-dwg",
"craft-dxf",
"craft-pdf",
"image",
@@ -760,7 +752,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.20.1"
version = "0.21.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -775,7 +767,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.20.1"
version = "0.21.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -789,13 +781,14 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.20.1"
version = "0.21.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
"cadcraft-engine",
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-io",
"cadcraft-render",
"chrono",
"craft-ui",
@@ -816,7 +809,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.20.1"
version = "0.21.0"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
@@ -1143,6 +1136,15 @@ name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-dwg"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf#7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf"
dependencies = [
"acadrust",
"log",
]
[[package]]
name = "craft-dxf"
version = "0.1.0"
@@ -5995,7 +5997,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.20.1"
version = "0.21.0"
dependencies = [
"chrono",
"serde_json",
+2 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.20.1"
version = "0.21.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -15,7 +15,7 @@ repository = "https://github.com/storytold/cadcraft"
cadcraft-geom = { path = "crates/geom" }
# DXF tags: the shared reader and writer (moved from crates/dxf; craft-libs#791).
craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "006e4df225fca57e95ead0fb1f91920900798a99" }
cadcraft-dwg = { path = "crates/dwg" }
craft-dwg = { git = "https://github.com/storytold/craft-libs", rev = "7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf" }
cadcraft-color = { path = "crates/color" }
cadcraft-doc = { path = "crates/doc" }
cadcraft-constraints = { path = "crates/constraints" }
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "cadcraft-dwg"
description = "CADCraft DWG bridge: converts DWG to and from DXF bytes using the acadrust crate (MPL-2.0, used unmodified)"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
log = { workspace = true }
# The same targets as build.rs's `no_dwg_bridge` leave acadrust out: wasm32, and FreeBSD aarch64,
# where acadrust 0.6.6 passes a `*const c_char` (u8 there) to its own `*const i8` sysctlbyname.
# 0.6.7 and 0.6.8 fix that but read some damaged DWGs as empty with no note (tests.rs, #800).
[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dependencies]
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
acadrust = "0.6.6"
-12
View File
@@ -1,12 +0,0 @@
//! Sets `no_dwg_bridge` on the targets built without acadrust (see Cargo.toml): the DWG functions
//! there return an error that says to use DXF.
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo::rustc-check-cfg=cfg(no_dwg_bridge)");
let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if arch == "wasm32" || (os == "freebsd" && arch == "aarch64") {
println!("cargo::rustc-cfg=no_dwg_bridge");
}
}
File diff suppressed because it is too large Load Diff
-632
View File
@@ -1,632 +0,0 @@
//! Another application's entities and their classes through the DWG bridge: on reading a DWG,
//! registered entities acadrust has no model for become proxy entities of their class; on writing
//! one, the DWG gets the source DXF's classes in their places, so every proxy names its own class.
use std::collections::{HashMap, HashSet};
use acadrust::CadDocument;
use acadrust::classes::{DxfClass, ProxyFlags};
use acadrust::entities::{EntityType, ExtendedEntityData, ProxyEntityData};
use acadrust::objects::{ObjectType, ProxyPayload};
fn class_number(index: usize) -> Result<i16, String> {
index.checked_add(500).and_then(|n| i16::try_from(n).ok()).ok_or_else(|| "DXF: too many application classes".into())
}
/// After a DWG read: each registered entity of another application's class that acadrust keeps
/// only as its record and opaque data (no decoded properties) becomes a proxy entity of that class
/// (DXF Reference, ACAD_PROXY_ENTITY), with its graphics, data bits, R2007+ strings and object
/// references, so the DXF keeps it; and every proxy's class number becomes 500 plus its class's
/// place in the classes, as a DXF numbers it. A record that can't be converted (its class is
/// missing or not unique) is left out and named in the notes; one with decoded properties stays as
/// read, also named, as its opaque data has no DXF form beside them.
pub(super) fn prepare(doc: &mut CadDocument) -> Vec<String> {
let classes: Vec<_> = doc.classes.iter().cloned().collect();
let mut numbers = HashMap::new();
let mut identities = HashMap::new();
for (index, class) in classes.iter().enumerate() {
let number = class_number(index).ok();
numbers.entry(i32::from(class.class_number)).and_modify(|v| *v = None).or_insert(number.map(i32::from));
let identity = (class.dxf_name.as_str(), class.cpp_class_name.as_str(), class.is_an_entity);
identities.entry(identity).and_modify(|v| *v = None).or_insert(number);
}
let identity = |dxf: &str, cpp: &str, entity: bool| {
identities
.get(&(dxf, cpp, entity))
.copied()
.flatten()
.map(i32::from)
.ok_or_else(|| format!("missing or ambiguous application CLASS for {dxf:.80} ({cpp:.80})"))
};
let Some(dwg_version) = super::native::proxy_dwg_version(doc.dwg_source_version.unwrap_or(doc.version)) else { return Vec::new() };
let maintenance = i32::from(doc.maintenance_version);
let remap = |id: i32| numbers.get(&id).copied().flatten().ok_or_else(|| format!("proxy names missing or ambiguous application class {id}"));
// Only mutate proxies: entities_mut invalidates the decoder's verbatim records for every entity.
let handles: Vec<_> = doc
.entities()
.filter_map(|e| match e {
EntityType::Extended(e) => match &e.data {
ExtendedEntityData::Proxy(_) => Some(e.common.handle),
ExtendedEntityData::RegisteredClass(data) if data.properties.is_empty() || data.payload.bit_count > 0 => Some(e.common.handle),
_ => None,
},
_ => None,
})
.collect();
let mut notes = Vec::new();
let mut entity_count = 0usize;
let mut object_count = 0usize;
let mut semantic = 0usize;
for handle in handles {
// Ok(None): not an application record; Ok(Some(None)): kept as read, with its properties.
let converted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<Option<Option<ExtendedEntityData>>, String> {
let Some(EntityType::Extended(entity)) = doc.get_entity(handle) else { return Ok(None) };
let data = match &entity.data {
ExtendedEntityData::Proxy(data) => {
let mut proxy = data.clone();
proxy.class_id = remap(proxy.class_id)?;
ExtendedEntityData::Proxy(proxy)
}
ExtendedEntityData::RegisteredClass(data) => {
if !data.properties.is_empty() {
return Ok(Some(None));
}
let class_id = identity(&data.dxf_name, &data.cpp_class_name, true)?;
let text_payload = entity.common.raw_record.as_deref().map(registered_text).transpose()?.unwrap_or_default();
ExtendedEntityData::Proxy(ProxyEntityData {
proxy_id: 498,
class_id,
dxf_subclass: String::new(),
version: (maintenance << 16) | dwg_version,
dwg_version,
maintenance_version: maintenance,
from_dxf: false,
graphics: ProxyPayload::from_bytes(entity.common.graphic_data.as_deref().unwrap_or_default()),
payload: data.payload.clone(),
text_payload,
object_ids: data.object_ids.clone(),
})
}
_ => return Ok(None),
};
Ok(Some(Some(data)))
}))
.unwrap_or_else(|_| Err("application record conversion failed".into()));
match converted {
Ok(Some(Some(data))) => {
if let Some(EntityType::Extended(entity)) = doc.get_entity_mut(handle) {
entity.data = data;
}
}
Ok(Some(None)) => semantic = semantic.saturating_add(1),
Ok(None) => {}
Err(why) => {
doc.remove_entity(handle);
entity_count = entity_count.saturating_add(1);
skipped_note(&mut notes, "entity", handle, &why);
}
}
}
doc.objects.retain(|handle, object| {
if let ObjectType::ProxyObject(proxy) = object {
match remap(proxy.class_id) {
Ok(id) => proxy.class_id = id,
Err(why) => {
object_count = object_count.saturating_add(1);
skipped_note(&mut notes, "object", *handle, &why);
return false;
}
}
}
true
});
if entity_count > 0 || object_count > 0 {
notes.insert(0, format!("DWG: partial read; application proxy records left out (entities: {entity_count}, objects: {object_count})"));
}
if semantic > 0 {
notes.push(format!(
"DWG: {semantic} registered entities with decoded properties keep them, without their other data; saving over the original file would lose it"
));
}
notes
}
fn skipped_note(notes: &mut Vec<String>, kind: &str, handle: acadrust::types::Handle, why: &str) {
if notes.len() < 6 {
let why: String = why.chars().take(256).collect();
notes.push(format!("DWG: proxy {kind} {handle} left out: {why}"));
}
}
/// The decoder's registered-class representation omits the separate R2007+ string stream.
fn registered_text(raw: &acadrust::entities::RawRecord) -> Result<ProxyPayload, String> {
use acadrust::io::dwg::DwgVersion;
use acadrust::io::dwg::dwg_stream_readers::object_reader::DwgObjectReader;
use acadrust::io::dwg::dwg_stream_writers::bit_writer::DwgBitWriter;
use acadrust::types::DxfVersion;
if raw.version < DxfVersion::AC1021 {
return Ok(ProxyPayload::default());
}
let version = DwgVersion::from_dxf_version(raw.version).map_err(|e| format!("DWG proxy text: {e}"))?;
let handle_bits = usize::try_from(raw.handle_bits).map_err(|_| "DWG proxy text: negative handle stream size")?;
let total_bits = raw.data.len().checked_mul(8).ok_or("DWG proxy text: record too large")?;
if handle_bits > total_bits {
return Err("DWG proxy text: handle stream exceeds record".into());
}
// Frame the verbatim record for the dependency's checked object reader.
let mut frame = DwgBitWriter::new(version, raw.version);
frame.write_modular_short(raw.data.len());
if raw.version >= DxfVersion::AC1024 {
frame.write_modular_char(handle_bits);
}
frame.write_bytes(&raw.data);
let reader = DwgObjectReader::new(frame.into_bytes(), raw.version, HashMap::new()).map_err(|e| format!("DWG proxy text: {e}"))?;
let (kind, mut streams) = reader.read_record_at(0).map_err(|e| format!("DWG proxy text: {e}"))?;
reader.read_common_entity_data(&mut streams, kind);
if kind == 498 {
// An existing registered-class envelope is a proxy and carries its class name first.
streams.read_variable_text();
} else if kind < 500 {
return Err("DWG proxy text: registered entity has no application class".into());
}
streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?;
let bits = usize::try_from(streams.text_remaining_bits()).map_err(|_| "DWG proxy text: negative string stream size")?;
if bits > total_bits {
return Err("DWG proxy text: string stream exceeds record".into());
}
let count = u32::try_from(bits).map_err(|_| "DWG proxy text: string stream too large")?;
let mut bytes = vec![0; bits.div_ceil(8)];
for (index, byte) in bytes.iter_mut().enumerate() {
for bit in 0..8.min(bits.saturating_sub(index * 8)) {
if streams.read_text_bit() {
*byte |= 0x80 >> bit;
}
}
}
streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?;
Ok(ProxyPayload::from_bits(&bytes, count))
}
/// ASCII DXF pairs decoded with the bridge's public code-page and escape codecs. A malformed
/// pair yields `None`, so both class scans fall back rather than guessing its identity.
pub(super) fn text_pairs(dxf: &[u8]) -> Option<impl Iterator<Item = Option<(i32, String)>> + '_> {
use acadrust::io::dxf::code_page::{decode_legacy_escapes, encoding_from_code_page};
if dxf.starts_with(b"AutoCAD Binary DXF") {
return None;
}
let version =
super::native::header_value(dxf, b"$ACADVER").and_then(|(_, v)| std::str::from_utf8(v).ok()).map(acadrust::DxfVersion::from_version_string);
let unicode = version.is_some_and(|v| v >= acadrust::DxfVersion::AC1021);
let encoding = if version.is_some_and(|v| v < acadrust::DxfVersion::AC1021) {
super::native::header_value(dxf, b"$DWGCODEPAGE").and_then(|(_, page)| std::str::from_utf8(page).ok()).and_then(encoding_from_code_page)
} else {
None
};
let mut lines = dxf.split(|b| *b == b'\n').map(|line| line.strip_suffix(b"\r").unwrap_or(line));
Some(std::iter::from_fn(move || {
let code = lines.next()?;
let Some(value) = lines.next() else { return (!code.trim_ascii().is_empty()).then_some(None) };
let pair = || {
let code = std::str::from_utf8(code).ok()?.trim().parse().ok()?;
let text = if let Some(encoding) = encoding {
encoding.decode(value).0.into_owned()
} else if unicode {
String::from_utf8_lossy(value).into_owned()
} else {
// The decoder uses UTF-8 with Latin-1 fallback when no encoding was declared.
std::str::from_utf8(value).map_or_else(|_| value.iter().map(|&b| char::from(b)).collect(), str::to_string)
};
let text = decode_legacy_escapes(&text);
// DXF's escaped control characters are decoded after CIF/MIF, as in the bridge.
let text = text.replace("^J", "\n").replace("^M", "\r").replace("^I", "\t").replace("^ ", "^");
Some((code, text))
};
Some(pair())
}))
}
/// The CLASS records of an ASCII DXF, in CLASSES order and numbered by place from 500 (DXF
/// Reference, CLASSES; a proxy's group 91 is that number). `None` for a binary DXF (CADCraft's own
/// bridge DXF is ASCII), and for one with a record that can't be written back as it is: no names,
/// flags or instance count out of range, more classes than the numbers allow. Then
/// `proxy_classes` matches the classes by name instead.
pub(super) fn source_classes(dxf: &[u8]) -> Option<Vec<DxfClass>> {
let pairs = text_pairs(dxf)?;
let mut records: Vec<Vec<(i32, String)>> = Vec::new();
let (mut section, mut in_classes) = (false, false);
for pair in pairs {
let (code, value) = pair?;
match (code, value.as_str()) {
(0, "ENDSEC") if in_classes => break,
(0, "SECTION") => section = true,
(2, name) if section => {
in_classes = name == "CLASSES";
section = false;
}
(0, kind) if in_classes => {
if kind != "CLASS" {
return None;
}
records.push(Vec::new());
}
(code, value) if in_classes => records.last_mut()?.push((code, value.to_string())),
_ => section = false,
}
}
records.iter().enumerate().map(|(index, groups)| source_class(groups, class_number(index).ok()?)).collect()
}
/// One CLASS record's groups as the class `number`, or `None` when it can't be written back.
fn source_class(groups: &[(i32, String)], number: i16) -> Option<DxfClass> {
let text = |code| groups.iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone()).unwrap_or_default();
let integer = |code| groups.iter().find(|(c, _)| *c == code).map_or(Some(0), |(_, v)| v.trim().parse::<i64>().ok());
let mut class = DxfClass::new(text(1), text(2));
if class.dxf_name.is_empty() || class.cpp_class_name.is_empty() {
return None;
}
class.application_name = text(3);
class.proxy_flags = ProxyFlags(u16::try_from(integer(90)?).ok()?);
class.instance_count = i32::try_from(integer(91)?).ok()?;
class.was_zombie = integer(280)? != 0;
class.is_an_entity = integer(281)? != 0;
class.item_class_id = if class.is_an_entity { 498 } else { 499 };
class.class_number = number;
Some(class)
}
/// Give a DWG of 2013 or later the source DXF's classes (`classes`, from [`source_classes`]) in
/// their places, with their metadata and duplicate names, so each proxy's DXF class number names
/// its own class; the decoder's default classes the source lacks follow them. A proxy whose number
/// names no source class is left out, as `proxy_classes` leaves it out. Returns how many proxy objects and
/// entities were left out; nothing changes when the drawing has no proxies. `None`, with nothing
/// changed, when the classes would be more than the class numbers allow.
pub(super) fn restore_classes(doc: &mut CadDocument, classes: Vec<DxfClass>) -> Option<(usize, usize)> {
let fits = |number: i32| number.checked_sub(500).and_then(|n| usize::try_from(n).ok()).is_some_and(|i| i < classes.len());
let mut any = false;
let mut entities = Vec::new();
for entity in doc.entities() {
if let EntityType::Extended(e) = entity
&& let ExtendedEntityData::Proxy(p) = &e.data
{
any = true;
if !fits(p.class_id) {
entities.push(e.common.handle);
}
}
}
let mut objects = Vec::new();
for (handle, object) in &doc.objects {
if let ObjectType::ProxyObject(p) = object {
any = true;
if !fits(p.class_id) {
objects.push(*handle);
}
}
}
if !any {
return Some((0, 0));
}
let mut names: HashSet<_> = classes.iter().map(|c| c.dxf_name.to_uppercase()).collect();
let defaults: Vec<_> = doc.classes.iter().filter(|c| names.insert(c.dxf_name.to_uppercase())).cloned().collect();
if let Some(last) = classes.len().checked_add(defaults.len())?.checked_sub(1) {
class_number(last).ok()?;
}
for handle in &entities {
doc.remove_entity(*handle);
}
for handle in &objects {
doc.objects.remove(handle);
}
doc.classes.clear();
for (index, mut class) in classes.into_iter().chain(defaults).enumerate() {
class.class_number = class_number(index).ok()?;
doc.classes.push_preserving(class);
}
Some((objects.len(), entities.len()))
}
#[cfg(test)]
mod tests {
use super::*;
use acadrust::entities::{EntityCommon, ExtendedEntity, RegisteredClassEntityData};
use acadrust::types::DxfVersion;
fn proxy_doc() -> CadDocument {
let mut doc = CadDocument::new();
doc.version = DxfVersion::AC1032;
doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
common: EntityCommon::default(),
data: ExtendedEntityData::Proxy(ProxyEntityData {
proxy_id: 498,
class_id: 501,
dxf_subclass: String::new(),
version: 33,
dwg_version: 33,
maintenance_version: 0,
from_dxf: false,
graphics: ProxyPayload::default(),
payload: ProxyPayload::from_bits(&[0x80], 1),
text_payload: ProxyPayload::default(),
object_ids: vec![],
}),
})))
.unwrap();
doc
}
/// An ASCII DXF of `groups`, `(code, value)` per pair of lines.
fn dxf(groups: &[(i32, &str)]) -> Vec<u8> {
groups.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect::<String>().into_bytes()
}
#[test]
fn source_classes_keep_duplicates_and_metadata_before_defaults() {
let bytes = dxf(&[
(0, "SECTION"),
(2, "HEADER"),
(9, "$DIMSTYLE"),
(2, "CLASSES"),
(0, "ENDSEC"),
(0, "SECTION"),
(2, "CLASSES"),
(0, "CLASS"),
(1, "Объект"),
(2, "First"),
(3, "Первый"),
(90, "1025"),
(91, "7"),
(280, "1"),
(281, "1"),
(0, "CLASS"),
(1, "Объект"),
(2, "Second"),
(3, "Второй"),
(90, "128"),
(281, "1"),
(0, "ENDSEC"),
(0, "EOF"),
]);
let classes = source_classes(&bytes).unwrap();
assert_eq!(classes.len(), 2);
assert_eq!(classes[0].application_name, "Первый");
assert_eq!(classes[0].proxy_flags.0, 1025);
assert_eq!(classes[0].instance_count, 7);
assert!(classes[0].was_zombie);
assert_eq!((classes[0].class_number, classes[1].class_number), (500, 501));
assert_eq!(classes[1].cpp_class_name, "Second");
let mut doc = proxy_doc();
assert_eq!(restore_classes(&mut doc, classes.clone()), Some((0, 0)));
assert_eq!(doc.classes.iter().take(2).cloned().collect::<Vec<_>>(), classes);
// A binary DXF, and a CLASS record that can't be written back as it is, give no classes.
assert!(source_classes(b"AutoCAD Binary DXF\r\n\x1a\0").is_none());
for (code, value) in [(1, ""), (2, ""), (90, "65536"), (91, "9223372036854775807"), (281, "x")] {
let mut class = vec![(1, "A"), (2, "A")];
class.retain(|(c, _)| *c != code);
class.push((code, value));
let mut groups = vec![(0, "SECTION"), (2, "CLASSES"), (0, "CLASS")];
groups.extend(class);
groups.extend([(0, "ENDSEC"), (0, "EOF")]);
assert!(source_classes(&dxf(&groups)).is_none(), "{code} {value}");
}
}
#[test]
fn source_classes_decode_escapes_once_and_respect_legacy_code_pages() {
let body = "0\nCLASS\n1\n\\U+041E\\U+0431\\U+044A\\U+0435\\U+043A\\U+0442\n2\nClass\n3\nCaf\\U+00E9\\U+D83D\\U+DE42\\U+005CU+0041\n281\n1\n0\nENDSEC\n0\nEOF\n";
let text = format!("0\nSECTION\n2\nCLASSES\n{body}");
let classes = source_classes(text.as_bytes()).unwrap();
assert_eq!(classes[0].dxf_name, "Объект");
assert_eq!(classes[0].application_name, "Café🙂\\U+0041");
let prefix =
b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1251\n0\nENDSEC\n0\nSECTION\n2\nCLASSES\n0\nCLASS\n1\n";
let encoded = [prefix.as_slice(), b"\xCF\xF0\xE8\xEC\xE5\xF0", b"\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n"].concat();
assert_eq!(source_classes(&encoded).unwrap()[0].dxf_name, "Пример");
// R2007+ text is UTF-8 even with a legacy code-page declaration.
let modern = String::from_utf8_lossy(prefix).replace("AC1015", "AC1032") + "Пример\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n";
assert_eq!(source_classes(modern.as_bytes()).unwrap()[0].dxf_name, "Пример");
}
#[test]
fn proxies_of_missing_classes_are_left_out() {
for id in [-1, 499, 501, i32::MAX] {
let mut doc = proxy_doc();
if let Some(EntityType::Extended(e)) = doc.entities_mut().next()
&& let ExtendedEntityData::Proxy(p) = &mut e.data
{
p.class_id = id;
}
let mut class = DxfClass::new_entity("ENTITY", "Entity");
class.class_number = 500;
assert_eq!(restore_classes(&mut doc, vec![class]), Some((0, 1)), "{id}");
assert_eq!(doc.entities().count(), 0);
}
// Class 501 is there: the proxy keeps it, in its place.
let mut doc = proxy_doc();
assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A"), DxfClass::new_entity("B", "B")]), Some((0, 0)));
assert_eq!(doc.entities().count(), 1);
assert_eq!(doc.classes.iter().nth(1).map(|c| (c.dxf_name.clone(), c.class_number)), Some(("B".to_string(), 501)));
// Without proxies the classes stay the decoder's.
let mut doc = CadDocument::new();
let before: Vec<_> = doc.classes.iter().cloned().collect();
assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A")]), Some((0, 0)));
assert_eq!(doc.classes.iter().cloned().collect::<Vec<_>>(), before);
}
#[test]
fn unrepresentable_proxies_keep_other_geometry_and_bounded_diagnostics() {
use acadrust::entities::{Line, RawRecord};
use acadrust::objects::ProxyObject;
use acadrust::types::{Handle, Vector3};
for count in [1, 10] {
let mut doc = proxy_doc();
if let Some(EntityType::Extended(e)) = doc.entities_mut().next()
&& let ExtendedEntityData::Proxy(p) = &mut e.data
{
p.class_id = i32::MAX;
}
let template = doc.entities().next().unwrap().clone();
for _ in 1..count {
let mut entity = template.clone();
entity.common_mut().handle = Handle::NULL;
doc.add_entity(entity).unwrap();
}
let object = Handle::new(u64::MAX - 1);
assert!(!doc.objects.contains_key(&object));
doc.objects.insert(object, ObjectType::ProxyObject(ProxyObject { handle: object, class_id: i32::MAX, ..Default::default() }));
let line = doc.add_entity(EntityType::Line(Line::from_points(Vector3::new(2.0, 3.0, 0.0), Vector3::new(12.0, 13.0, 0.0)))).unwrap();
let raw = RawRecord { data: vec![0xAA, 0x55], handle_bits: 0, version: DxfVersion::AC1032 };
doc.get_entity_mut(line).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone()));
let notes = prepare(&mut doc);
assert_eq!(notes.len(), (count + 1).min(6) + 1);
assert!(notes.iter().all(|n| n.starts_with("DWG:")));
assert!(notes[0].contains(&format!("entities: {count}, objects: 1")));
assert_eq!(doc.entities().count(), 1);
assert!(!doc.objects.contains_key(&object));
let Some(EntityType::Line(actual)) = doc.get_entity(line) else { panic!("readable line") };
assert_eq!(actual.start, Vector3::new(2.0, 3.0, 0.0));
assert_eq!(actual.end, Vector3::new(12.0, 13.0, 0.0));
assert_eq!(doc.get_entity(line).unwrap().common().raw_record.as_deref(), Some(&raw));
}
}
#[test]
fn opaque_registered_class_ambiguity_is_not_guessed() {
let mut doc = CadDocument::new();
doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom"));
doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom"));
doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
common: EntityCommon::default(),
data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
dxf_name: "CUSTOM".into(),
cpp_class_name: "Custom".into(),
properties: vec![],
payload: ProxyPayload::from_bits(&[0x80], 1),
object_ids: vec![],
}),
})))
.unwrap();
assert!(prepare(&mut doc).iter().any(|n| n.contains("ambiguous")));
assert_eq!(doc.entities().count(), 0);
assert!(class_number(usize::MAX).is_err());
}
#[test]
fn semantic_records_keep_verbatim_data_and_mixed_payloads_are_reported() {
use acadrust::entities::RawRecord;
use acadrust::objects::{SemanticProperty, SemanticPropertyValue};
for payload in [ProxyPayload::default(), ProxyPayload::from_bits(&[0x80], 1)] {
let mut doc = CadDocument::new();
let raw = RawRecord { data: vec![0xAA], handle_bits: 0, version: DxfVersion::AC1032 };
let handle = doc
.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
common: EntityCommon::default(),
data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
dxf_name: "SEMANTIC".into(),
cpp_class_name: "Semantic".into(),
properties: vec![SemanticProperty {
subclass: "Semantic".into(),
code: 1,
value: SemanticPropertyValue::Text("value".into()),
}],
payload: payload.clone(),
object_ids: vec![],
}),
})))
.unwrap();
doc.get_entity_mut(handle).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone()));
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
if payload.bit_count == 0 {
assert!(prepare(&mut doc).is_empty());
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
} else {
// Kept as read, and named: its opaque data has no DXF form beside the properties.
assert!(prepare(&mut doc).iter().any(|n| n.contains("decoded properties")));
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
}
}
}
}
#[cfg(test)]
mod string_stream_tests {
use super::*;
use acadrust::entities::{EntityCommon, ExtendedEntity, RawRecord, RegisteredClassEntityData};
use acadrust::types::DxfVersion;
use std::io::Cursor;
#[test]
fn registered_transport_recovers_separate_non_byte_aligned_strings() {
for version in [DxfVersion::AC1027, DxfVersion::AC1032] {
let mut source = CadDocument::new();
source.version = version;
source.classes.add_or_update(DxfClass::new_entity("CUSTOM_TEXT", "CustomText"));
let class_id = i32::from(source.classes.get_by_name("CUSTOM_TEXT").unwrap().class_number);
let text = ProxyPayload::from_bits(&[0xA5, 0xC7, 0xE0], 19);
let payload = ProxyPayload::from_bits(&[0x80], 1);
source
.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
common: EntityCommon::default(),
data: ExtendedEntityData::Proxy(ProxyEntityData {
proxy_id: 498,
class_id,
dxf_subclass: "CustomText".into(),
version: 33,
dwg_version: 33,
maintenance_version: 0,
from_dxf: false,
graphics: ProxyPayload::default(),
payload: payload.clone(),
text_payload: text.clone(),
object_ids: vec![],
}),
})))
.unwrap();
let bytes = acadrust::DwgWriter::write_to_vec(&source).unwrap();
let mut decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap();
let entity = decoded.entities().next().unwrap();
let handle = entity.common().handle;
let raw = entity.common().raw_record.clone().unwrap();
assert_eq!(registered_text(&raw).unwrap().data(), text.data());
assert_eq!(registered_text(&raw).unwrap().bit_count, 19);
let EntityType::Extended(entity) = decoded.get_entity_mut(handle).unwrap() else { panic!() };
entity.data = ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
dxf_name: "CUSTOM_TEXT".into(),
cpp_class_name: "CustomText".into(),
properties: vec![],
payload: payload.clone(),
object_ids: vec![],
});
entity.common.raw_record = Some(raw);
assert!(prepare(&mut decoded).is_empty());
for _ in 0..2 {
let mut dxf = Vec::new();
acadrust::DxfWriter::new(&decoded).write_to_writer(&mut dxf).unwrap();
let bytes = crate::dxf_to_dwg_as(&dxf, "AC1032").unwrap();
decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap();
let p = decoded
.entities()
.find_map(|e| match e {
EntityType::Extended(e) => match &e.data {
ExtendedEntityData::Proxy(p) => Some(p),
_ => None,
},
_ => None,
})
.unwrap();
assert_eq!(p.payload.bit_count, 1);
assert_eq!(p.payload.data(), payload.data());
assert_eq!(p.text_payload.bit_count, 19);
assert_eq!(p.text_payload.data(), text.data());
}
}
}
#[test]
fn invalid_string_stream_boundaries_return_errors() {
for handle_bits in [-1, 1000] {
let raw = RawRecord { data: vec![0; 8], handle_bits, version: DxfVersion::AC1032 };
assert!(registered_text(&raw).is_err());
}
}
}
-378
View File
@@ -1,378 +0,0 @@
//! The DWG reader and writer in a child process.
//!
//! An allocation failure or stack overflow in the third-party code ends the process it runs in, and
//! `catch_unwind` can't stop that. So the app runs them in a copy of itself: [`use_own_worker`] says
//! how to start one, and [`serve_if_asked`], first thing in `main`, turns the copy into a worker
//! that reads one request from standard input and writes one reply to standard output. A file that
//! kills the worker then costs that one open or save, and every other open drawing stays safe.
//!
//! Without a worker (libraries, tests, a build that can't start one) [`decode`] and [`encode_as`]
//! run in the calling process, as they always did. There's no memory limit on the worker yet: that
//! takes per-platform system calls, which `unsafe_code = "forbid"` keeps out of this crate.
//!
//! A reply comes from a process that just handled a possibly hostile file, so it is parsed as
//! untrusted input.
use std::ffi::OsString;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::sync::mpsc::{RecvTimeoutError, channel};
use std::time::Duration;
use super::Limits;
/// The argument that makes the app a worker.
pub const WORKER_ARG: &str = "--dwg-worker";
/// Bumped when the messages change: an app updated while running may start a newer copy.
const PROTOCOL: u8 = 2;
const DECODE: u8 = 1;
const ENCODE: u8 = 2;
const CRASH: u8 = 0xff;
/// How long one file may take before the worker is stopped. It can't be interrupted otherwise.
const TIMEOUT: Duration = Duration::from_secs(30 * 60);
/// Most bytes accepted for what a reply carries besides the drawing.
const MAX_NOTES_BYTES: u64 = 64 << 20;
/// Most bytes accepted as a written DWG.
const MAX_DWG_REPLY: u64 = 2 << 30;
/// Most bytes one request holds: the largest input, whichever the direction, and its header.
const MAX_REQUEST: u64 = (2 << 30) + 256;
struct Worker {
program: PathBuf,
args: Vec<OsString>,
}
static WORKER: Mutex<Option<Worker>> = Mutex::new(None);
/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`].
pub fn use_worker(program: PathBuf, args: Vec<OsString>) {
if let Ok(mut w) = WORKER.lock() {
*w = Some(Worker { program, args });
}
}
/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be
/// found, the reader and writer stay in this process.
pub fn use_own_worker() {
match std::env::current_exe() {
Ok(program) => use_worker(program, vec![WORKER_ARG.into()]),
Err(e) => log::warn!("DWG worker: can't find this program: {e}"),
}
}
/// Serve one request and return the exit code if this process was started as a worker.
pub fn serve_if_asked() -> Option<i32> {
(std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock()))
}
/// [`super::dwg_to_dxf_noted`] in the worker, when there is one.
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
// Before starting a process, and so the same limit holds with or without one.
if bytes.len() > limits.max_dwg_bytes {
return super::dwg_to_dxf_noted(bytes, limits);
}
let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES);
match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") {
Some(reply) => reply?,
None => super::dwg_to_dxf_noted(bytes, limits),
}
}
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
encode_noted(dxf, version).map(|(dwg, _)| dwg)
}
/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes
/// back as notes of `TYPE<tab>count`.
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
encode_kept_noted(dxf, version, None)
}
/// [`encode_noted`] for a DXF whose carried-over groups were read from a file of version `kept`
/// ([`super::dxf_to_dwg_kept_noted`]); `None` is [`encode_noted`].
pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
if u8::try_from(version.len()).is_err() {
return Err(super::unsupported(version));
}
// A kept version too long to send is no version CADCraft knows: it changes nothing.
let kept = kept.filter(|k| u8::try_from(k.len()).is_ok());
let mut header = request(ENCODE, Limits::DEFAULT, version);
push_str(&mut header, kept.unwrap_or(""));
match call(&header, dxf, MAX_DWG_REPLY, "writing this file") {
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(&notes))),
None => encode_here(dxf, version, kept),
}
}
/// The conversion in this process.
fn encode_here(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
match kept {
Some(kept) => super::dxf_to_dwg_kept_noted(dxf, version, kept),
None => super::dxf_to_dwg_noted(dxf, version),
}
}
fn unwritten_from(notes: &[String]) -> super::Unwritten {
notes.iter().filter_map(|n| n.split_once('\t')).filter_map(|(kind, n)| Some((kind.to_string(), n.parse::<usize>().ok()?))).fold(
super::Unwritten::new(),
|mut m, (kind, n)| {
*m.entry(kind).or_insert(0) = n;
m
},
)
}
/// Make the worker abort, as the third-party code can: what the tests prove isolation with.
#[doc(hidden)]
pub fn crash_worker_for_test() -> Result<(), String> {
call(&request(CRASH, Limits::DEFAULT, ""), &[], 1024, "testing").ok_or("no worker")??.map(|_| ())
}
fn request(op: u8, limits: Limits, version: &str) -> Vec<u8> {
let mut r = vec![PROTOCOL, op];
r.extend((limits.max_dwg_bytes as u64).to_le_bytes());
r.extend((limits.max_dxf_bytes as u64).to_le_bytes());
push_str(&mut r, version);
r
}
/// `text` with a one-byte length before it; callers keep it under 256 bytes.
fn push_str(r: &mut Vec<u8>, text: &str) {
r.push(u8::try_from(text.len()).unwrap_or(0));
r.extend(text.as_bytes());
}
type Reply = Result<(Vec<u8>, Vec<String>), String>;
/// Run one request in a worker. `None`: none could be started, so the caller does the work.
/// `Err`: the worker went wrong, and `doing` says at what, for the user.
fn call(header: &[u8], payload: &[u8], reply_limit: u64, doing: &str) -> Option<Result<Reply, String>> {
let mut command = {
let worker = WORKER.lock().ok()?;
let worker = worker.as_ref()?;
let mut c = Command::new(&worker.program);
c.args(&worker.args);
c
};
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
let mut child = match command.spawn() {
Ok(c) => c,
Err(e) => {
log::warn!("DWG worker: can't start it: {e}");
return None;
}
};
let (Some(mut to_child), Some(from_child)) = (child.stdin.take(), child.stdout.take()) else {
let _ = child.kill();
let _ = child.wait();
return None;
};
let received = std::thread::scope(|scope| {
// Written beside the read: a worker that replies before it has read everything, or dies,
// must block neither side. A failed write shows up as the reply going wrong.
scope.spawn(move || {
let _ = to_child.write_all(header).and_then(|()| to_child.write_all(payload));
});
let (tx, rx) = channel();
scope.spawn(move || {
let mut reply = Vec::new();
let read = from_child.take(reply_limit).read_to_end(&mut reply);
let _ = tx.send(read.map(|_| reply));
});
let received = rx.recv_timeout(TIMEOUT);
if matches!(received, Err(RecvTimeoutError::Timeout)) {
let _ = child.kill();
}
received
});
let status = child.wait();
let stopped = || format!("the DWG worker stopped while {doing}; nothing else is affected");
Some(match (received, status) {
(Err(RecvTimeoutError::Timeout), _) => Err(format!("{doing} took too long, so the DWG worker was stopped; nothing else is affected")),
(Ok(Ok(reply)), Ok(status)) if status.success() => parse_reply(reply).ok_or_else(stopped),
_ => Err(stopped()),
})
}
/// Take `n` bytes off the front of `b`.
fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
let (head, tail) = b.split_at_checked(n)?;
*b = tail;
Some(head)
}
fn take_u32(b: &mut &[u8]) -> Option<usize> {
let bytes: [u8; 4] = take(b, 4)?.try_into().ok()?;
usize::try_from(u32::from_le_bytes(bytes)).ok()
}
fn take_u64(b: &mut &[u8]) -> Option<u64> {
Some(u64::from_le_bytes(take(b, 8)?.try_into().ok()?))
}
/// A reply: 0, the note count, each note with its length, then the bytes with their length; or 1
/// and a message. `None` when it isn't one, whole. Takes the reply, because the bytes can be most
/// of the memory the app may use: they are kept, not copied.
fn parse_reply(mut reply: Vec<u8>) -> Option<Reply> {
let total = reply.len();
let mut b = &reply[..];
match *take(&mut b, 1)?.first()? {
0 => {
let count = take_u32(&mut b)?;
let mut notes = Vec::new();
for _ in 0..count {
let n = take_u32(&mut b)?;
notes.push(String::from_utf8_lossy(take(&mut b, n)?).into_owned());
}
let n = usize::try_from(take_u64(&mut b)?).ok()?;
if b.len() != n {
return None;
}
reply.drain(..total - n);
Some(Ok((reply, notes)))
}
1 => Some(Err(String::from_utf8_lossy(b).into_owned())),
_ => None,
}
}
fn write_reply(out: &mut impl Write, reply: Reply) -> std::io::Result<()> {
match reply {
Ok((bytes, notes)) => {
out.write_all(&[0])?;
out.write_all(&u32::try_from(notes.len()).unwrap_or(0).to_le_bytes())?;
for note in &notes {
out.write_all(&u32::try_from(note.len()).unwrap_or(0).to_le_bytes())?;
if u32::try_from(note.len()).is_ok() {
out.write_all(note.as_bytes())?;
}
}
out.write_all(&(bytes.len() as u64).to_le_bytes())?;
out.write_all(&bytes)?;
}
Err(e) => {
out.write_all(&[1])?;
out.write_all(e.as_bytes())?;
}
}
out.flush()
}
/// What a request asks for, or why it can't be served.
fn handle(mut b: &[u8]) -> Reply {
let bad = || "the DWG worker got a request it can't read (a different CADCraft version?)".to_string();
if take(&mut b, 1).and_then(<[u8]>::first) != Some(&PROTOCOL) {
return Err(bad());
}
let op = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
let max_dwg_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
let max_dxf_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
let version_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
match op {
DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }),
ENCODE => {
// The version the DXF's carried-over groups were read from; empty for none.
let kept_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
let kept = take(&mut b, usize::from(kept_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
encode_here(b, version, (!kept.is_empty()).then_some(kept))
.map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect()))
}
CRASH => std::process::abort(),
_ => Err(bad()),
}
}
/// Serve the one request on `input`. The exit code is 0 unless the reply couldn't be sent.
fn serve(input: impl Read, mut output: impl Write) -> i32 {
let mut request = Vec::new();
let reply = match input.take(MAX_REQUEST).read_to_end(&mut request) {
Ok(_) => handle(&request),
Err(e) => Err(format!("the DWG worker could not read its request: {e}")),
};
i32::from(write_reply(&mut output, reply).is_err())
}
#[cfg(test)]
mod tests {
use super::*;
fn wire(reply: Reply) -> Vec<u8> {
let mut out = Vec::new();
write_reply(&mut out, reply).unwrap();
out
}
#[test]
fn what_the_writer_left_out_survives_the_reply() {
let notes = vec!["ACME_WIDGET\t2".to_string(), "not a count".to_string(), "X\tmany".to_string()];
assert_eq!(unwritten_from(&notes), [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
}
#[test]
fn replies_round_trip() {
let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()]));
assert_eq!(parse_reply(wire(ok.clone())), Some(ok));
let failed: Reply = Err("DWG: no".into());
assert_eq!(parse_reply(wire(failed.clone())), Some(failed));
}
#[test]
fn a_reply_cut_short_or_made_up_is_refused() {
let whole = wire(Ok((b"bytes".to_vec(), vec!["note".into()])));
for n in 0..whole.len() {
assert_eq!(parse_reply(whole[..n].to_vec()), None, "cut at {n}");
}
assert_eq!(parse_reply(vec![7, 0, 0]), None);
// A note count far beyond what follows ends at the first missing note.
assert_eq!(parse_reply(vec![0, 0xff, 0xff, 0xff, 0xff]), None);
assert_eq!(parse_reply(vec![0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), None);
}
#[test]
fn a_request_it_cannot_read_is_an_error_not_a_crash() {
for request in [
&[][..],
&[PROTOCOL + 1, DECODE],
&[PROTOCOL],
&[PROTOCOL, DECODE, 1, 2, 3],
&[PROTOCOL, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
] {
assert!(handle(request).is_err(), "{request:?}");
}
}
/// An encode request carries the kept version after the target one; a request without it is
/// one it can't read, and one with it reaches the converter.
#[test]
fn an_encode_request_carries_the_kept_version() {
let bad = "the DWG worker got a request it can't read (a different CADCraft version?)";
let short = self::request(ENCODE, Limits::DEFAULT, "AC1032");
assert_eq!(handle(&short).unwrap_err(), bad);
for kept in ["", "AC1032"] {
let mut request = self::request(ENCODE, Limits::DEFAULT, "AC1032");
push_str(&mut request, kept);
request.extend(b"not a drawing");
let err = handle(&request).unwrap_err();
assert_ne!(err, bad, "kept {kept:?}");
}
}
#[test]
fn serving_reports_a_decode_failure_as_a_reply() {
let mut request = self::request(DECODE, Limits::DEFAULT, "");
request.extend(b"AC1032 not really a drawing");
let mut out = Vec::new();
assert_eq!(serve(&request[..], &mut out), 0);
assert!(matches!(parse_reply(out.clone()), Some(Err(_))), "{out:?}");
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
pub(super) fn blockreplace_spec() -> CommandSpec {
CommandSpec::new("blockreplace", "Replace Block...", run_blockreplace)
.menu(&["Tools", "Express Tools", "Replace Block..."])
.menu(&["Tools", "Additional Tools", "Replace Block..."])
.alias(&["-blockreplace"])
.params("{block (the block whose references are replaced), with (the replacement block), purge?: bool (default false: remove the replaced block's definition when nothing references it any more)} → {block, with, replaced, purged}")
.interactive(|_| Ok(Box::new(BlockreplaceM::default())))
+1 -1
View File
@@ -12,7 +12,7 @@ use crate::{Result, Session};
pub(super) fn burst_spec() -> CommandSpec {
CommandSpec::new("burst", "Explode Attributes to Text", run_burst)
.menu(&["Tools", "Express Tools", "Explode Attributes to Text"])
.menu(&["Tools", "Additional Tools", "Explode Attributes to Text"])
.params("{handles?} (block references; other objects are left alone) → {handles}")
.interactive(|_| Ok(Box::new(SelectRun::new("burst", "BURST"))))
}
+1 -1
View File
@@ -20,7 +20,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("breakline", "Break-line Symbol", run_breakline)
.menu(&["Tools", "Express Tools", "Break-line Symbol"])
.menu(&["Tools", "Additional Tools", "Break-line Symbol"])
.params(
"{p1, p2, at?: [x,y] (symbol location; default the midpoint), size? (default 1, times DIMSCALE), \
extension? (past both points; default 0), block? (a block with two points on Defpoints; default the built-in symbol)} → {handle}",
+22 -5
View File
@@ -43,7 +43,8 @@ pub(crate) struct GripEdit {
hot: Vec<(Handle, usize)>,
/// Typed `GRIP`: still collecting grips ("Select grips").
picking: bool,
/// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the selection.
/// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the objects of the
/// selection that the drawing shows.
targets: Vec<Handle>,
base: Vec2,
mode: GripMode,
@@ -69,6 +70,12 @@ fn positive(v: Option<f64>) -> Result<f64> {
v.filter(|f| f.is_finite() && *f > 1e-12).ok_or_else(|| other("Requires a positive number."))
}
/// Whether the drawing shows `h`. A hidden object (HIDEOBJECTS, an off or frozen layer) takes no
/// part in a grip edit, even when Undo or a selection command put it back into the selection.
fn shown(s: &Session, h: Handle) -> bool {
s.doc().is_ok_and(|d| d.entity(h).is_some_and(|e| d.is_visible(e)))
}
impl GripEdit {
fn new(s: &Session, hot: Vec<(Handle, usize)>) -> Result<Self> {
let mut g = GripEdit {
@@ -87,16 +94,21 @@ impl GripEdit {
Ok(g)
}
/// Make grip `index` of `h` hot (the first one becomes the base point).
/// Make grip `index` of `h` hot (the first one becomes the base point). The grip of an object
/// the drawing does not show is left out, and so are the hidden objects of the selection.
fn add_hot(&mut self, s: &Session, (h, i): (Handle, usize)) -> Result<()> {
if self.hot.contains(&(h, i)) || self.hot.len() >= MAX_HOT {
return Ok(());
}
let p = s.grips_of(h)?.get(i).copied().ok_or_else(|| other("no such grip"))?;
// After the lookup: a missing object or grip stays the error it was.
if !shown(s, h) {
return Ok(());
}
if self.hot.is_empty() {
self.base = p;
self.targets.push(h);
self.targets.extend(s.selection().into_iter().filter(|x| *x != h));
self.targets.extend(s.selection().into_iter().filter(|x| *x != h && shown(s, *x)));
} else if !self.targets.contains(&h) {
self.targets.push(h);
}
@@ -113,7 +125,7 @@ impl GripEdit {
if super::curves::is_locked(s, h) {
continue;
}
let Some(e) = d.entity(h) else { continue };
let Some(e) = d.entity(h).filter(|e| d.is_visible(e)) else { continue };
let gs = e.kind.grips();
// The nearest grip of this object within the tolerance.
let best = gs
@@ -470,7 +482,9 @@ impl Interactive for GripEdit {
impl Session {
/// Make grips hot and start editing them (the UI's click on a grip; Shift+click collects
/// several first). `hot` lists (object, grip index); the first is the base grip. The grip
/// modes and their options then run at the command line like any prompt.
/// modes and their options then run at the command line like any prompt. Grips of objects the
/// drawing does not show are left out (the first grip left is the base grip), and it is an
/// error when none is left.
pub fn start_grip_edit(&mut self, hot: &[(Handle, usize)]) -> Result<()> {
if self.running.is_some() {
self.cancel();
@@ -479,6 +493,9 @@ impl Session {
return Err(other("no grip"));
}
let g = GripEdit::new(self, hot.to_vec())?;
if g.hot.is_empty() {
return Err(other("no grip"));
}
let label = g.label();
let machine = crate::guard::Guarded::wrap(Box::new(g));
let st = self.state()?;
+1 -1
View File
@@ -65,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params("{handle (block reference), pick?: [x,y] (nearest nested object; default all), delta? | from,to}")
.interactive(|_| Ok(Box::new(NcopyM::default()))),
CommandSpec::new("mocoro", "Move/Copy/Rotate", mocoro::run_mocoro)
.menu(&["Tools", "Express Tools", "Move/Copy/Rotate"])
.menu(&["Tools", "Additional Tools", "Move/Copy/Rotate"])
.params("{handles?, base: [x,y], ops: [{move: [x,y]} | {copy: [x,y]} | {rotate: degrees} | {scale: factor} | {base: [x,y]}]} → {handles (copies)}")
.interactive(|_| Ok(Box::new(mocoro::MocoroM::default()))),
CommandSpec::new("chspace", "Change Space", run_chspace)
+1 -1
View File
@@ -18,7 +18,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("txt2mtxt", "Convert Text to Mtext", run_txt2mtxt)
.menu(&["Tools", "Express Tools", "Convert Text to Mtext"])
.menu(&["Tools", "Additional Tools", "Convert Text to Mtext"])
.params(
"{handles? (default the selection; TEXT, and MTEXT when combining), combine? (default true: one MTEXT; false: one per TEXT), \
sort?: \"topdown\" (default) | \"selection\", wrap? (default false: one line per text)} → {handles}",
+10
View File
@@ -1551,3 +1551,13 @@ fn setvar_rejects_non_finite_text_for_real_variables() {
}
}
}
#[test]
fn additional_tools_keeps_compatible_command_ids() {
assert_eq!(crate::cmd::resolve_alias("-blockreplace"), "blockreplace");
for id in ["blockreplace", "burst", "breakline", "mocoro", "txt2mtxt"] {
let command = crate::cmd::find_command(id).expect("existing command id");
assert!(command.menu.contains(&"Additional Tools"), "{id}: {:?}", command.menu);
assert!(!command.menu.contains(&"Express Tools"));
}
}
@@ -0,0 +1,161 @@
//! A hidden object that is back in the selection takes no part in a grip edit: Undo, `sssetfirst`
//! or a control call can restore it there, and the grips it shares with a visible object must not
//! drag it along.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Handle};
use cadcraft_engine::geom::Vec2;
use serde_json::json;
/// Draws a line from `a` to `b` and returns its handle.
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
let r = s.execute("line", &json!({ "points": [a, b] })).unwrap();
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
}
/// The two ends of the line `h`.
fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) {
match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::Line(l) => (l.a.xy(), l.b.xy()),
k => panic!("not a line: {k:?}"),
}
}
/// Two lines sharing the end (0, 0), the second one hidden, both in the selection.
fn shared_corner() -> (Session, Handle, Handle) {
let mut s = Session::new();
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let hidden = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
s.execute("hideobjects", &json!({ "handles": [hidden.hex()] })).unwrap();
let d = s.doc().unwrap();
assert!(!d.is_visible(d.entity(hidden).unwrap()), "the second line must be hidden");
s.set_selection(vec![shown, hidden]);
(s, shown, hidden)
}
/// The index of the grip of `h` at `p`.
fn grip_at(s: &Session, h: Handle, p: Vec2) -> usize {
s.grips_of(h).unwrap().iter().position(|g| g.near(p, 1e-9)).unwrap()
}
/// Stretching the shared corner moves the visible line only; the hidden one stays where it was.
#[test]
fn a_hidden_selected_line_is_not_stretched_with_a_shared_grip() {
let (mut s, shown, hidden) = shared_corner();
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
s.start_grip_edit(&[(shown, i)]).unwrap();
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
let (start, end) = ends(&s, hidden);
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
}
/// The Move mode of a grip edit moves the visible selection only.
#[test]
fn a_hidden_selected_line_is_not_moved_by_the_move_mode_of_a_grip_edit() {
let (mut s, shown, hidden) = shared_corner();
let i = grip_at(&s, shown, Vec2::new(10.0, 0.0));
s.start_grip_edit(&[(shown, i)]).unwrap();
s.cmdline("").unwrap();
assert!(s.prompt_text().contains("** MOVE **"), "{}", s.prompt_text());
s.cmdline("12,5").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(2.0, 5.0), 1e-9) && end.near(Vec2::new(12.0, 5.0), 1e-9), "{start:?} {end:?}");
let (start, end) = ends(&s, hidden);
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
}
/// The grip of a hidden object is never made hot, so a grip edit cannot start on it.
#[test]
fn the_grip_of_a_hidden_object_cannot_be_made_hot() {
let (mut s, _, hidden) = shared_corner();
let before = s.state().unwrap().doc.clone();
let err = s.start_grip_edit(&[(hidden, 0)]).unwrap_err().to_string();
assert!(err.contains("no grip"), "{err}");
assert!(s.running.is_none());
assert!(std::sync::Arc::ptr_eq(&before, &s.state().unwrap().doc), "nothing may change");
}
/// A hidden object's grip among the hot grips is left out, wherever it stands in the list: the
/// first grip the drawing shows is the base grip, and only the visible line is stretched.
#[test]
fn a_hidden_grip_among_the_hot_grips_is_left_out() {
for hidden_first in [false, true] {
let (mut s, shown, hidden) = shared_corner();
let mut hot = vec![(shown, grip_at(&s, shown, Vec2::new(0.0, 0.0))), (hidden, grip_at(&s, hidden, Vec2::new(0.0, 10.0)))];
if hidden_first {
hot.reverse();
}
s.start_grip_edit(&hot).unwrap();
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "hidden first: {hidden_first}: {start:?} {end:?}");
let (start, end) = ends(&s, hidden);
assert!(
start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9),
"hidden first: {hidden_first}: the hidden line moved: {start:?} {end:?}"
);
}
}
/// Only what the drawing does not show is left out: a grip of an object that does not exist is
/// still an error, also after a good one.
#[test]
fn a_missing_object_among_the_hot_grips_is_still_an_error() {
let (mut s, shown, _) = shared_corner();
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
let err = s.start_grip_edit(&[(shown, i), (Handle(0xFFFF), 0)]).unwrap_err().to_string();
assert!(err.contains("no such object"), "{err}");
assert!(s.running.is_none());
}
/// A selected line on a layer that is off is not shown either, and is not stretched with a
/// shared grip.
#[test]
fn a_selected_line_on_a_layer_that_is_off_is_not_stretched_with_a_shared_grip() {
let mut s = Session::new();
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
s.execute("layer.new", &json!({ "name": "Guides", "current": true })).unwrap();
let off = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
s.execute("layer.current", &json!({ "name": "0" })).unwrap();
s.execute("layer.set", &json!({ "name": "Guides", "on": false })).unwrap();
let d = s.doc().unwrap();
assert!(!d.is_visible(d.entity(off).unwrap()), "the second line must be on a layer that is off");
s.set_selection(vec![shown, off]);
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
s.start_grip_edit(&[(shown, i)]).unwrap();
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
let (start, end) = ends(&s, off);
assert!(
start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9),
"the line on the layer that is off moved: {start:?} {end:?}"
);
}
/// The typed GRIP command finds no grip where only a hidden object has one, and at the shared
/// corner it makes only the visible line's grip hot.
#[test]
fn the_typed_grip_command_finds_no_grip_of_a_hidden_object() {
let (mut s, shown, hidden) = shared_corner();
s.cmdline("grip").unwrap();
assert!(s.prompt_text().contains("Select grip"), "{}", s.prompt_text());
// Only the hidden line has a grip at (0, 10): the command says so and asks again.
s.cmdline("0,10").unwrap();
assert!(s.log.iter().any(|l| l.contains("No grip of a selected object there")), "{:?}", s.log);
s.cmdline("0,0").unwrap();
s.cmdline("").unwrap();
assert!(s.prompt_text().contains("** STRETCH **"), "{}", s.prompt_text());
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
let (start, end) = ends(&s, hidden);
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
}
+1
View File
@@ -62,6 +62,7 @@ mod hatch_dialog_helpers;
mod hatch_numeric_validation;
mod hatch_options;
mod helix;
mod hidden_grip_targets;
mod inquiry_keywords;
mod inquiry_variables;
mod inquiry_z;
+2 -2
View File
@@ -13,7 +13,7 @@ cadcraft-geom = { workspace = true }
cadcraft-color = { workspace = true }
cadcraft-doc = { workspace = true }
craft-dxf = { workspace = true }
cadcraft-dwg = { workspace = true }
craft-dwg = { workspace = true }
cadcraft-render = { workspace = true }
cadcraft-fonts = { workspace = true }
thiserror = { workspace = true }
@@ -29,7 +29,7 @@ tiny-skia = { workspace = true }
# Reads plotted PDFs back.
craft-pdf = { workspace = true, features = ["read"] }
# Inspect DWG output only where cadcraft-dwg builds its native bridge. An unconditional
# Inspect DWG output only where craft-dwg builds its native bridge. An unconditional
# dev-dependency also builds acadrust during --all-targets, breaking FreeBSD aarch64 (#2526).
[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dev-dependencies]
acadrust = "0.6.6"
+5 -2
View File
@@ -344,7 +344,10 @@ fn write_text_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo
fn write_insert_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) {
if let EntityKind::Insert(i) = &e.kind {
w.s(0, "INSERT");
common(w, e, owner, paper, "AcDbBlockReference");
// A reference repeated in rows or columns (MINSERT, groups 70/71) is an AcDbMInsertBlock;
// readers that go by the marker draw only the first copy of an AcDbBlockReference.
let array = i.cols > 1 || i.rows > 1;
common(w, e, owner, paper, if array { "AcDbMInsertBlock" } else { "AcDbBlockReference" });
if !i.attribs.is_empty() {
w.i(66, 1);
}
@@ -354,7 +357,7 @@ fn write_insert_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: b
w.f(42, i.scale.y);
w.f(43, i.scale.z);
w.f(50, i.rotation.to_degrees());
if i.cols > 1 || i.rows > 1 {
if array {
w.i(70, i64::from(i.cols));
w.i(71, i64::from(i.rows));
w.f(44, i.col_spacing);
+23 -10
View File
@@ -61,11 +61,24 @@ pub enum IoError {
pub type Result<T> = std::result::Result<T, IoError>;
/// How a program runs the DWG reader and writer in a child process (`cadcraft_dwg::worker`).
/// Restore CADCraft's public diagnostics after the shared byte bridge supplies generic wording.
fn cadcraft_dwg_message(message: &str) -> String {
message
.replace("accepted by this bridge", "CADCraft opens")
.replace("types this bridge can't preserve", "types CADCraft doesn't keep")
.replace("this bridge writes", "CADCraft writes")
.replace("accepted for DWG conversion", "CADCraft converts to DWG")
}
fn dwg_error(message: String) -> IoError {
IoError::Format(cadcraft_dwg_message(&message))
}
/// How a program runs the DWG reader and writer in a child process (`craft_dwg::worker`).
pub mod dwg_worker {
pub use cadcraft_dwg::{WORKER_ARG, serve_if_asked, use_own_worker};
pub use craft_dwg::{WORKER_ARG, serve_if_asked, use_own_worker};
#[cfg(all(not(target_arch = "wasm32"), not(all(target_os = "freebsd", target_arch = "aarch64"))))]
pub use cadcraft_dwg::{crash_worker_for_test, use_worker};
pub use craft_dwg::{crash_worker_for_test, use_worker};
}
fn ext(name: impl AsRef<Path>) -> String {
@@ -81,10 +94,10 @@ pub fn read(bytes: &[u8], name: impl AsRef<Path>) -> Result<Drawing> {
/// [`read`], also returning the DXF rendition a DWG was read through (`None` for other files),
/// which the round-trip harness counts.
pub(crate) fn read_source(bytes: &[u8], name: &Path) -> Result<(Drawing, Option<Vec<u8>>)> {
if cadcraft_dwg::is_dwg(bytes) {
let (dxf, notes) = cadcraft_dwg::decode(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?;
if craft_dwg::is_dwg(bytes) {
let (dxf, notes) = craft_dwg::decode(bytes, craft_dwg::Limits::DEFAULT).map_err(dwg_error)?;
let mut d = dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")))?;
d.read_notes = notes;
d.read_notes = notes.into_iter().map(|note| cadcraft_dwg_message(&note)).collect();
return Ok((d, Some(dxf)));
}
let d = match (Format::from_path(name), ext(name).as_str()) {
@@ -132,21 +145,21 @@ pub fn write_with(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Re
/// [`write_with`], also returning what the file leaves out of the drawing: for a DWG, how many
/// records of each DXF type the DWG writer can't write (other programs' custom entities and
/// objects); for other formats, nothing.
pub fn write_noted(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Result<(Vec<u8>, cadcraft_dwg::Unwritten)> {
pub fn write_noted(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Result<(Vec<u8>, craft_dwg::Unwritten)> {
let name = name.as_ref();
if matches!(Format::from_path(name), Some(Format::Dwg)) && !opts.binary {
return write_dwg(d, opts.version.unwrap_or_else(|| save_version(d).max(Version::R2004)));
}
write_with(d, name, opts).map(|b| (b, cadcraft_dwg::Unwritten::new()))
write_with(d, name, opts).map(|b| (b, craft_dwg::Unwritten::new()))
}
/// The DWG bridge reads our R2000 DXF and writes the DWG at `version`, in the DWG worker when
/// there is one. The objects CADCraft keeps without modelling them are still in the groups of the
/// file they came from (`Foreign::version`, not `$ACADVER`, which SAVEAS changes), and the bridge
/// reads them as that version.
fn write_dwg(d: &Drawing, version: Version) -> Result<(Vec<u8>, cadcraft_dwg::Unwritten)> {
fn write_dwg(d: &Drawing, version: Version) -> Result<(Vec<u8>, craft_dwg::Unwritten)> {
let bridge = dxf_write::dwg_bridge(d, version);
cadcraft_dwg::encode_kept_noted(bridge.as_bytes(), version.code(), d.foreign.version.as_deref()).map_err(IoError::Format)
craft_dwg::encode_kept_noted(bridge.as_bytes(), version.code(), d.foreign.version.as_deref()).map_err(dwg_error)
}
/// Like [`write`], but image formats (PNG, SVG, PDF) show `window` (a model-space rectangle in
+2 -2
View File
@@ -36,7 +36,7 @@ impl Format {
}
/// The format of a file's bytes: DWG by its magic, otherwise DXF.
pub fn of(bytes: &[u8]) -> Format {
if cadcraft_dwg::is_dwg(bytes) { Format::Dwg } else { Format::Dxf }
if craft_dwg::is_dwg(bytes) { Format::Dwg } else { Format::Dxf }
}
fn other(self) -> Format {
match self {
@@ -279,7 +279,7 @@ pub fn check_bytes(bytes: &[u8], rel: &str, opts: &CorpusOptions) -> FileReport
let format = Format::of(bytes);
let mut f = FileReport { path: rel.to_string(), format: Some(format), bytes: bytes.len() as u64, ..FileReport::default() };
if format == Format::Dwg {
f.version = cadcraft_dwg::version(bytes).unwrap_or_default();
f.version = craft_dwg::version(bytes).unwrap_or_default();
}
let start = Instant::now();
let name = format!("source.{}", format.ext());
+42 -22
View File
@@ -797,7 +797,7 @@ fn landscape_media_keeps_its_size_order_and_rotation() {
assert_eq!(p.paper, media);
let dwg = write(&d, "t.dwg").unwrap();
let back = String::from_utf8_lossy(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned();
let back = String::from_utf8_lossy(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned();
assert_eq!(plot_paper_groups(&back, "Layout1"), (420.0, 297.0, rotation), "DWG, landscape {landscape}");
let r = read(&dwg, "t.dwg").unwrap();
let p = &r.layout("Layout1").unwrap().page;
@@ -844,7 +844,7 @@ fn a_files_own_paper_groups_are_written_back() {
assert_eq!((p.width_mm, p.height_mm, p.landscape), (pw, ph, landscape), "{media} {groups:?} read");
assert_eq!(plot_paper_groups(&write_dxf(&r), "Layout1"), groups, "{media} {groups:?} DXF");
let dwg = write(&r, "t.dwg").unwrap();
let back = String::from_utf8_lossy(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned();
let back = String::from_utf8_lossy(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned();
assert_eq!(plot_paper_groups(&back, "Layout1"), groups, "{media} {groups:?} DWG");
let r = read(&dwg, "t.dwg").unwrap();
assert_eq!(plot_paper_groups(&write_dxf(&r), "Layout1"), groups, "{media} {groups:?} DWG read and saved again");
@@ -1234,7 +1234,7 @@ fn viewport_frozen_layers_survive_dwg() {
let back = read(&dwg, "t.dwg").unwrap();
assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]);
// The DWG holds them in group 331, what AutoCAD reads.
let p = pairs(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
let p = pairs(&String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
assert_eq!(frozen_331(&p), [layer_handle(&p, "Walls"), layer_handle(&p, "A B")]);
}
@@ -1376,8 +1376,8 @@ fn generated_arrowhead_blocks_are_not_kept_as_user_blocks() {
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();
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let from_dwg = read_dxf(&craft_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");
@@ -1408,8 +1408,8 @@ fn file_arrowhead_blocks_with_standard_names_are_kept() {
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();
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let from_dwg = read_dxf(&craft_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}");
@@ -1479,8 +1479,8 @@ fn many_entities(n: usize) -> Drawing {
#[cfg(not(target_arch = "wasm32"))]
fn ac1024_dwg(n: usize) -> Vec<u8> {
let dxf = write_dxf(&many_entities(n)).replacen("AC1015", "AC1024", 1);
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some("AC1024"));
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
assert_eq!(craft_dwg::version(&dwg).as_deref(), Some("AC1024"));
dwg
}
@@ -1492,7 +1492,7 @@ fn ac1024_dwg(n: usize) -> Vec<u8> {
fn ac1024_dwg_with_many_entities_opens() {
let n = 30_000;
let dwg = ac1024_dwg(n);
let dxf = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap();
let dxf = craft_dwg::dwg_to_dxf(&dwg).unwrap();
let tags = craft_dxf::parse(&dxf).unwrap().len();
assert!(dxf.len() < dwg.len() * 20, "DXF {} bytes from {} DWG bytes", dxf.len(), dwg.len());
assert!(tags < dwg.len(), "{tags} group codes from {} DWG bytes", dwg.len());
@@ -1509,16 +1509,16 @@ fn ac1024_dwg_with_many_entities_opens() {
#[test]
fn oversized_dwg_and_dxf_are_rejected_early() {
let dwg = ac1024_dwg(3000);
let full = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap().len();
let full = craft_dwg::dwg_to_dxf(&dwg).unwrap().len();
assert!(full > 400_000, "{full}");
let limits = cadcraft_dwg::Limits { max_dxf_bytes: 300_000, ..cadcraft_dwg::Limits::DEFAULT };
let e = cadcraft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
let limits = craft_dwg::Limits { max_dxf_bytes: 300_000, ..craft_dwg::Limits::DEFAULT };
let e = craft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
assert!(e.starts_with("DWG: converting this 0."), "{e}");
assert!(e.contains("MB file produced more than 0.3 MB of drawing data (the limit)"), "{e}");
let limits = cadcraft_dwg::Limits { max_dwg_bytes: 100_000, ..cadcraft_dwg::Limits::DEFAULT };
let e = cadcraft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
assert!(e.starts_with("DWG: the file is 0.") && e.ends_with("MB, larger than the 0.1 MB CADCraft opens"), "{e}");
let limits = craft_dwg::Limits { max_dwg_bytes: 100_000, ..craft_dwg::Limits::DEFAULT };
let e = craft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
assert!(e.starts_with("DWG: the file is 0.") && e.ends_with("MB, larger than the 0.1 MB accepted by this bridge"), "{e}");
// Lines that aren't even group codes: the size is reported before anything is parsed.
let text = "not a group code\nx\n".repeat(60);
@@ -1526,6 +1526,26 @@ fn oversized_dwg_and_dxf_are_rejected_early() {
assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)");
}
#[test]
fn shared_dwg_diagnostics_keep_cadcraft_vocabulary() {
assert_eq!(
cadcraft_dwg_message("DWG: the file is 0.1 MB, larger than the 0.1 MB accepted by this bridge"),
"DWG: the file is 0.1 MB, larger than the 0.1 MB CADCraft opens"
);
assert_eq!(
cadcraft_dwg_message("DWG: 2 objects of types this bridge can't preserve were left out (FOO × 2)"),
"DWG: 2 objects of types CADCraft doesn't keep were left out (FOO × 2)"
);
assert_eq!(
cadcraft_dwg_message("DWG version `AC1015` can't be written (this bridge writes AC1018+)"),
"DWG version `AC1015` can't be written (CADCraft writes AC1018+)"
);
assert_eq!(
cadcraft_dwg_message("DXF: the input is 1024 MB, larger than the 1024 MB accepted for DWG conversion"),
"DXF: the input is 1024 MB, larger than the 1024 MB CADCraft converts to DWG"
);
}
/// Issue #800: one unreadable object record doesn't fail the whole DWG. The reader skips it, the
/// rest opens, and the drawing's read notes say how many records were left out and of which
/// types, so a partial read never passes for a complete one. A complete read has no notes.
@@ -2336,8 +2356,8 @@ fn arc_length_dimension_keeps_its_kind_and_centre_on_roundtrip() {
_ => panic!("not a dimension"),
}
// And through DWG.
let dwg = cadcraft_dwg::dxf_to_dwg(text.as_bytes()).unwrap();
let back = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dwg = craft_dwg::dxf_to_dwg(text.as_bytes()).unwrap();
let back = read_dxf(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dims: Vec<_> = back.model.iter().filter_map(|e| if let EntityKind::Dimension(x) = &e.kind { Some(x.clone()) } else { None }).collect();
assert_eq!(dims.len(), 1);
assert!(matches!(dims[0].kind, DimKind::ArcLength), "{:?}", dims[0].kind);
@@ -3568,7 +3588,7 @@ fn proxies_keep_their_class_in_dwg() {
fn a_proxy_whose_class_is_unknown_is_left_out_of_the_dwg() {
use acadrust::entities::{EntityType, ExtendedEntityData};
use acadrust::objects::ObjectType;
let dwg = cadcraft_dwg::dxf_to_dwg_as(&proxy_file(), "AC1032").unwrap();
let dwg = craft_dwg::dxf_to_dwg_as(&proxy_file(), "AC1032").unwrap();
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
let class_of = |id: i32| doc.classes.iter().find(|c| i32::from(c.class_number) == id).map(|c| c.dxf_name.clone());
let mut objects: Vec<Option<String>> = doc
@@ -3599,11 +3619,11 @@ fn a_proxy_whose_class_is_unknown_is_left_out_of_the_dwg() {
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_proxy_left_out_of_the_dwg_is_counted_as_unwritten() {
let (_, unwritten) = cadcraft_dwg::dxf_to_dwg_noted(&proxy_file(), "AC1032").unwrap();
let (_, unwritten) = craft_dwg::dxf_to_dwg_noted(&proxy_file(), "AC1032").unwrap();
assert_eq!(unwritten, [("ACAD_PROXY_OBJECT".to_string(), 1)].into_iter().collect());
let d = read(&proxy_file(), "p.dxf").unwrap();
let binary = write_with(&d, "p.dxf", &SaveOptions { version: Some(Version::R2018), binary: true }).unwrap();
let (_, unwritten) = cadcraft_dwg::dxf_to_dwg_noted(&binary, "AC1032").unwrap();
let (_, unwritten) = craft_dwg::dxf_to_dwg_noted(&binary, "AC1032").unwrap();
// CADCraft's own save already left the stray proxy out; the binary DXF's other three go.
assert_eq!(unwritten, [("ACAD_PROXY_ENTITY".to_string(), 1), ("ACAD_PROXY_OBJECT".to_string(), 2)].into_iter().collect());
}
@@ -3620,7 +3640,7 @@ fn a_binary_dxf_leaves_its_proxies_out_of_the_dwg() {
let d = read(&proxy_file(), "p.dxf").unwrap();
let binary = write_with(&d, "p.dxf", &SaveOptions { version: Some(Version::R2018), binary: true }).unwrap();
assert!(binary.starts_with(b"AutoCAD Binary DXF"));
let dwg = cadcraft_dwg::dxf_to_dwg_as(&binary, "AC1032").unwrap();
let dwg = craft_dwg::dxf_to_dwg_as(&binary, "AC1032").unwrap();
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
let proxies = doc.objects.values().filter(|o| matches!(o, ObjectType::ProxyObject(_))).count()
+ doc.entities().filter(|e| matches!(e, EntityType::Extended(x) if matches!(x.data, ExtendedEntityData::Proxy(_)))).count();
+1 -1
View File
@@ -285,7 +285,7 @@ fn standard_dimassoc_associates_in_dxf_and_dwg() {
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 dwg = craft_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");
}
+3 -3
View File
@@ -92,7 +92,7 @@ fn an_ac1018_dwg_gets_the_variable_dictionary_autocad_always_writes() {
#[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 dwg = craft_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:?}");
@@ -107,7 +107,7 @@ fn the_dictionary_keeps_the_drawings_dimassoc_and_hidetext() {
#[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 dwg = craft_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:?}");
@@ -139,7 +139,7 @@ fn a_variable_dictionary_the_drawing_has_is_left_alone() {
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 dwg = craft_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:?}");
+2 -2
View File
@@ -155,7 +155,7 @@ fn a_rotated_linear_dimension_stores_its_projected_length() {
Vec3::new(-40.0, 105.0, 0.0),
);
let dwg = write_with(&d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap();
let dxf = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dxf = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let t = tags(&dxf);
let dim = t.iter().position(|(c, v)| *c == 0 && v == "DIMENSION").unwrap();
let m: f64 = t[dim..].iter().find(|(c, _)| *c == 42).unwrap().1.parse().unwrap();
@@ -242,7 +242,7 @@ fn justified_text_made_here_writes_its_baseline_start_for_every_justification()
/// Group 42 of a dimension in the DXF AutoCAD reads from `d`'s DWG.
fn stored_measurement(d: &Drawing) -> f64 {
let dwg = write_with(d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap();
let dxf = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dxf = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let t = tags(&dxf);
let dim = t.iter().position(|(c, v)| *c == 0 && v == "DIMENSION").unwrap();
t[dim..].iter().find(|(c, _)| *c == 42).unwrap().1.parse().unwrap()
+1 -1
View File
@@ -93,7 +93,7 @@ fn dwg_keeps_the_solids_geometry() {
let d = cadcraft_io::read_dxf(dxf().as_bytes()).unwrap();
let dwg = cadcraft_io::write(&d, "x.dwg").unwrap();
// The DWG read back (as the bridge's DXF) binds the same bytes to both solids.
let back = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let back = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let mut got = asm_data(&back);
got.sort();
assert_eq!(got, vec![("2A".to_string(), SAB_HEX.to_string()), ("2B".to_string(), SAB_HEX.to_string())]);
+1 -1
View File
@@ -248,7 +248,7 @@ fn hostile_multibyte_never_panics() {
#[test]
fn pre_2007_dwg_text_decodes_through_the_bridge() {
let dxf = ascii_dxf("AC1015", "ANSI_1251", b"\xD1\xEB\xEE\xE9", b"\xC1\xEB\xEE\xEA", b"\xCF\xF0\xE8\xE2\xE5\xF2");
let dwg = cadcraft_dwg::dxf_to_dwg(&dxf).unwrap();
let dwg = craft_dwg::dxf_to_dwg(&dxf).unwrap();
let d = cadcraft_io::read(&dwg, "x.dwg").unwrap();
assert_eq!(names(&d), ("Слой".into(), "Блок".into(), "Привет".into()));
}
+1 -1
View File
@@ -71,7 +71,7 @@ fn dwg_keeps_true_colours_and_transparency() {
);
let dwg = cadcraft_io::write(&d, "x.dwg").unwrap();
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some("AC1032"));
assert_eq!(craft_dwg::version(&dwg).as_deref(), Some("AC1032"));
let header = records(&cadcraft_io::write_dxf(&d), "SECTION");
assert_eq!(header.first().and_then(|h| group(h, "1")), Some("AC1015"), "the DXF stays R2000");
+3 -3
View File
@@ -166,7 +166,7 @@ fn dwg_at_every_version_reads_back() {
let d = sample();
for v in Version::ALL {
let dwg = write_with(&d, "x.dwg", &opts(v, false)).unwrap();
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some(v.code()), "{v}");
assert_eq!(craft_dwg::version(&dwg).as_deref(), Some(v.code()), "{v}");
let back = read(&dwg, "x.dwg").unwrap();
assert_eq!(save_version(&back), v, "{v}");
assert_eq!(back.model.len(), d.model.len(), "{v}");
@@ -187,6 +187,6 @@ fn dwg_at_every_version_reads_back() {
#[test]
fn dwg_default_is_at_least_2004() {
let from_r2000 = read(write_dxf(&sample()).as_bytes(), "x.dxf").unwrap();
assert_eq!(cadcraft_dwg::version(&write(&from_r2000, "x.dwg").unwrap()).as_deref(), Some("AC1018"));
assert_eq!(cadcraft_dwg::version(&write(&sample(), "x.dwg").unwrap()).as_deref(), Some("AC1032"));
assert_eq!(craft_dwg::version(&write(&from_r2000, "x.dwg").unwrap()).as_deref(), Some("AC1018"));
assert_eq!(craft_dwg::version(&write(&sample(), "x.dwg").unwrap()).as_deref(), Some("AC1032"));
}
+1 -1
View File
@@ -104,7 +104,7 @@ fn with_scale_action() -> String {
/// A scale action goes through CADCraft's own DWG save (the bridge DXF, then acadrust) whole:
/// the DWG holds it with its connections, and it reads back as the same action. AutoCAD refused
/// such DWGs while acadrust left out the byte after its connections (cadcraft-dwg's
/// such DWGs while acadrust left out the byte after its connections (craft-dwg's
/// `scale_actions_keep_the_byte_autocad_reads_after_their_connections` covers the byte).
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
#[test]
+1
View File
@@ -37,6 +37,7 @@ mod layout_plot_window;
mod leader_vertex_elevation;
#[cfg(not(target_arch = "wasm32"))]
mod low_handseed;
mod minsert_marker;
mod mleader_content;
mod mleader_roundtrip;
mod mline_roundtrip;
+78
View File
@@ -0,0 +1,78 @@
//! A block reference repeated in rows or columns (MINSERT) is written with the subclass marker
//! `AcDbMInsertBlock`, as AutoCAD writes it; other readers (BricsCAD) draw only the first copy of
//! an array marked `AcDbBlockReference`. A plain INSERT keeps `AcDbBlockReference` (#3127).
use std::sync::Arc;
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Insert, Line, Space};
use cadcraft_geom::Vec3;
fn drawing(refs: &[Insert]) -> Drawing {
let mut d = Drawing::new_metric();
let mut block = Block::new("MP");
block.entities.push(Entity::new(d.new_handle(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(2.5, 5.0, 0.0) })));
d.blocks.insert(block.name.clone(), Arc::new(block));
for r in refs {
d.add(&Space::Model, Common::default(), EntityKind::Insert(r.clone())).unwrap();
}
d
}
/// The subclass markers (group 100) of each INSERT record in the DXF text, in file order.
fn insert_markers(text: &str) -> Vec<Vec<String>> {
let lines: Vec<&str> = text.lines().map(str::trim).collect();
let pairs: Vec<(&str, &str)> = lines.chunks(2).filter_map(|c| Some((*c.first()?, *c.get(1)?))).collect();
let mut out = Vec::new();
let mut cur: Option<Vec<String>> = None;
for (code, value) in pairs {
if code == "0" {
out.extend(cur.take());
if value == "INSERT" {
cur = Some(Vec::new());
}
} else if code == "100"
&& let Some(m) = cur.as_mut()
{
m.push(value.to_string());
}
}
out.extend(cur);
out
}
fn inserts(d: &Drawing) -> Vec<Insert> {
d.model.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).collect()
}
#[test]
fn minsert_is_written_with_the_minsert_marker() {
let columns = Insert { block: "MP".into(), cols: 6, col_spacing: 2.5, ..Insert::default() };
let rows = Insert { block: "MP".into(), insert: Vec3::new(0.0, 20.0, 0.0), rows: 3, row_spacing: 5.0, ..Insert::default() };
let text = cadcraft_io::write_dxf(&drawing(&[columns, rows]));
let markers = insert_markers(&text);
assert_eq!(markers.len(), 2, "{markers:?}");
for m in &markers {
assert_eq!(m, &["AcDbEntity", "AcDbMInsertBlock"], "an arrayed reference is an AcDbMInsertBlock");
}
}
#[test]
fn plain_insert_keeps_the_block_reference_marker() {
let plain = Insert { block: "MP".into(), insert: Vec3::new(30.0, 0.0, 0.0), ..Insert::default() };
let text = cadcraft_io::write_dxf(&drawing(&[plain]));
assert_eq!(insert_markers(&text), [["AcDbEntity", "AcDbBlockReference"]]);
}
#[test]
fn minsert_array_reads_back_from_its_own_dxf() {
let refs = [
Insert { block: "MP".into(), cols: 6, col_spacing: 2.5, ..Insert::default() },
Insert { block: "MP".into(), insert: Vec3::new(0.0, 20.0, 0.0), cols: 2, rows: 3, col_spacing: 4.0, row_spacing: 5.0, ..Insert::default() },
Insert { block: "MP".into(), insert: Vec3::new(30.0, 0.0, 0.0), ..Insert::default() },
];
let text = cadcraft_io::write_dxf(&drawing(&refs));
let back = inserts(&cadcraft_io::read_dxf(text.as_bytes()).unwrap());
let got: Vec<_> = back.iter().map(|i| (i.block.as_str(), i.insert, i.cols, i.rows, i.col_spacing, i.row_spacing)).collect();
let want: Vec<_> = refs.iter().map(|i| (i.block.as_str(), i.insert, i.cols, i.rows, i.col_spacing, i.row_spacing)).collect();
assert_eq!(got, want);
}
+1 -1
View File
@@ -59,7 +59,7 @@ fn model_layout_plots_extents_fitted_and_layouts_their_sheet() {
fn model_layout_plot_type_survives_the_dwg_bridge() {
let dwg = cadcraft_io::write(&drawing(), "x.dwg").unwrap();
// What the DWG holds, as the bridge's own DXF.
let dxf = String::from_utf8_lossy(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).to_string();
let dxf = String::from_utf8_lossy(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).to_string();
let p = plots(&dxf);
let model = p.iter().find(|(n, _, _)| n.eq_ignore_ascii_case("Model")).unwrap();
assert_eq!(model.1, Some(1), "{p:?}");
+3 -3
View File
@@ -82,7 +82,7 @@ fn dwg_mtext_caret_spaces_round_trip() -> TestResult {
let mut d = original.clone();
for pass in 1..=2 {
let bytes = write_with(&d, "carets.dwg", &SaveOptions { version: Some(version), binary: false })?;
assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()));
assert_eq!(craft_dwg::version(&bytes).as_deref(), Some(version.code()));
// Inspect the stored DWG text before its DXF rendition: a compensating reader
// must not hide corruption on save. This uses the same codec, not an external oracle.
let native = acadrust::DwgReader::from_stream(std::io::Cursor::new(&bytes)).read()?;
@@ -93,7 +93,7 @@ fn dwg_mtext_caret_spaces_round_trip() -> TestResult {
});
assert_eq!(text, Some(contents), "native DWG {version:?}, pass={pass}, {handle:?}");
}
let dxf = cadcraft_dwg::dwg_to_dxf(&bytes)?;
let dxf = craft_dwg::dwg_to_dxf(&bytes)?;
let converted = read(&dxf, "bridge.dxf")?;
assert_contents(&converted, &expected, &format!("DWG bridge DXF {version:?}, pass={pass}"))?;
d = read(&bytes, "carets.dwg")?;
@@ -112,7 +112,7 @@ fn bridge_caret_space_is_escaped_once_in_each_direction() -> TestResult {
let native = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf.into_bytes()))?.read()?;
assert!(native.entities().any(|e| matches!(e, acadrust::EntityType::MText(t) if t.value == "^ C")));
let dwg = write_with(&d, "carets.dwg", &SaveOptions { version: Some(Version::R2018), binary: false })?;
let returned = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg)?)?;
let returned = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg)?)?;
assert!(returned.contains(" 1\r\n^ C\r\n"), "the return bridge must encode the separator independently of authored whitespace");
Ok(())
}
+3 -3
View File
@@ -90,7 +90,7 @@ fn dwg_keeps_ole_objects() {
assert_eq!(ole(&d), 1);
let dwg = cadcraft_io::write(&d, "x.dwg").unwrap();
let back = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let back = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let got = frames(&back);
assert_eq!(got.len(), 1, "{got:?}");
assert_kept(&got[0]);
@@ -99,14 +99,14 @@ fn dwg_keeps_ole_objects() {
let again = cadcraft_io::read(&dwg, "x.dwg").unwrap();
assert_eq!(ole(&again), 1);
let dwg = cadcraft_io::write(&again, "x.dwg").unwrap();
let got = frames(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
let got = frames(&String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
assert_eq!(got.len(), 1, "{got:?}");
assert_kept(&got[0]);
// Older DWG versions keep it the same way.
for version in [cadcraft_io::Version::R2000, cadcraft_io::Version::R2010] {
let dwg = cadcraft_io::write_with(&d, "x.dwg", &cadcraft_io::SaveOptions { version: Some(version), binary: false }).unwrap();
let got = frames(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
let got = frames(&String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
assert_eq!(got.len(), 1, "{version:?}: {got:?}");
assert_kept(&got[0]);
}
+1 -1
View File
@@ -62,7 +62,7 @@ fn check_version_using(case: &str, original: &Drawing, expected: &Drawing, versi
for pass in 1..=2 {
let context = format!("{case} · DWG {version} · pass={pass}");
let bytes = write_with(&current, "memory.dwg", &options).unwrap_or_else(|e| panic!("{context}: write: {e}"));
assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
assert_eq!(craft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
let back = read(&bytes, "memory.dwg").unwrap_or_else(|e| panic!("{context}: read: {e}"));
assert!(back.read_notes.is_empty(), "{context}: read notes: {:?}", back.read_notes);
assert!(back.skipped_records.is_empty(), "{context}: skipped records: {:?}", back.skipped_records);
+1 -1
View File
@@ -82,7 +82,7 @@ pub(super) fn check(path: &str, version: Version, binary: bool) {
assert_eq!(craft_dxf::is_binary(&bytes), binary, "{context}: encoding");
dxf_owner(&bytes);
} else {
assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
assert_eq!(craft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
}
let back = read(&bytes, path).unwrap();
preserved(&original, &back, &context);
+6 -6
View File
@@ -107,8 +107,8 @@ fn the_block_draw_order_survives_dxf_and_dwg() {
/// The table itself (not CADCraft's reordered records) goes through the DWG bridge (#2410).
#[test]
fn a_partial_block_table_survives_the_dwg_bridge() {
let dwg = cadcraft_dwg::dxf_to_dwg(block_dxf().as_bytes()).unwrap();
let dxf = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap();
let dwg = craft_dwg::dxf_to_dwg(block_dxf().as_bytes()).unwrap();
let dxf = craft_dwg::dwg_to_dxf(&dwg).unwrap();
let d = cadcraft_io::read_dxf(&dxf).unwrap();
assert_eq!(block_kinds(&d), ["wipeout", "circle", "hline", "vline"]);
}
@@ -120,8 +120,8 @@ fn a_zero_sort_handle_draws_last() {
let dxf = block_dxf_with("331\n25B\n5\n0\n331\n25D\n5\n25E\n331\n25E\n5\n25B\n");
let want = ["wipeout", "circle", "vline", "hline"];
assert_eq!(block_kinds(&cadcraft_io::read_dxf(dxf.as_bytes()).unwrap()), want, "DXF");
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let back = cadcraft_io::read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let back = cadcraft_io::read_dxf(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
assert_eq!(block_kinds(&back), want, "DWG");
}
@@ -135,7 +135,7 @@ fn two_unlisted_block_entities_each_sort_by_their_own_handle() {
let dxf = block_dxf_with("331\n25B\n5\n25B\n331\n25D\n5\n25D\n");
let want = ["hline", "circle", "vline", "wipeout"];
assert_eq!(block_kinds(&cadcraft_io::read_dxf(dxf.as_bytes()).unwrap()), want, "DXF");
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let back = cadcraft_io::read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let back = cadcraft_io::read_dxf(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
assert_eq!(block_kinds(&back), want, "DWG");
}
+5 -5
View File
@@ -45,7 +45,7 @@ fn check(bytes: &[u8], name: &str) -> cadcraft_doc::Drawing {
})
.unwrap();
assert!(unknown.tags.iter().any(|t| t.code == 340 && t.value == line.hex()));
let dxf = if name.ends_with(".dwg") { cadcraft_dwg::dwg_to_dxf(bytes).unwrap() } else { bytes.to_vec() };
let dxf = if name.ends_with(".dwg") { craft_dwg::dwg_to_dxf(bytes).unwrap() } else { bytes.to_vec() };
let tags = craft_dxf::parse(&dxf).unwrap();
let sections = craft_dxf::sections(&tags);
let classes = sections.iter().find(|s| s.name == "CLASSES").unwrap();
@@ -124,7 +124,7 @@ fn duplicate_names_keep_distinct_classes_for_entities_and_dictionary_objects() {
for _ in 0..3 {
for name in ["copy.dxf", "copy.dwg"] {
let bytes = cadcraft_io::write_with(&drawing, name, &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
let dxf = if name.ends_with(".dwg") { cadcraft_dwg::dwg_to_dxf(&bytes).unwrap() } else { bytes.clone() };
let dxf = if name.ends_with(".dwg") { craft_dwg::dwg_to_dxf(&bytes).unwrap() } else { bytes.clone() };
let tags = craft_dxf::parse(&dxf).unwrap();
let sections = craft_dxf::sections(&tags);
let classes = craft_dxf::records(&sections.iter().find(|s| s.name == "CLASSES").unwrap().tags);
@@ -197,7 +197,7 @@ fn unused_malformed_classes_do_not_block_plain_geometry() {
let dxf = format!(
"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n0\nENDSEC\n0\nSECTION\n2\nCLASSES\n0\nCLASS\n1\n{name}\n2\n{cpp}\n3\nSynthetic\n90\n{flags}\n91\n{count}\n281\n1\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nLINE\n5\n10\n100\nAcDbEntity\n8\n0\n100\nAcDbLine\n10\n2\n20\n3\n11\n12\n21\n13\n0\nENDSEC\n0\nEOF\n"
);
let bytes = cadcraft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1032").unwrap();
let bytes = craft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1032").unwrap();
let drawing = cadcraft_io::read(&bytes, "plain.dwg").unwrap();
let lines: Vec<_> = drawing.model.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(l) } else { None }).collect();
assert_eq!(lines.len(), 1);
@@ -219,7 +219,7 @@ fn proxy_spelling_is_preserved_in_dxf_and_normalized_only_for_dwg() {
let tags = craft_dxf::parse(&out).unwrap();
assert!(tags.iter().any(|t| t.code == 0 && t.str() == entity));
let bytes = cadcraft_io::write_with(&drawing, "copy.dwg", &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
let decoded = cadcraft_dwg::dwg_to_dxf(&bytes).unwrap();
let decoded = craft_dwg::dwg_to_dxf(&bytes).unwrap();
let tags = craft_dxf::parse(&decoded).unwrap();
let proxies: Vec<_> =
craft_dxf::sections(&tags).iter().flat_map(|s| craft_dxf::records(&s.tags)).filter(|(kind, _)| kind.starts_with("ACAD_PROXY_")).collect();
@@ -283,7 +283,7 @@ fn ambiguous_fallback_classes_are_reported_without_losing_plain_geometry() {
source = source.replace("1\nCUSTOM\n2\nSecond", "1\n\\U+0043USTOM\n2\nSecond");
source = source.replace("1\nCUSTOM\n2\nObject", "1\nOTHER\n2\nObject");
}
let (bytes, unwritten) = cadcraft_dwg::dxf_to_dwg_noted(source.as_bytes(), "AC1032").unwrap();
let (bytes, unwritten) = craft_dwg::dxf_to_dwg_noted(source.as_bytes(), "AC1032").unwrap();
assert_eq!(unwritten.get("ACAD_PROXY_ENTITY"), Some(&2));
assert_eq!(unwritten.get("ACAD_PROXY_OBJECT"), if escaped { None } else { Some(&1) });
let drawing = cadcraft_io::read(&bytes, "copy.dwg").unwrap();
+2
View File
@@ -40,6 +40,8 @@ craft-ui = { workspace = true, features = ["native-windows"] }
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
# Validates the canvas WGSL and its GLSL ES 3.0 (WebGL2) translation in tests (same naga wgpu uses).
naga = { version = "30", features = ["wgsl-in", "glsl-out"] }
# Reads the synthetic dynamic-block fixture of the io tests for the dynamic grips tests.
cadcraft-io = { workspace = true }
[build-dependencies]
serde_json = { workspace = true }
+3 -1
View File
@@ -1103,7 +1103,9 @@ fn paint_canvas(frame: CanvasPaint<'_>) {
{
let gs = app.session.settings.gripsize as f32;
for h in sel.iter().take(200) {
if let Some(e) = st.doc.entity(*h) {
if let Some(e) = st.doc.entity(*h)
&& st.doc.is_visible(e)
{
let grips = e.kind.grips();
let count = grips.len();
for (gi, g) in grips.into_iter().enumerate() {
+5 -1
View File
@@ -3,6 +3,10 @@ use super::{CadApp, Handle, Input, Pos2, Rect, Xf, vec2};
/// A click on grip `g` (idle): Shift+click makes it hot or cold again; a plain click starts
/// editing it, with the other hot grips when it is one of them.
pub(super) fn click_grip(app: &mut CadApp, g: (Handle, usize), shift: bool) {
// A hot grip whose object was hidden since (a layer turned off, HIDEOBJECTS) is no longer
// painted, so it must not be stretched along with the visible ones.
let Ok(st) = app.session.state() else { return };
app.canvas.hot_grips.retain(|(h, _)| st.doc.entity(*h).is_some_and(|e| st.doc.is_visible(e)));
let hot = &mut app.canvas.hot_grips;
if shift {
match hot.iter().position(|x| *x == g) {
@@ -85,7 +89,7 @@ pub(super) fn grip_at(app: &CadApp, xf: &Xf, hover: Option<Pos2>) -> Option<(Han
}
for h in sel.iter().take(200) {
let e = st.doc.entity(*h)?;
if st.doc.layer(&e.common.layer).is_some_and(|l| l.locked) {
if !st.doc.is_visible(e) || st.doc.layer(&e.common.layer).is_some_and(|l| l.locked) {
continue;
}
let grips = e.kind.grips();
+18
View File
@@ -230,3 +230,21 @@ fn dynamic_input_distance_follows_the_drawing_units() {
let text = painted_text(&mut app, &ctx, at);
assert!(text.contains(&format!(" {} <", arch(c.len()))), "no {} in:\n{text}", arch(c.len()));
}
/// A hot grip whose object was hidden since (HIDEOBJECTS, a layer turned off) is no longer
/// painted, so clicking another hot grip must not stretch the hidden object along with it.
#[test]
fn clicking_a_hot_grip_leaves_hidden_objects_out_of_the_stretch() {
let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default());
for y in [0, 30] {
app.run("line", serde_json::json!({ "points": [[0, y], [100, y]] })).unwrap();
}
let handles = app.session.doc().unwrap().model.handles();
let (shown, hidden) = (handles[0], handles[1]);
app.session.set_selection(vec![shown, hidden]);
app.canvas.hot_grips = vec![(shown, 0), (hidden, 0)];
app.run("hideobjects", serde_json::json!({ "handles": [hidden.hex()] })).unwrap();
app.session.set_selection(vec![shown, hidden]);
click_grip(&mut app, (shown, 0), false);
assert_eq!(app.canvas.hot_grips, vec![(shown, 0)], "the hidden object's grip must leave the hot set");
}
+59 -1
View File
@@ -34,7 +34,7 @@ pub(crate) fn of_selection(app: &mut CadApp) -> Arc<Vec<(Handle, DynGrip)>> {
let grips: Vec<(Handle, DynGrip)> = sel
.iter()
.filter(|h| matches!(st.doc.entity(**h).map(|e| &e.kind), Some(cadcraft_engine::doc::EntityKind::Insert(_))))
.filter(|h| st.doc.entity(**h).is_some_and(|e| !st.doc.layer(&e.common.layer).is_some_and(|l| l.locked)))
.filter(|h| st.doc.entity(**h).is_some_and(|e| st.doc.is_visible(e) && !st.doc.layer(&e.common.layer).is_some_and(|l| l.locked)))
.take(MAX_REFERENCES)
.flat_map(|h| cadcraft_engine::doc::dynblock::grips(&st.doc, *h).into_iter().map(move |g| (*h, g)))
.collect();
@@ -152,3 +152,61 @@ pub(crate) fn menu(app: &mut CadApp, ctx: &egui::Context, xf: &Xf) {
app.canvas.dyn_menu = None;
}
}
#[cfg(test)]
mod tests {
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Handle, Space};
use serde_json::json;
use super::of_selection;
use crate::{CadApp, Services};
/// DXF text from lines of `code value`.
fn dxf(src: &str) -> String {
src.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| {
let (code, value) = l.split_once(' ').unwrap_or((l, ""));
format!("{code}\n{value}\n")
})
.collect()
}
/// A session with the synthetic DOOR block of the io tests (Width's end grip shown in the
/// Open state, plus the visibility grip) and a reference to it at (100, 0).
fn door() -> (Session, Handle) {
let grip =
"0 BLOCKLINEARGRIP\n5 68\n330 61\n100 AcDbEvalExpr\n90 9\n98 33\n99 232\n100 AcDbBlockElement\n300 End Grip\n98 33\n99 232\n1071 0\n";
let src = include_str!("../../io/tests/data/dynblock_door.txt")
.replace("\r\n", "\n")
.replace("170 4\n91 0\n91 0\n", "170 4\n91 8\n91 9\n")
.replace("1030 0\n93 0\n170 0\n171 0\n100 AcDbBlockVisibilityParameter", "1030 0\n93 10\n170 0\n171 0\n100 AcDbBlockVisibilityParameter")
.replace("91 5\n93 32\n95 6\n360 67", "91 5\n93 32\n95 6\n360 67\n92 -1\n92 -1\n92 -1\n92 -1\n91 6\n93 32\n95 9\n360 68")
.replace("95 1\n333 63\n", "95 2\n333 63\n333 68\n")
.replace("0 BLOCKFLIPGRIP", &format!("{grip}0 BLOCKFLIPGRIP"));
let d = cadcraft_io::read_dxf(dxf(&src).as_bytes()).unwrap();
let mut s = Session::empty();
s.open_drawing(d, "door", None);
let r = s.execute("insert", &json!({ "name": "Door", "at": [100, 0] })).unwrap();
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
assert!(s.doc().unwrap().space_of(h) == Some(Space::Model));
(s, h)
}
/// A hidden reference restored to the selection (Undo, or another selection source) shows
/// and takes no dynamic grips, as ordinary grips are not shown for it either.
#[test]
fn a_hidden_reference_has_no_dynamic_grips() {
let (session, h) = door();
let mut app = CadApp::new(session, Services::default());
app.session.set_selection(vec![h]);
assert_eq!(of_selection(&mut app).len(), 2, "a visible reference shows its two grips");
app.run("hideobjects", json!({ "handles": [h.hex()] })).unwrap();
app.session.set_selection(vec![h]);
let st = app.session.state().unwrap();
assert!(!st.doc.is_visible(st.doc.entity(h).unwrap()), "the reference must be hidden");
assert!(of_selection(&mut app).is_empty(), "a hidden reference must not offer dynamic grips");
}
}
+3
View File
@@ -1383,3 +1383,6 @@ menu View عرض
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. تغيّر الرسم. أغلق النافذة وافتحها مرة أخرى.
# ArtCraft feature naming
Additional Tools أدوات إضافية
Can't render this file because it contains an unexpected character in line 1335 and column 24.
+3
View File
@@ -1459,3 +1459,6 @@ menu Modify Izmijeni
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. Crtež se promijenio. Zatvorite prozor i otvorite ga ponovo.
# ArtCraft feature naming
Additional Tools Dodatni alati
Can't render this file because it contains an unexpected character in line 1444 and column 24.
+3
View File
@@ -1239,3 +1239,6 @@ menu Modify Modifikace
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. Výkres se změnil. Zavřete okno a otevřete jej znovu.
# ArtCraft feature naming
Additional Tools Další nástroje
1 # CADCraft Czech interface catalog — original translations by @Losminek (AI-assisted).
1239
1240
1241
1242 # ArtCraft feature naming
1243
1244
+3
View File
@@ -1056,3 +1056,6 @@
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. Die Zeichnung hat sich geändert. Schließen Sie das Fenster und öffnen Sie es erneut.
# ArtCraft feature naming
Additional Tools Zusätzliche Werkzeuge
1 # CADCraft German interface catalog — original translations by the CADCraft contributors.
1056
1057
1058
1059 # ArtCraft feature naming
1060
1061
+3
View File
@@ -1350,3 +1350,6 @@ menu View Προβολή
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. Το σχέδιο άλλαξε. Κλείστε το παράθυρο και ανοίξτε το ξανά.
# ArtCraft feature naming
Additional Tools Πρόσθετα εργαλεία
1 # Greek interface catalog for CADCraft. Original, AI-assisted translation by @Camthalion77 with ChatGPT.
1350
1351
1352
1353 # ArtCraft feature naming
1354
1355
+3
View File
@@ -1270,3 +1270,6 @@
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. El dibujo cambió. Cierre la ventana y ábrala de nuevo.
# ArtCraft feature naming
Additional Tools Herramientas adicionales
1 # CADCraft Spanish interface catalog — original translations.
1270
1271
1272
1273 # ArtCraft feature naming
1274
1275
+3
View File
@@ -1036,3 +1036,6 @@
Press the keys Appuyez sur les touches
Back to CADCraft's shortcut Revenir au raccourci de CADCraft
A shortcut you give wins over CADCraft's own. Changes apply at once and are kept for next time. Un raccourci que vous définissez l'emporte sur celui de CADCraft. Les modifications s'appliquent tout de suite et sont conservées.
# ArtCraft feature naming
Additional Tools Outils supplémentaires
1 # CADCraft French interface catalog — original translations by @pechnikov2026-star.
1036
1037
1038
1039 # ArtCraft feature naming
1040
1041
+3
View File
@@ -1361,3 +1361,6 @@ menu View Nézet
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. A rajz megváltozott. Zárja be az ablakot, és nyissa meg újra.
# ArtCraft feature naming
Additional Tools További eszközök
Can't render this file because it contains an unexpected character in line 16 and column 72.
+3
View File
@@ -1398,3 +1398,6 @@ menu Modify Edita
ENTER INVIO
Shortcut Menu: always enabled Menu di scelta rapida: sempre attivo
Shortcut Menu: enabled when command options are present Menu di scelta rapida: attivo se il comando ha opzioni
# ArtCraft feature naming
Additional Tools Strumenti aggiuntivi
1 # CADCraft Italian interface catalog — original translations by Emanuele Bottelli (AI-assisted, Claude).
1398
1399
1400
1401 # ArtCraft feature naming
1402
1403
+3
View File
@@ -1472,3 +1472,6 @@ menu View ხედი
Text to find მოსაძებნი ტექსტი
Whole words მთლიანი სიტყვები
Nothing found ვერაფერი მოიძებნა
# ArtCraft feature naming
Additional Tools დამატებითი ხელსაწყოები
Can't render this file because it contains an unexpected character in line 10 and column 14.
+3
View File
@@ -1259,3 +1259,6 @@ menu Modify 수정
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. 도면이 변경되었습니다. 창을 닫고 다시 여십시오.
# ArtCraft feature naming
Additional Tools 추가 도구
1 # CADCraft Korean interface catalog — original Korean translations.
1259
1260
1261
1262 # ArtCraft feature naming
1263
1264
+3
View File
@@ -1465,3 +1465,6 @@ menu View Exibir
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. O desenho mudou. Feche a janela e abra-a novamente.
# ArtCraft feature naming
Additional Tools Ferramentas adicionais
Can't render this file because it contains an unexpected character in line 1413 and column 24.
+3
View File
@@ -1030,3 +1030,6 @@
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. Чертёж изменился. Закройте окно и откройте его снова.
# ArtCraft feature naming
Additional Tools Дополнительные инструменты
1 # CADCraft Russian interface catalog — original translations by @pechnikov2026-star.
1030
1031
1032
1033 # ArtCraft feature naming
1034
1035
+3
View File
@@ -1483,3 +1483,6 @@ menu Modify Ändra
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. Ritningen har ändrats. Stäng fönstret och öppna det igen.
# ArtCraft feature naming
Additional Tools Ytterligare verktyg
Can't render this file because it contains an unexpected character in line 1472 and column 24.
+3
View File
@@ -1364,3 +1364,6 @@ menu View Görünüm
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. Çizim değişti. Pencereyi kapatıp yeniden açın.
# ArtCraft feature naming
Additional Tools Ek Araçlar
Can't render this file because it contains an unexpected character in line 11 and column 13.
+3
View File
@@ -1422,3 +1422,6 @@
ENTER Клавіша Enter
Shortcut Menu: always enabled Контекстне меню: завжди
Shortcut Menu: enabled when command options are present Контекстне меню: коли команда має параметри
# ArtCraft feature naming
Additional Tools Додаткові інструменти
Can't render this file because it contains an unexpected character in line 1385 and column 24.
+3
View File
@@ -1464,3 +1464,6 @@ menu Modify Hiệu chỉnh
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. Bản vẽ đã thay đổi. Đóng cửa sổ và mở lại.
# ArtCraft feature naming
Additional Tools Công cụ bổ sung
Can't render this file because it contains an unexpected character in line 1444 and column 24.
+3
View File
@@ -1476,3 +1476,6 @@ menu View 视图
Ctrl+Enter to apply Ctrl+Enter 应用
Edit Text 编辑文字
The drawing changed. Close the window and open it again. 图纸已切换。请关闭窗口后重新打开。
# ArtCraft feature naming
Additional Tools 其他工具
Can't render this file because it contains an unexpected character in line 1439 and column 24.
+215
View File
@@ -0,0 +1,215 @@
//! Guard the production display surfaces without banning compatibility identifiers or notices.
use std::path::{Path, PathBuf};
const BANNED: &[&str] = &["Express Tools"];
const ALLOWED: &[&str] = &[];
const ROOTS: &[&str] = &["src", "../engine/src/cmd"];
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn banned_display(text: &str) -> bool {
if ALLOWED.contains(&text) {
return false;
}
BANNED.iter().any(|banned| {
text.match_indices(banned)
.any(|(at, _)| !text[..at].chars().next_back().is_some_and(is_word) && !text[at + banned.len()..].chars().next().is_some_and(is_word))
})
}
// Read ordinary and raw Rust string literals, ignoring comments. No compiler dependency is
// needed, so this guard can also run by itself when a full application build is unavailable.
#[allow(clippy::cognitive_complexity)] // This small test-only lexer keeps raw-string/comment state together.
fn literals(source: &str) -> Vec<String> {
let chars: Vec<char> = source.chars().collect();
let mut out = Vec::new();
let mut at = 0;
let mut test_module = false;
let mut test_depth = 0_usize;
while at < chars.len() {
if chars.get(at..at + 12) == Some(&['#', '[', 'c', 'f', 'g', '(', 't', 'e', 's', 't', ')', ']']) {
at += 12;
let rest: String = chars[at..].iter().collect();
if rest.trim_start().starts_with("mod ") {
test_module = true;
}
} else if chars.get(at..at + 2) == Some(&['/', '/']) {
while at < chars.len() && chars[at] != '\n' {
at += 1;
}
} else if chars.get(at..at + 2) == Some(&['/', '*']) {
at += 2;
let mut depth = 1;
while at < chars.len() && depth > 0 {
if chars.get(at..at + 2) == Some(&['/', '*']) {
depth += 1;
at += 2;
} else if chars.get(at..at + 2) == Some(&['*', '/']) {
depth -= 1;
at += 2;
} else {
at += 1;
}
}
} else if chars[at] == 'r' && matches!(chars.get(at + 1), Some('"' | '#')) {
let start = at;
at += 1;
while chars.get(at) == Some(&'#') {
at += 1;
}
let hashes = at - start - 1;
if chars.get(at) != Some(&'"') {
continue;
}
at += 1;
let begin = at;
while at < chars.len() {
if chars[at] == '"' && (1..=hashes).all(|n| chars.get(at + n) == Some(&'#')) {
break;
}
at += 1;
}
if !test_module {
out.push(chars[begin..at].iter().collect());
}
at += 1 + hashes;
} else if chars[at] == '"' {
at += 1;
let mut text = String::new();
while at < chars.len() {
let c = chars[at];
at += 1;
if c == '"' {
break;
}
if c == '\\' && at < chars.len() {
let escaped = chars[at];
at += 1;
match escaped {
'n' => text.push('\n'),
't' => text.push('\t'),
'r' => text.push('\r'),
'0' => text.push('\0'),
'\n' | '\r' => {
while chars.get(at).is_some_and(|c| c.is_whitespace()) {
at += 1;
}
}
other => text.push(other),
}
} else {
text.push(c);
}
}
if !test_module {
out.push(text);
}
} else if chars[at] == '\'' && chars.get(at + 1) == Some(&'\\') {
// Skip the escape and its payload, including hexadecimal/Unicode escapes.
at += 3;
while at < chars.len() && chars[at] != '\'' {
at += 1;
}
at += 1;
} else if chars[at] == '\'' && chars.get(at + 2) == Some(&'\'') {
at += 3; // A character literal such as '"', not the start of a string.
} else {
if test_module {
match chars[at] {
'{' => test_depth += 1,
'}' => {
test_depth = test_depth.saturating_sub(1);
if test_depth == 0 {
test_module = false;
}
}
';' if test_depth == 0 => test_module = false,
_ => {}
}
}
at += 1;
}
}
out
}
fn check_tree(path: &Path, failures: &mut Vec<String>) {
if path.is_dir() {
for entry in std::fs::read_dir(path).expect("source directory") {
let path = entry.expect("source entry").path();
if path.file_name().is_some_and(|name| name == "tests") {
continue;
}
check_tree(&path, failures);
}
} else if path.extension().is_some_and(|ext| ext == "rs") {
let name = path.file_name().expect("source filename").to_string_lossy();
if name == "tests.rs" || name.ends_with("_tests.rs") {
return;
}
let source = std::fs::read_to_string(path).expect("UTF-8 Rust source");
// Skip only inline test modules, not later production following test-only imports.
for text in literals(&source) {
if banned_display(&text) {
failures.push(format!("{}: {text:?}", path.display()));
}
}
}
}
#[test]
fn production_display_names_stay_neutral() {
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut failures = Vec::new();
for root in ROOTS {
let root = base.join(root);
assert!(root.exists(), "missing display source: {}", root.display());
check_tree(&root, &mut failures);
}
assert!(failures.is_empty(), "Banned display names:\n{}", failures.join("\n"));
}
#[test]
fn guard_distinguishes_labels_from_identifiers_and_comments() {
for name in BANNED {
assert!(banned_display(name), "{name}");
}
for name in ALLOWED {
assert!(!banned_display(name), "{name}");
}
assert!(!banned_display("filter.cameraRaw"));
assert!(!banned_display("tool.guidedUpright"));
assert!(!banned_display("insert.smartArt"));
assert_eq!(literals("// \"ignored\"\n/* \"ignored\" */ \"label\""), ["label"]);
assert_eq!(literals("r#\"raw label\"#"), ["raw label"]);
}
#[test]
fn guard_scans_production_after_test_only_items() {
let source = r###"#[cfg(test)] mod tests { let _ = "ignored"; }
#[cfg(test)] use fixtures::Helper;
fn production() { let _ = r#"live label"#; }
"###;
assert_eq!(literals(source), ["live label"]);
assert_eq!(literals("#[cfg(test)] mod tests; fn live() { let _ = \"live label\"; }"), ["live label"]);
}
#[test]
fn guard_skips_escaped_character_literals() {
assert_eq!(literals(r#"let q = '\"'; "label""#), ["label"]);
assert_eq!(literals(r#"let q = '\''; "label""#), ["label"]);
assert_eq!(literals(r#"let q = '\\'; "label""#), ["label"]);
assert_eq!(literals(r#"let q = '\u{22}'; "label""#), ["label"]);
}
#[test]
fn guard_decodes_whitespace_escapes() {
assert_eq!(literals(r#""one\ntwo\tthree\rfour\0""#), ["one\ntwo\tthree\rfour\0"]);
assert_eq!(literals("\"joined\\\n label\""), ["joinedlabel"]);
for name in BANNED {
let source = format!("\"\\t{name}\\n\"");
assert!(literals(&source).iter().any(|label| banned_display(label)));
}
}
@@ -0,0 +1,78 @@
//! An ordinary hidden object restored to the selection must not paint grips.
use cadcraft_engine::doc::Handle;
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::{Session, View};
use cadcraft_ui_egui::{CadApp, Services, theme::Tokens};
use serde_json::json;
/// An app with two lines, the second hidden by HIDEOBJECTS, and an empty selection.
fn drawing_with_hidden_object() -> (CadApp, Handle, Handle) {
let mut app = CadApp::new(Session::new(), Services::default());
app.ui.start_tab = false;
app.ui.in_window_menu = false;
app.integrated_titlebar = true;
for y in [0, 30] {
app.run("line", json!({ "points": [[0, y], [100, y]] })).unwrap();
}
let handles = app.session.doc().unwrap().model.handles();
assert_eq!(handles.len(), 2);
let (regular, hidden) = (handles[0], handles[1]);
app.run("hideobjects", json!({ "handles": [hidden.hex()] })).unwrap();
assert!(app.session.doc().unwrap().is_hidden_object(hidden));
app.session.set_selection(Vec::new());
(app, regular, hidden)
}
/// The grip rectangles (filled with `color`) inside `canvas` among the painted shapes.
fn count_grips(shape: &egui::Shape, canvas: egui::Rect, color: egui::Color32) -> usize {
match shape {
egui::Shape::Rect(rect) => usize::from(rect.fill == color && canvas.contains(rect.rect.center())),
egui::Shape::Vec(shapes) => shapes.iter().map(|shape| count_grips(shape, canvas, color)).sum(),
_ => 0,
}
}
/// Runs one frame of the app and counts the grip rectangles painted on its canvas.
fn painted_grips(app: &mut CadApp, ctx: &egui::Context) -> usize {
app.logic(ctx);
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))),
events: vec![egui::Event::PointerGone],
..Default::default()
};
let mut output = ctx.run_ui(input, |ui| app.ui(ui));
output.textures_delta.clear();
let canvas = app.canvas.rect.unwrap();
let color = Tokens::get().grip;
output.shapes.iter().map(|shape| count_grips(&shape.shape, canvas, color)).sum()
}
/// A selected ordinary line paints its grips; the same selection of a hidden line paints none.
#[test]
fn a_hidden_selection_paints_no_grips_while_an_ordinary_selection_does() {
let (mut app, regular, hidden) = drawing_with_hidden_object();
let ctx = egui::Context::default();
// Settle the dock layout, then keep both objects comfortably within the canvas.
painted_grips(&mut app, &ctx);
painted_grips(&mut app, &ctx);
assert!(app.session.state_mut().unwrap().set_view(View { center: Vec2::new(50.0, 15.0), height: 120.0, twist: 0.0 }));
app.session.set_selection(vec![regular]);
painted_grips(&mut app, &ctx);
let ordinary_grips = painted_grips(&mut app, &ctx);
let state = app.session.state().unwrap();
let xf = app.canvas.xf.unwrap();
assert!(state.doc.entity(regular).unwrap().kind.grips().iter().all(|point| xf.rect.contains(xf.to_screen(*point))));
assert!(ordinary_grips >= 2, "the counter must find ordinary selected-line grips, got {ordinary_grips}");
// Undo/Redo or another public selection source can restore a still-hidden handle.
app.session.set_selection(vec![hidden]);
painted_grips(&mut app, &ctx);
let hidden_grips = painted_grips(&mut app, &ctx);
assert!(app.session.selection().contains(&hidden), "the forced hidden selection must reach the paint loop");
let state = app.session.state().unwrap();
assert!(!state.doc.is_visible(state.doc.entity(hidden).unwrap()));
let xf = app.canvas.xf.unwrap();
assert!(state.doc.entity(hidden).unwrap().kind.grips().iter().all(|point| xf.rect.contains(xf.to_screen(*point))));
assert_eq!(hidden_grips, 0, "a hidden object leaked blue grip rectangles into the canvas");
}
+2 -2
View File
@@ -20,7 +20,7 @@ About 57,000 lines of Rust in 12 library crates, 3 apps and `xtask` (counted 202
| L2 | `render` | 3,300 | Display lists (polylines, triangles, points tagged with entity handles): linetypes, hatches, gradients, text, block expansion, dimension geometry, paper-space clipping; CPU rasteriser (`tiny-skia`) for PNG, plot preview and tests |
| L2 | `constraints` | 2,504 | Own parametric solver: damped Gauss–Newton / Levenberg–Marquardt (`lm.rs`), 12 geometric and the dimensional constraints, expressions and parameters, AutoConstrain inference |
| L3 | `io` | 5,124 | DXF ⇄ document mapping (`dxf_read.rs`, `dxf_write.rs`, CADCraft xdata in `dxf_ext.rs`), PDF plotting, SVG and PNG export |
| L3 | `dwg` | 149 | DWG through a DXF bridge: the `acadrust` crate (MPL-2.0, unmodified) converts DWG ⇄ DXF bytes; nothing else sees its types. Native only |
| L3 | `craft-dwg` | shared | DWG through the shared byte bridge: `acadrust` (MPL-2.0, unmodified) converts DWG ⇄ DXF bytes; the app keeps process isolation and maps DXF into its document model. Native only |
| L4 | `engine` | 25,090 | `Session`, the command registry (`cmd/*`), command-line parser and prompt machines, object snaps and polar/ortho (`snap.rs`), selection (`select.rs`), grips (`grips.rs`), associativity (`assoc.rs`), system variables, units, undo, R-tree spatial index (`spatial.rs`) |
| L5 | `ui-egui` | 9,849 | The swappable egui front end: GPU canvas (`gpu.rs`, wgpu), menus and shortcuts (`menus.rs`), command line, palettes, Layer Properties Manager and other dialogs, ViewCube, icons drawn in code, themes, the interface string catalog (`i18n/`, English, Ukrainian and German), JSON control channel (`control.rs`) |
| L5 | `mcp` | 559 | MCP server (JSON-RPC 2.0 over stdio) on a headless session or bridged to a running app |
@@ -64,7 +64,7 @@ SVG and plot preview reuse the same display list through the CPU rasteriser or v
## Files
DXF is the native path: `dxf` parses tags, `io::dxf_read` maps them to the document, and
`io::dxf_write` writes an R2000 (AC1015) DXF. DWG is converted to DXF in memory by `acadrust` and
`io::dxf_write` writes an R2000 (AC1015) DXF. DWG is converted to DXF in memory by shared `craft-dwg` and
read through the same path; saving DWG writes our DXF and converts it back. CADCraft-only data
(constraints, associativity details, table flags) travels in xdata under our own application
name. Detail and gaps: [file-format-parity.md](file-format-parity.md).
+19
View File
@@ -0,0 +1,19 @@
# Feature naming
ArtCraft apps use descriptive names for their own features. Product names used to describe compatibility, file formats, attribution or external software remain accurate.
## Naming map
Express Tools → Additional Tools. This is a descriptive naming cleanup, not a determination that the old phrase is a registered trademark.
## Compatibility boundary
Command IDs, aliases, prompts, drawing data, DXF/DWG identifiers and interoperability references are unchanged. No assets, attribution, dependencies or rendering behavior change.
## Localization and verification
Each shipped non-English interface catalog includes the new labels. Regression tests cover the labels and stable command identities.
## Regression coverage
The standard-library-only `display_names` integration tests scan production Rust string literals for the retired display names and preserve explicit compatibility exceptions. They also test identifier, comment and raw-string handling. These focused checks do not replace application or visual testing.
+6 -4
View File
@@ -4,7 +4,7 @@ use serde_json::Value;
use std::process::Command;
#[test]
fn dwg_test_dependency_matches_the_bridge_targets() {
fn dwg_test_dependency_matches_the_shared_bridge_targets() {
let output = Command::new(env!("CARGO"))
.args(["metadata", "--format-version", "1", "--no-deps", "--locked"])
.current_dir(env!("CARGO_MANIFEST_DIR"))
@@ -17,9 +17,11 @@ fn dwg_test_dependency_matches_the_bridge_targets() {
let package = packages.iter().find(|p| p["name"] == name).expect("workspace crate");
package["dependencies"].as_array().expect("crate dependencies").iter().find(|d| d["name"] == "acadrust").expect("DWG decoder dependency")
};
let bridge = dependency("cadcraft-dwg");
let tests = dependency("cadcraft-io");
assert_eq!(tests["kind"], "dev", "the IO crate only inspects acadrust output in tests");
assert_eq!(tests["target"], bridge["target"], "--all-targets must not add acadrust where the native DWG bridge is unavailable");
assert_eq!(tests["req"], bridge["req"], "tests must inspect the same decoder version as the bridge");
assert_eq!(
tests["target"], "cfg(not(any(target_arch = \"wasm32\", all(target_os = \"freebsd\", target_arch = \"aarch64\"))))",
"--all-targets must not add acadrust where the shared DWG bridge excludes it"
);
assert_eq!(tests["req"], "^0.6.6", "tests inspect output from the decoder version used by craft-dwg");
}