mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-15 02:15:21 -06:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdb6b6e1f1 | ||
|
|
b0f91a8116 | ||
|
|
67514021ae | ||
|
|
8e69890223 | ||
|
|
fbb083803b | ||
|
|
54dfb2ee00 | ||
|
|
fa7a97728b | ||
|
|
2a9fc8f241 | ||
|
|
ddfa833bc2 | ||
|
|
bf581c2376 |
@@ -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"]'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¬es))),
|
||||
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 ¬es {
|
||||
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(¬es), [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_round_trip() {
|
||||
let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()]));
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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())))
|
||||
|
||||
@@ -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"))))
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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(¬e)).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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())]);
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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(¤t, "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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(§ions.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();
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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 Πρόσθετα εργαλεία
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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 추가 도구
|
||||
|
||||
|
@@ -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.
|
@@ -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 Дополнительные инструменты
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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");
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user