Files
OneCAD-Draft/src/io/mod.rs
T
Afonsop 6f429f2416 fix: close the review findings on the last web wave
Full verification of the XREF/data/ARCHIVE/worker wave (three
reviewers, one forensic) found no blockers and no crossfire damage;
five majors and several minors are fixed here.

- ARCHIVE now packages XREFs: the bytes registry added by the same
  commit is consulted (the in-code 'no xref store exists' comment was
  false within the commit), and the drawing is packaged only after
  the full save preparation — without it kernel solids shipped
  without their ACIS data.
- PendingClose stores the stable tab id instead of a vector index:
  the web save now completes asynchronously, and closing a lower tab
  during the serialization window shifted indices — closing the WRONG
  tab, or panicking on a stale one. All producers and consumers
  resolve the id at use.
- The xref bytes registry is capped (6 entries, oldest evicted): every
  open, drop, and pick registered full drawing bytes with no bound —
  an OOM waiting to happen. Reopened recents register too, so any
  entry path can serve as another drawing's xref.
- A re-picked data-link source with the same name but different bytes
  no longer silently replaces the first (the browser hands back bare
  names, so two same-named files collided and every reference read
  the newer pick): the pick is refused and the user is told why.
- The unresolved-xref warning lists file names (what to drop on the
  page), not internal block names; the ZIP writer refuses the ZIP64
  sentinel values (0xFFFF counts, 0xFFFFFFFF sizes/offsets) instead
  of emitting archives that send strict readers hunting for a ZIP64
  EOCD that is absent; docs note the worker-side saves.

Known follow-ups documented, not fixed here: the serde-skipped
ExtendedEntity/CadDocument raw bytes still drop on worker crossings
(needs acadrust accessors — upstream), and WBLOCK/SAVEALL/ARCHIVE
still serialize inline (the worker route is a small follow-up).
2026-09-18 00:32:59 +01:00

2967 lines
114 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// I/O module — open, save, and export CAD documents.
//
// All file reading/writing goes through acadrust.
// Default save format: DWG (AC1032 / R2018+).
#[cfg(not(target_arch = "wasm32"))]
pub mod edit_lock;
pub mod file_association;
pub mod linetypes;
pub mod obj;
pub mod paper_sizes;
pub mod patterns;
pub mod pdf_export;
pub mod plot_style;
pub mod print_to_printer;
pub mod recovery;
#[cfg(not(target_arch = "wasm32"))]
pub mod single_instance;
pub mod step;
pub mod stl;
pub mod thumbnail;
pub mod update_check;
#[cfg(target_arch = "wasm32")]
pub(crate) mod web_recent;
#[cfg(target_arch = "wasm32")]
mod web_worker;
pub mod xref;
// Registered on every target so the pure helpers (and their tests) compile on
// the native host; the OPFS half inside is wasm-only.
pub(crate) mod web_autosave;
#[cfg(target_arch = "wasm32")]
pub(crate) mod web_drop;
// Minimal in-memory ZIP writer (classic format, flate2 CRC/deflate) used by
// the web ARCHIVE/ETRANSMIT transmittal download.
pub mod zip;
use crate::scene::DerivedCaches;
use acadrust::entities::EntityType;
// The path-based readers are native-only; web parsing runs through the
// parse worker / the xref bytes registry instead.
#[cfg(not(target_arch = "wasm32"))]
use acadrust::io::dwg::DwgReader;
#[cfg(not(target_arch = "wasm32"))]
use acadrust::{DwgReadOptions, DxfReader, DxfReaderConfiguration};
use acadrust::{CadDocument, DwgWriter, DxfWriter};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU16, AtomicU32, AtomicU8, Ordering};
use std::sync::Arc;
/// Thread-safe state shared by the native loader and the open overlay.
///
/// `basis_points` is monotonic in 0..=10000. `completed/total` describe the
/// current sub-stage and let diagnostics distinguish real progress from a
/// cosmetic timer.
#[derive(Debug)]
pub struct OpenProgressState {
pub phase: AtomicU8,
pub basis_points: AtomicU16,
pub completed: AtomicU32,
pub total: AtomicU32,
}
impl OpenProgressState {
pub fn new(phase: u8) -> Self {
Self {
phase: AtomicU8::new(phase),
basis_points: AtomicU16::new(0),
completed: AtomicU32::new(0),
total: AtomicU32::new(1),
}
}
pub fn set(&self, phase: u8, basis_points: u16, completed: usize, total: usize) {
self.completed
.store(completed.min(u32::MAX as usize) as u32, Ordering::Relaxed);
self.total.store(
total.max(1).min(u32::MAX as usize) as u32,
Ordering::Relaxed,
);
self.basis_points
.fetch_max(basis_points.min(10000), Ordering::Relaxed);
self.phase.store(phase, Ordering::Release);
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_fraction(&self, phase: u8, base: u16, span: u16, completed: usize, total: usize) {
let denominator = total.max(1) as u64;
let value = base as u64 + (completed.min(total.max(1)) as u64 * span as u64 / denominator);
self.set(phase, value.min(10000) as u16, completed, total);
}
}
pub fn open_phase_name(phase: u8) -> &'static str {
match phase {
crate::app::OPEN_PHASE_READING => "reading",
crate::app::OPEN_PHASE_PARSING => "parsing",
crate::app::OPEN_PHASE_XREF => "references",
crate::app::OPEN_PHASE_CACHING => "derived-caches",
crate::app::OPEN_PHASE_FINALIZING => "finalizing",
_ => "unknown",
}
}
fn recovery_fingerprint_needed(caches: &DerivedCaches) -> bool {
let parser_issue = caches.read_stats.as_ref().is_some_and(|stats| {
stats.recovered() || stats.skipped_source_records > 0 || !stats.stream_completed
});
let reference_issue = caches.xrefs.iter().any(|item| {
matches!(
item.status,
crate::io::xref::XrefStatus::Recovered | crate::io::xref::XrefStatus::Failed
)
});
parser_issue || caches.corrupt_dropped > 0 || caches.xref_dropped > 0 || reference_issue
}
#[derive(Debug, Clone)]
pub struct OpenLoadError {
pub message: String,
pub source_sha256: Option<String>,
pub read_stats: Option<acadrust::ReadStats>,
pub recovery_available: bool,
}
impl OpenLoadError {
fn new(message: impl Into<String>, source_sha256: Option<String>) -> Self {
Self {
message: message.into(),
source_sha256,
read_stats: None,
recovery_available: false,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn recovery_prompt(
message: impl Into<String>,
read_stats: Option<acadrust::ReadStats>,
) -> Self {
Self {
message: message.into(),
source_sha256: None,
read_stats,
recovery_available: true,
}
}
}
impl std::fmt::Display for OpenLoadError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl From<String> for OpenLoadError {
fn from(message: String) -> Self {
Self::new(message, None)
}
}
impl From<&str> for OpenLoadError {
fn from(message: &str) -> Self {
Self::new(message, None)
}
}
// ── Open ──────────────────────────────────────────────────────────────────
/// Show the file picker and return the chosen path plus its size in bytes.
/// Returning size up-front lets the loading overlay display "47.3 MB" before
/// the parser thread starts.
#[cfg(not(target_arch = "wasm32"))]
pub async fn pick_open_path() -> Option<(PathBuf, u64)> {
let handle = crate::sys::file_dialog()
.set_title(crate::t!("Open CAD file").as_ref())
.add_filter(
crate::t!("CAD Files").as_ref(),
&["dwg", "dxf", "bak", "sv$", "DWG", "DXF", "BAK"],
)
.add_filter(crate::t!("DWG Files").as_ref(), &["dwg", "DWG"])
.add_filter(crate::t!("DXF Files").as_ref(), &["dxf", "DXF"])
.add_filter(
crate::t!("Backup / Autosave").as_ref(),
&["bak", "sv$", "BAK"],
)
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await?;
let path = crate::sys::handle_path(&handle);
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
Some((path, size))
}
/// Pick the drawing whose layers become translation targets. A standards file
/// is a drawing too, so `.dws` and `.dwt` sit alongside the ordinary formats
/// rather than needing anything of their own. (#624)
pub async fn pick_layer_standard_path() -> Option<PathBuf> {
let handle = crate::sys::file_dialog()
.set_title(crate::t!("Load layer standard").as_ref())
.add_filter(
crate::t!("Drawings and standards").as_ref(),
&["dwg", "dws", "dwt", "dxf", "DWG", "DWS", "DWT", "DXF"],
)
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await?;
Some(crate::sys::handle_path(&handle))
}
/// Pick where a set of layer mappings is written, or read back from.
pub async fn pick_layer_mapping_path(save: bool) -> Option<PathBuf> {
let dialog = crate::sys::file_dialog()
.set_title(
crate::t!(if save {
"Save layer mappings"
} else {
"Load layer mappings"
})
.as_ref(),
)
.add_filter(crate::t!("Layer mappings").as_ref(), &["ocslmap"])
.add_filter(crate::t!("All Files").as_ref(), &["*"]);
let handle = if save {
dialog.set_file_name("layers.ocslmap").save_file().await?
} else {
dialog.pick_file().await?
};
Some(crate::sys::handle_path(&handle))
}
/// Web SCRIPT: pick the .scr file through the browser and read its text —
/// the byte-level stand-in for the typed path the native command reads with
/// `std::fs::read_to_string`. `None` means the user cancelled.
#[cfg(target_arch = "wasm32")]
pub async fn pick_script_web() -> Option<(String, String)> {
let Some(handle) = crate::sys::file_dialog()
.set_title(crate::t!("SCRIPT path to the .scr file:").as_ref())
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await
else {
return None;
};
let name = handle.file_name();
let bytes = handle.read().await;
Some((name, String::from_utf8_lossy(&bytes).into_owned()))
}
/// Web CENTERLTYPEFILE: pick the linetype (.lin/.dnl) file through the
/// browser and read its text — the stand-in for the typed path the native
/// command reads inside `set_centerline_setting`. `None` = cancelled.
#[cfg(target_arch = "wasm32")]
pub async fn pick_linetype_file_web() -> Option<(String, String)> {
let Some(handle) = crate::sys::file_dialog()
.set_title(crate::t!("Load linetype file").as_ref())
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await
else {
return None;
};
let name = handle.file_name();
let bytes = handle.read().await;
Some((name, String::from_utf8_lossy(&bytes).into_owned()))
}
/// Load a CAD file from a known path. Parsing and cache building run on a
/// dedicated OS thread so the async executor stays free for rendering during
/// the load. Writes phase markers into `phase` so the UI can show
/// "Parsing entities…" / "Building caches…" / "Finalizing…" while the loader
/// thread runs.
#[cfg(not(target_arch = "wasm32"))]
pub async fn open_path_with_phase(
path: PathBuf,
progress: Arc<OpenProgressState>,
model_bg: [f32; 4],
) -> Result<(String, PathBuf, CadDocument, DerivedCaches), OpenLoadError> {
open_path_with_phase_attempt(path, progress, model_bg, OpenAttempt::Strict).await
}
#[cfg(target_arch = "wasm32")]
pub async fn open_path_with_phase(
_path: PathBuf,
_progress: Arc<OpenProgressState>,
_model_bg: [f32; 4],
) -> Result<(String, PathBuf, CadDocument, DerivedCaches), OpenLoadError> {
Err(OpenLoadError::from(
"filesystem path opening is unavailable on this target",
))
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn recover_path_with_phase(
path: PathBuf,
progress: Arc<OpenProgressState>,
model_bg: [f32; 4],
initial_error: String,
initial_stats: Option<acadrust::ReadStats>,
) -> Result<(String, PathBuf, CadDocument, DerivedCaches), OpenLoadError> {
open_path_with_phase_attempt(
path,
progress,
model_bg,
OpenAttempt::Recovery(initial_error, initial_stats),
)
.await
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone)]
enum OpenAttempt {
Strict,
Recovery(String, Option<acadrust::ReadStats>),
}
#[cfg(not(target_arch = "wasm32"))]
struct OpenAttemptFailure {
message: String,
read_stats: Option<acadrust::ReadStats>,
recoverable: bool,
}
#[cfg(not(target_arch = "wasm32"))]
impl From<String> for OpenAttemptFailure {
fn from(message: String) -> Self {
Self {
message,
read_stats: None,
recoverable: false,
}
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn open_path_with_phase_attempt(
path: PathBuf,
progress: Arc<OpenProgressState>,
model_bg: [f32; 4],
attempt: OpenAttempt,
) -> Result<(String, PathBuf, CadDocument, DerivedCaches), OpenLoadError> {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".into());
let path2 = path.clone();
let progress2 = progress.clone();
let recovery_available = matches!(attempt, OpenAttempt::Strict);
let (sender, receiver) = iced::futures::channel::oneshot::channel();
std::thread::Builder::new()
.name("ocs-file-open".to_string())
.spawn(move || {
let initial_fingerprint = crate::io::edit_lock::FileFingerprint::capture(&path2).ok();
let attempted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
(|| -> Result<_, OpenAttemptFailure> {
use iced::time::Instant;
progress2.set(crate::app::OPEN_PHASE_PARSING, 200, 0, 1000);
let t_parse = Instant::now();
let parser_progress = {
let progress = Arc::clone(&progress2);
let callback: Arc<dyn Fn(u16) + Send + Sync> = Arc::new(move |value| {
progress.set_fraction(
crate::app::OPEN_PHASE_PARSING,
200,
5600,
value as usize,
1000,
);
});
callback
};
std::fs::File::open(&path2).map_err(|error| OpenAttemptFailure {
message: format!("failed to open drawing: {error}"),
read_stats: None,
recoverable: false,
})?;
let outcome = load_file_for_open(&path2, Some(parser_progress), &attempt)?;
let read_stats = outcome.stats;
let mut doc = outcome.document;
let parse_ms = t_parse.elapsed().as_millis() as u32;
progress2.set(crate::app::OPEN_PHASE_PARSING, 5800, 1000, 1000);
let t_purge = Instant::now();
let dropped = purge_corrupt_entities(&mut doc);
let purge_ms = t_purge.elapsed().as_millis() as u32;
if matches!(attempt, OpenAttempt::Strict) && dropped > 0 {
return Err(OpenAttemptFailure {
message: format!(
"normal read found {dropped} structurally invalid drawing records"
),
read_stats: Some(read_stats),
recoverable: true,
});
}
progress2.set(crate::app::OPEN_PHASE_XREF, 6000, 0, 1);
let t_xref = Instant::now();
let (xref_infos, xref_dropped) = if let Some(base_dir) = path2.parent() {
let xref_progress = {
let progress = Arc::clone(&progress2);
let callback: Arc<dyn Fn(usize, usize) + Send + Sync> =
Arc::new(move |completed, total| {
progress.set_fraction(
crate::app::OPEN_PHASE_XREF,
6000,
1400,
completed,
total,
);
});
callback
};
crate::io::xref::resolve_xrefs_with_progress(
&mut doc,
base_dir,
Some(xref_progress),
)
} else {
(Vec::new(), 0)
};
let xref_ms = t_xref.elapsed().as_millis() as u32;
progress2.set(crate::app::OPEN_PHASE_CACHING, 7400, 0, 10000);
let t_caches = Instant::now();
let cache_progress = |value: u16| {
progress2.set_fraction(
crate::app::OPEN_PHASE_CACHING,
7400,
2200,
value as usize,
10000,
);
};
let mut caches = crate::scene::build_derived_caches_with_progress(
&doc,
&cache_progress,
path2.parent(),
);
caches.timings = crate::scene::OpenTimings {
parse_ms,
purge_ms,
caches_ms: t_caches.elapsed().as_millis() as u32,
xref_ms,
// Filled in after `prepare_open_geometry` below.
finalize_ms: 0,
};
caches.corrupt_dropped = dropped;
caches.read_stats = Some(read_stats);
caches.xref_dropped = xref_dropped;
caches.xrefs = xref_infos;
if recovery_fingerprint_needed(&caches) {
caches.source_sha256 =
stable_sha256_file(&path2, initial_fingerprint.as_ref());
}
progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9600, 0, 1);
let t_finalize = Instant::now();
let (prepared_doc, prepared_geometry) =
crate::scene::prepare_open_geometry(doc, &caches, model_bg);
caches.timings.finalize_ms = t_finalize.elapsed().as_millis() as u32;
doc = prepared_doc;
caches.prepared_geometry = Some(prepared_geometry);
progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9950, 1, 1);
// The localized command-line summary predates `finalize_ms` and
// is keyed on its format string, so the full breakdown goes out
// here instead of changing that key across every locale.
crate::perf_record!(
"[perf] open-phases parse={}ms purge={}ms xref={}ms caches={}ms finalize={}ms",
caches.timings.parse_ms,
caches.timings.purge_ms,
caches.timings.xref_ms,
caches.timings.caches_ms,
caches.timings.finalize_ms,
);
Ok((doc, caches))
})()
}));
let result = match attempted {
Ok(Ok(value)) => Ok(value),
Ok(Err(failure)) => Err(if recovery_available && failure.recoverable {
OpenLoadError::recovery_prompt(failure.message, failure.read_stats)
} else {
OpenLoadError {
message: failure.message,
source_sha256: stable_sha256_file(&path2, initial_fingerprint.as_ref()),
read_stats: failure.read_stats,
recovery_available: false,
}
}),
Err(payload) => {
let message = format!(
"file-open worker panicked: {}",
panic_message(payload.as_ref())
);
Err(OpenLoadError {
message,
source_sha256: stable_sha256_file(&path2, initial_fingerprint.as_ref()),
read_stats: None,
recovery_available: false,
})
}
};
let _ = sender.send(result);
})
.map_err(|error| OpenLoadError::from(format!("failed to start parser thread: {error}")))?;
let (doc, caches) = receiver
.await
.map_err(|_| OpenLoadError::from("parser thread stopped without a result"))??;
Ok((name, path, doc, caches))
}
#[cfg(not(target_arch = "wasm32"))]
fn stable_sha256_file(
path: &Path,
initial: Option<&crate::io::edit_lock::FileFingerprint>,
) -> Option<String> {
let initial = initial?;
let before = crate::io::edit_lock::FileFingerprint::capture(path).ok()?;
if &before != initial {
return None;
}
let digest = crate::io::recovery::sha256_file(path).ok()?;
let after = crate::io::edit_lock::FileFingerprint::capture(path).ok()?;
(after == before).then_some(digest)
}
#[cfg(not(target_arch = "wasm32"))]
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
let message = payload
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| payload.downcast_ref::<&str>().copied())
.unwrap_or("unknown panic payload");
message.chars().take(500).collect()
}
/// Web file open: show the browser picker, read the chosen file's bytes, parse
/// it, and build the derived caches — producing the same payload as the native
/// `open_path_with_phase` so it can feed the existing `Message::FileOpened`
/// handler. There is no filesystem path on the web, so a name-only `PathBuf`
/// stands in for the document path.
#[cfg(target_arch = "wasm32")]
#[derive(Debug, Clone)]
pub struct WebOpenOutcome {
pub name: String,
pub size_bytes: u64,
pub result: Result<(String, PathBuf, CadDocument, DerivedCaches), OpenLoadError>,
pub recovery_bytes: Option<Arc<[u8]>>,
pub cache_bytes: Option<Arc<[u8]>>,
pub record_recent: bool,
}
#[cfg(target_arch = "wasm32")]
pub async fn pick_and_load_web(progress: Arc<OpenProgressState>) -> WebOpenOutcome {
let Some(handle) = crate::sys::file_dialog()
.set_title(crate::t!("Open CAD file").as_ref())
.add_filter(
crate::t!("CAD Files").as_ref(),
&["dwg", "dxf", "DWG", "DXF"],
)
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await
else {
return WebOpenOutcome {
name: "Opening…".to_string(),
size_bytes: 0,
result: Err(OpenLoadError::from("Cancelled")),
recovery_bytes: None,
cache_bytes: None,
record_recent: false,
};
};
let name = handle.file_name();
progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2);
let bytes: Arc<[u8]> = Arc::from(handle.read().await);
// The opened drawing's own bytes enter the xref registry too: another
// open drawing may reference it by name, and the registry — not the
// absent filesystem — is what re-resolves that reference in-session.
crate::io::xref::register_source(&name, Arc::new(bytes.to_vec()));
let size_bytes = bytes.len() as u64;
let result = load_web_bytes(&name, &bytes, progress.clone(), false, "", None).await;
let keep_for_recovery = result
.as_ref()
.err()
.is_some_and(|error| error.recovery_available);
let cache_bytes = result.is_ok().then(|| Arc::clone(&bytes));
WebOpenOutcome {
name,
size_bytes,
result,
recovery_bytes: keep_for_recovery.then(|| Arc::clone(&bytes)),
cache_bytes,
record_recent: false,
}
}
/// Reopen a browser-private recent copy without showing the file picker.
#[cfg(target_arch = "wasm32")]
pub async fn open_recent_web(path: PathBuf, progress: Arc<OpenProgressState>) -> WebOpenOutcome {
open_recent_web_attempt(path, progress, false, String::new()).await
}
#[cfg(target_arch = "wasm32")]
pub async fn recover_web_bytes(
name: String,
bytes: Arc<[u8]>,
progress: Arc<OpenProgressState>,
initial_error: String,
initial_stats: Option<acadrust::ReadStats>,
) -> WebOpenOutcome {
let size_bytes = bytes.len() as u64;
let result = load_web_bytes(&name, &bytes, progress, true, &initial_error, initial_stats).await;
WebOpenOutcome {
name,
size_bytes,
result,
recovery_bytes: None,
cache_bytes: None,
record_recent: false,
}
}
#[cfg(target_arch = "wasm32")]
async fn open_recent_web_attempt(
path: PathBuf,
progress: Arc<OpenProgressState>,
recovery_mode: bool,
initial_error: String,
) -> WebOpenOutcome {
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
let bytes = match web_recent::read(&name).await {
Ok(bytes) => bytes,
Err(error) => {
return WebOpenOutcome {
name: name.clone(),
size_bytes: 0,
result: Err(OpenLoadError::from(format!(
"Recent copy unavailable for \"{name}\": {error}"
))),
recovery_bytes: None,
cache_bytes: None,
record_recent: false,
};
}
};
progress.set(
crate::app::OPEN_PHASE_READING,
1000,
bytes.len(),
bytes.len(),
);
let result = load_web_bytes(&name, &bytes, progress, recovery_mode, &initial_error, None).await;
// A recent reopened from OPFS registers like a picked or dropped file,
// so it can serve as another drawing's xref in this session.
#[cfg(target_arch = "wasm32")]
xref::register_source(&name, Arc::new(bytes.to_vec()));
let keep_for_recovery = result
.as_ref()
.err()
.is_some_and(|error| error.recovery_available);
let record_recent = result.is_ok();
WebOpenOutcome {
name: name.clone(),
size_bytes: bytes.len() as u64,
result,
recovery_bytes: keep_for_recovery.then(|| Arc::from(bytes)),
cache_bytes: None,
record_recent,
}
}
#[cfg(target_arch = "wasm32")]
async fn load_web_bytes(
name: &str,
bytes: &[u8],
progress: Arc<OpenProgressState>,
recovery_mode: bool,
initial_error: &str,
mut initial_stats: Option<acadrust::ReadStats>,
) -> Result<(String, PathBuf, CadDocument, DerivedCaches), OpenLoadError> {
progress.set(crate::app::OPEN_PHASE_PARSING, 1000, 0, 1);
let (outcome, mut source_sha256) =
match web_worker::parse_document(name, bytes, recovery_mode, initial_error).await {
Ok(result) => result,
Err(error) => {
let recoverable_parse_error = error.recovery_available;
let mut source_sha256 = error.source_sha256;
if recovery_mode && source_sha256.is_none() {
source_sha256 = web_worker::sha256_document(bytes).await.ok();
}
let read_stats = merge_read_stats(error.read_stats, initial_stats.take());
let message = if recovery_mode && !error.message.contains("initial read failed:") {
format!(
"initial read failed: {initial_error}; recovery read failed: {}",
error.message
)
} else {
error.message
};
return Err(OpenLoadError {
message: format!("Web parser worker: {message}"),
recovery_available: !recovery_mode && recoverable_parse_error,
source_sha256,
read_stats,
});
}
};
let mut outcome = outcome;
if let Some(initial_stats) = initial_stats.take() {
merge_read_diagnostics(&mut outcome.stats, initial_stats);
}
let mut doc = outcome.document;
normalize_block_origins(&mut doc);
if name.to_ascii_lowercase().ends_with(".dxf") {
fix_dxf_dimension_rotations(&mut doc);
fix_dxf_layout_plot_settings(&mut doc);
}
fix_viewport_status_flags(&mut doc);
fix_current_style_names(&mut doc);
progress.set(crate::app::OPEN_PHASE_CACHING, 7000, 0, 1);
let dropped = purge_corrupt_entities(&mut doc);
if !recovery_mode && dropped > 0 {
return Err(OpenLoadError {
message: format!("normal read found {dropped} structurally invalid drawing records"),
source_sha256: None,
read_stats: Some(outcome.stats),
recovery_available: true,
});
}
// XREFs inside the opened drawing resolve from the in-session bytes
// registry (see `resolve_web_xrefs`) before the caches snapshot the
// document, so merged xref geometry is part of the caches — the same
// phase order the native open uses.
let (xref_infos, xref_dropped) = resolve_web_xrefs(&mut doc, progress.clone()).await;
let mut caches = crate::scene::build_derived_caches(&doc);
caches.corrupt_dropped = dropped;
caches.read_stats = Some(outcome.stats);
caches.xrefs = xref_infos;
caches.xref_dropped = xref_dropped;
if source_sha256.is_none() && recovery_fingerprint_needed(&caches) {
source_sha256 = web_worker::sha256_document(bytes).await.ok();
}
caches.source_sha256 = source_sha256;
progress.set(crate::app::OPEN_PHASE_FINALIZING, 9900, 1, 1);
let path = PathBuf::from(name);
Ok((name.to_string(), path, doc, caches))
}
/// Web: parse a *second* drawing (a block to insert, a layer-standards
/// file) from bytes on the parse worker, with the same post-parse fixups
/// `load_web_bytes` applies — the byte-level counterpart of `load_file`.
#[cfg(target_arch = "wasm32")]
async fn parse_web_document(name: &str, bytes: &[u8]) -> Result<CadDocument, String> {
let (outcome, _) = web_worker::parse_document(name, bytes, false, "")
.await
.map_err(|error| error.message)?;
let mut doc = outcome.document;
normalize_block_origins(&mut doc);
if name.to_ascii_lowercase().ends_with(".dxf") {
fix_dxf_dimension_rotations(&mut doc);
fix_dxf_layout_plot_settings(&mut doc);
}
fix_viewport_status_flags(&mut doc);
fix_current_style_names(&mut doc);
Ok(doc)
}
/// Web: show the browser picker, read the chosen file's bytes, and parse it
/// as a drawing on the worker — `pick_and_load_web`'s pick-read-parse core
/// for features that consume a second drawing instead of opening a tab.
/// `Ok(None)` means the user cancelled.
#[cfg(target_arch = "wasm32")]
pub async fn pick_and_parse_web(
title: &str,
filter_label: &str,
extensions: &[&str],
) -> Result<Option<(String, CadDocument)>, String> {
let Some(handle) = crate::sys::file_dialog()
.set_title(title)
.add_filter(filter_label, extensions)
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await
else {
return Ok(None);
};
let name = handle.file_name();
let bytes = handle.read().await;
let document = parse_web_document(&name, &bytes).await?;
Ok(Some((name, document)))
}
/// Web: parse drawing bytes already in hand on the shared worker — the
/// byte-level counterpart of `load_file` for callers that ran their own
/// picker (Data Extraction's multi-file source list). Same normalization as
/// every other web-opened drawing.
#[cfg(target_arch = "wasm32")]
pub async fn parse_drawing_bytes_web(name: &str, bytes: &[u8]) -> Result<CadDocument, String> {
parse_web_document(name, bytes).await
}
/// Web: resolve the XREF blocks inside a freshly opened drawing against the
/// in-session bytes registry — the browser counterpart of the native open's
/// xref phase. Every referenced file whose bytes are registered (picked or
/// dropped earlier this session) is parsed on the shared worker first, so
/// the UI thread only runs the (cheap) merge; unregistered references come
/// back as `NotFound` for the open handler to warn about.
#[cfg(target_arch = "wasm32")]
async fn resolve_web_xrefs(
doc: &mut CadDocument,
progress: Arc<OpenProgressState>,
) -> (Vec<crate::io::xref::XrefInfo>, usize) {
let pending: Vec<String> = doc
.block_records
.iter()
.filter(|br| (br.flags.is_xref || br.flags.is_xref_overlay) && !br.xref_path.is_empty())
.map(|br| br.xref_path.clone())
.collect();
let total = pending.len().max(1);
for (index, raw_path) in pending.iter().enumerate() {
progress.set(crate::app::OPEN_PHASE_XREF, 5900, index, total);
let Some(key) = crate::io::xref::find_registered_source(raw_path) else {
continue;
};
let Some(bytes) = crate::io::xref::registered_bytes(&key) else {
continue;
};
// The strict worker refuses recoverable parses; those fall through
// to a direct registry read inside the resolve so their stats (and
// the Failed status derived from them) match the native loader.
if let Ok((outcome, _)) = web_worker::parse_document(&key, &bytes, false, "").await {
crate::io::xref::register_parsed(&key, outcome);
}
}
progress.set(crate::app::OPEN_PHASE_XREF, 5900, total, total);
// `set_fraction` is native-only; the web phase reports completed/total
// directly, which the open overlay surfaces alongside the fixed weight.
let xref_progress: Arc<dyn Fn(usize, usize) + Send + Sync> = {
let progress = Arc::clone(&progress);
Arc::new(move |completed, total| {
progress.set(crate::app::OPEN_PHASE_XREF, 5900, completed, total);
})
};
crate::io::xref::resolve_xrefs_with_progress(doc, Path::new(""), Some(xref_progress))
}
/// Web XATTACH pick: read the chosen drawing's bytes, keep them in the xref
/// registry (so later reloads and re-opens of the host drawing re-resolve
/// the reference in-session), and pre-parse on the worker so the
/// commit-time merge stays cheap. A parse failure defers to commit time,
/// which re-reads the registered bytes and reports the same Failed status
/// the native attach would. `Err("Cancelled")` mirrors the native pick.
#[cfg(target_arch = "wasm32")]
pub async fn pick_and_register_xref_web() -> Result<PathBuf, String> {
let Some(handle) = crate::sys::file_dialog()
.set_title(crate::t!("Select External Reference File").as_ref())
.add_filter(
crate::t!("CAD Files").as_ref(),
&["dwg", "dxf", "bak", "DWG", "DXF", "BAK"],
)
.add_filter(crate::t!("DWG Files").as_ref(), &["dwg", "DWG"])
.add_filter(crate::t!("DXF Files").as_ref(), &["dxf", "DXF"])
.add_filter(crate::t!("Backup Files").as_ref(), &["bak", "BAK"])
.pick_file()
.await
else {
return Err("Cancelled".to_string());
};
let name = handle.file_name();
let bytes = handle.read().await;
crate::io::xref::register_source(&name, Arc::new(bytes.clone()));
if let Ok((outcome, _)) = web_worker::parse_document(&name, &bytes, false, "").await {
crate::io::xref::register_parsed(&name, outcome);
}
Ok(PathBuf::from(name))
}
/// Parse a drawing from bytes using its name or recovery-file signature.
#[cfg(not(target_arch = "wasm32"))]
pub fn load_bytes(name: &str, bytes: Vec<u8>) -> Result<CadDocument, String> {
use std::io::Cursor;
let ext = name.rsplit('.').next().unwrap_or_default().to_lowercase();
let format = if matches!(ext.as_str(), "bak" | "sv$") {
sniff_dwg_or_dxf_bytes(&bytes)
} else {
ext.as_str()
};
match format {
"dwg" => {
let mut doc = DwgReader::from_stream(Cursor::new(bytes))
.read()
.map_err(|e| e.to_string())?;
fix_viewport_status_flags(&mut doc);
fix_current_style_names(&mut doc);
Ok(doc)
}
"dxf" => {
let mut doc = DxfReader::from_reader(Cursor::new(bytes))
.map_err(|e| e.to_string())?
.read()
.map_err(|e| e.to_string())?;
fix_dxf_dimension_rotations(&mut doc);
fix_dxf_layout_plot_settings(&mut doc);
fix_viewport_status_flags(&mut doc);
fix_current_style_names(&mut doc);
Ok(doc)
}
_ => Err(format!("Unsupported file format: .{ext}")),
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn load_bytes_finalized(path: &Path, bytes: Vec<u8>) -> Result<(CadDocument, usize), String> {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
let mut doc = load_bytes(&name, bytes)?;
normalize_block_origins(&mut doc);
resolve_raster_image_paths(&mut doc, path.parent());
doc.source_path = Some(path.to_string_lossy().into_owned());
let dropped = purge_corrupt_entities(&mut doc);
Ok((doc, dropped))
}
/// Load a DWG or DXF file directly from a path (auto-detect by extension).
/// Peek at a file's leading bytes to tell a DWG (version tag "AC10xx") from a
/// DXF. Used for `.bak` copies, whose extension hides the real format.
/// Native-only: the web reads registered bytes instead of the filesystem.
#[cfg(not(target_arch = "wasm32"))]
fn sniff_dwg_or_dxf(path: &Path) -> String {
use std::io::Read;
let mut buf = [0u8; 6];
if let Ok(mut f) = std::fs::File::open(path) {
let _ = f.read(&mut buf);
}
sniff_dwg_or_dxf_bytes(&buf).to_string()
}
fn sniff_dwg_or_dxf_bytes(bytes: &[u8]) -> &'static str {
if bytes.starts_with(b"AC10") {
"dwg"
} else {
"dxf"
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn load_file(path: &Path) -> Result<CadDocument, String> {
load_file_with_progress(path, None).map(|outcome| outcome.document)
}
/// Opening a drawing by path is a desktop affair. In the browser a file
/// arrives through the page rather than from a filesystem the app can reach,
/// so there is nothing behind a path to open.
///
/// The function still exists there so the features that read a *second*
/// drawing — importing one as a block, taking layer standards from one — go on
/// compiling and say why they cannot run, instead of each having to know that
/// the web has no files.
#[cfg(target_arch = "wasm32")]
pub fn load_file(_path: &Path) -> Result<CadDocument, String> {
Err(crate::t!("Opening a drawing by path is not available in the browser.").into_owned())
}
/// Path-based reader with progress — native-only: on the web xrefs resolve
/// from the bytes registry (`io::xref::read_registered_outcome`), never a
/// filesystem path.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn load_file_with_progress(
path: &Path,
_progress: Option<Arc<dyn Fn(u16) + Send + Sync>>,
) -> Result<acadrust::ReadOutcome, String> {
let outcome = read_file_attempt(path, _progress, false).map_err(|failure| failure.message)?;
if !outcome.stats.has_usable_drawing_data() {
return Err("initial read returned no source drawing records".to_string());
}
finalize_loaded_outcome(path, outcome)
}
#[cfg(not(target_arch = "wasm32"))]
fn load_file_for_open(
path: &Path,
progress: Option<Arc<dyn Fn(u16) + Send + Sync>>,
attempt: &OpenAttempt,
) -> Result<acadrust::ReadOutcome, OpenAttemptFailure> {
let outcome = match attempt {
OpenAttempt::Strict => {
let outcome =
read_file_attempt(path, progress, false).map_err(|failure| OpenAttemptFailure {
message: failure.message,
read_stats: None,
recoverable: failure.recoverable,
})?;
if !outcome.stats.has_usable_drawing_data() {
return Err(OpenAttemptFailure {
message: "initial read returned no source drawing records".to_string(),
read_stats: Some(outcome.stats),
recoverable: true,
});
}
if outcome.stats.recovered()
|| outcome.stats.skipped_source_records > 0
|| !outcome.stats.stream_completed
{
let message = outcome
.stats
.diagnostics
.first()
.map(|diagnostic| diagnostic.message.clone())
.unwrap_or_else(|| {
"normal read detected recoverable drawing errors".to_string()
});
return Err(OpenAttemptFailure {
message,
read_stats: Some(outcome.stats),
recoverable: true,
});
}
outcome
}
OpenAttempt::Recovery(initial_error, initial_stats) => {
let mut outcome =
read_file_attempt(path, progress, true).map_err(|failure| OpenAttemptFailure {
message: format!(
"initial read failed: {initial_error}; recovery read failed: {}",
failure.message
),
read_stats: initial_stats.clone(),
recoverable: false,
})?;
if !outcome.stats.has_usable_drawing_data() {
if let Some(initial_stats) = initial_stats.clone() {
merge_read_diagnostics(&mut outcome.stats, initial_stats);
}
return Err(OpenAttemptFailure {
message: format!(
"initial read failed: {initial_error}; recovery found no usable drawing data"
),
read_stats: Some(outcome.stats),
recoverable: false,
});
}
outcome.document.notifications.notify(
acadrust::notification::NotificationType::Error,
format!("Initial read failed; recovery mode continued: {initial_error}"),
);
acadrust::push_read_diagnostic(
&mut outcome.stats.diagnostics,
acadrust::ReadDiagnostic::new(
"strict-read-failed",
acadrust::ReadStage::RecordStream,
initial_error.clone(),
),
);
outcome.stats.recovered_errors = outcome.stats.recovered_errors.saturating_add(1);
if let Some(initial_stats) = initial_stats.clone() {
merge_read_diagnostics(&mut outcome.stats, initial_stats);
}
outcome
}
};
finalize_loaded_outcome(path, outcome).map_err(OpenAttemptFailure::from)
}
#[cfg(not(target_arch = "wasm32"))]
struct ReaderFailure {
message: String,
#[cfg(not(target_arch = "wasm32"))]
recoverable: bool,
}
#[cfg(not(target_arch = "wasm32"))]
impl ReaderFailure {
fn from_reader(error: acadrust::DxfError) -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
recoverable: recoverable_reader_error(&error),
message: error.to_string(),
}
}
fn terminal(message: impl Into<String>) -> Self {
Self {
message: message.into(),
#[cfg(not(target_arch = "wasm32"))]
recoverable: false,
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn recoverable_reader_error(error: &acadrust::DxfError) -> bool {
matches!(
error,
acadrust::DxfError::Compression(_)
| acadrust::DxfError::Parse(_)
| acadrust::DxfError::InvalidDxfCode(_)
| acadrust::DxfError::InvalidHandle(_)
| acadrust::DxfError::ObjectNotFound(_)
| acadrust::DxfError::InvalidEntityType(_)
| acadrust::DxfError::ChecksumMismatch { .. }
| acadrust::DxfError::InvalidHeader(_)
| acadrust::DxfError::InvalidFormat(_)
| acadrust::DxfError::InvalidSentinel(_)
| acadrust::DxfError::Decompression(_)
| acadrust::DxfError::Encoding(_)
)
}
fn merge_read_diagnostics(target: &mut acadrust::ReadStats, source: acadrust::ReadStats) {
for diagnostic in source.diagnostics {
if !target.diagnostics.contains(&diagnostic) {
acadrust::push_read_diagnostic(&mut target.diagnostics, diagnostic);
}
}
}
#[cfg(target_arch = "wasm32")]
fn merge_read_stats(
primary: Option<acadrust::ReadStats>,
fallback: Option<acadrust::ReadStats>,
) -> Option<acadrust::ReadStats> {
match (primary, fallback) {
(Some(mut primary), Some(fallback)) => {
merge_read_diagnostics(&mut primary, fallback);
Some(primary)
}
(Some(primary), None) => Some(primary),
(None, fallback) => fallback,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn read_file_attempt(
path: &Path,
progress: Option<Arc<dyn Fn(u16) + Send + Sync>>,
failsafe: bool,
) -> Result<acadrust::ReadOutcome, ReaderFailure> {
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
// A `.bak` backup or `.sv$` autosave holds a verbatim DWG/DXF copy — detect
// the real format from the file's leading bytes, not the extension.
let effective = if ext == "bak" || ext == "sv$" {
sniff_dwg_or_dxf(path)
} else {
ext.clone()
};
match effective.as_str() {
"dwg" => read_dwg_path(path, progress, failsafe),
"dxf" => read_dxf_path(path, failsafe),
_ => Err(ReaderFailure::terminal(format!(
"Unsupported file format: .{ext}"
))),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn finalize_loaded_outcome(
path: &Path,
mut outcome: acadrust::ReadOutcome,
) -> Result<acadrust::ReadOutcome, String> {
let doc = &mut outcome.document;
normalize_block_origins(doc);
if outcome.stats.source_format == Some(acadrust::SourceFormat::Dxf) {
fix_dxf_dimension_rotations(doc);
fix_dxf_layout_plot_settings(doc);
}
fix_viewport_status_flags(doc);
fix_current_style_names(doc);
resolve_raster_image_paths(doc, path.parent());
doc.source_path = Some(path.to_string_lossy().into_owned());
Ok(outcome)
}
#[cfg(not(target_arch = "wasm32"))]
fn read_dwg_path(
path: &Path,
progress: Option<Arc<dyn Fn(u16) + Send + Sync>>,
failsafe: bool,
) -> Result<acadrust::ReadOutcome, ReaderFailure> {
let options = if failsafe {
DwgReadOptions::failsafe()
} else {
DwgReadOptions::default()
};
let mut reader = {
let mut reader = DwgReader::from_mmap(path).map_err(ReaderFailure::from_reader)?;
reader.options = options;
reader
};
if let Some(progress) = progress {
reader.set_progress_callback(progress);
}
reader.read_with_stats().map_err(ReaderFailure::from_reader)
}
#[cfg(not(target_arch = "wasm32"))]
fn read_dxf_path(path: &Path, failsafe: bool) -> Result<acadrust::ReadOutcome, ReaderFailure> {
DxfReader::from_file(path)
.map_err(ReaderFailure::from_reader)?
.with_configuration(DxfReaderConfiguration {
failsafe,
..DxfReaderConfiguration::default()
})
.read_with_stats()
.map_err(ReaderFailure::from_reader)
}
/// Canonicalise legacy block-table origins to the post-R10 representation:
/// block contents are local around zero and INSERT carries placement.
///
/// DWG exposes the legacy point on `BlockRecord::base_point`; DXF exposes it
/// on the structural BLOCK entity. Rendering, picking, exploding and nested
/// insertion can then share the ordinary zero-origin transform without each
/// path having to reinterpret this compatibility field.
fn normalize_block_origins(doc: &mut CadDocument) {
use acadrust::types::Vector3;
use acadrust::EntityType;
let blocks: Vec<_> = doc
.block_records
.iter()
.filter_map(|record| {
let marker_point = match doc.get_entity(record.block_entity_handle) {
Some(EntityType::Block(block)) => block.base_point,
_ => Vector3::ZERO,
};
let base_point = if marker_point.length() > 1e-12 {
marker_point
} else {
record.base_point
};
(base_point.length() > 1e-12).then(|| {
(
record.name.clone(),
record.block_entity_handle,
record.entity_handles.clone(),
base_point,
)
})
})
.collect();
for (name, marker_handle, handles, base_point) in blocks {
let offset = base_point * -1.0;
for handle in handles {
let Some(entity) = doc.get_entity_mut(handle) else {
continue;
};
match entity {
EntityType::Block(block) => block.base_point = Vector3::ZERO,
EntityType::BlockEnd(_) => {}
_ => entity.translate(offset),
}
}
if let Some(EntityType::Block(block)) = doc.get_entity_mut(marker_handle) {
block.base_point = Vector3::ZERO;
}
if let Some(record) = doc.block_records.get_mut(&name) {
record.base_point = Vector3::ZERO;
}
}
}
/// A RasterImage entity stores its file path on the linked ImageDefinition,
/// often as the original author's absolute path (e.g. a Windows / VMware share)
/// that doesn't exist on this machine. Resolve each image to a usable path:
/// the stored path if it exists, otherwise the same file name next to the
/// drawing — and write the result onto the entity so the renderer finds it.
/// Native-only: its callers resolve against a real folder; web images bind
/// through the name-keyed byte registry instead.
#[cfg(not(target_arch = "wasm32"))]
fn resolve_raster_image_paths(doc: &mut CadDocument, base_dir: Option<&Path>) {
use acadrust::objects::ObjectType;
use acadrust::EntityType;
use std::collections::HashMap;
let defs: HashMap<acadrust::Handle, String> = doc
.objects
.iter()
.filter_map(|(h, o)| match o {
ObjectType::ImageDefinition(d) => Some((*h, d.file_name.clone())),
_ => None,
})
.collect();
for e in doc.entities_mut() {
if let EntityType::RasterImage(img) = e {
let raw = if !img.file_path.trim().is_empty() {
img.file_path.clone()
} else {
img.definition_handle
.and_then(|h| defs.get(&h).cloned())
.unwrap_or_default()
};
if raw.trim().is_empty() {
continue;
}
if let Some(resolved) = resolve_image_file(&raw, base_dir) {
img.file_path = resolved;
} else {
// At least surface the stored path so the renderer can try it.
img.file_path = raw;
}
}
}
// Underlay definitions (PDF/DWF/DGN) store their file the same way raster
// images do — resolve them with the same fallbacks so a drawing shipped
// next to its PDF finds it even when the stored relative path is stale.
for o in doc.objects.values_mut() {
if let ObjectType::UnderlayDefinition(def) = o {
if def.file_path.trim().is_empty() {
continue;
}
if let Some(resolved) = resolve_image_file(&def.file_path, base_dir) {
def.file_path = resolved;
}
}
}
}
/// Resolve a (possibly foreign / absolute) image path to an existing file:
/// as stored, then relative to the drawing folder, then just the file name
/// next to the drawing.
pub(crate) fn resolve_image_file(raw: &str, base_dir: Option<&Path>) -> Option<String> {
if Path::new(raw).is_file() {
return Some(raw.to_string());
}
let base_dir = base_dir?;
let joined = base_dir.join(raw);
if joined.is_file() {
return Some(joined.to_string_lossy().into_owned());
}
let name = raw.rsplit(|c| c == '/' || c == '\\').next().unwrap_or(raw);
let cand = base_dir.join(name);
if cand.is_file() {
return Some(cand.to_string_lossy().into_owned());
}
None
}
// ── Save ──────────────────────────────────────────────────────────────────
pub const DEFAULT_SAVE_FORMAT: &str = "DWG 2018";
pub const SAVE_FORMAT_OPTIONS: &[&str] = &[
"DWG 2018", "DWG 2013", "DWG 2010", "DWG 2007", "DWG 2004", "DWG 2000", "DWG R14", "DXF 2018",
"DXF 2013", "DXF 2010", "DXF 2007", "DXF 2004", "DXF 2000", "DXF R14",
];
pub fn canonical_save_format(format: &str) -> &'static str {
SAVE_FORMAT_OPTIONS
.iter()
.copied()
.find(|candidate| candidate.eq_ignore_ascii_case(format))
.unwrap_or(DEFAULT_SAVE_FORMAT)
}
pub fn source_is_dxf(path: Option<&Path>, document: &CadDocument) -> bool {
match path
.and_then(Path::extension)
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("dxf") => true,
Some("dwg") => false,
_ => document.dwg_source_version.is_none(),
}
}
/// Parse a format string like "DWG 2013" or "DXF 2007" into
/// `(extension, DxfVersion)`. Falls back to ("dwg", AC1032) for unknown strings.
pub fn parse_save_format(format: &str) -> (&'static str, acadrust::DxfVersion) {
use acadrust::DxfVersion;
let f = format.to_ascii_uppercase();
let is_dxf = f.starts_with("DXF");
let ext = if is_dxf { "dxf" } else { "dwg" };
let version = if f.contains("2013") {
DxfVersion::AC1027
} else if f.contains("2010") {
DxfVersion::AC1024
} else if f.contains("2007") {
DxfVersion::AC1021
} else if f.contains("2004") {
DxfVersion::AC1018
} else if f.contains("2000") {
DxfVersion::AC1015
} else if f.contains("R14") {
DxfVersion::AC1014
} else {
DxfVersion::AC1032
}; // 2018
(ext, version)
}
/// Reverse of [`parse_save_format`]: the Save-dialog format string for a
/// version + DXF/DWG choice (e.g. `AC1018, is_dxf=false` -> `"DWG 2004"`).
/// Used to default the Save-As dropdown to the loaded file's version so a
/// round-trip preserves it instead of silently offering "DWG 2018".
pub fn format_for_version(version: acadrust::DxfVersion, is_dxf: bool) -> String {
use acadrust::DxfVersion::*;
let year = match version {
AC1032 => "2018",
AC1027 => "2013",
AC1024 => "2010",
AC1021 => "2007",
AC1018 => "2004",
AC1015 => "2000",
AC1014 => "R14",
_ => "2018",
};
format!("{} {}", if is_dxf { "DXF" } else { "DWG" }, year)
}
/// Count of unsupported "raw passthrough" objects/entities — AEC / application
/// objects with no native representation, kept only as verbatim source-version
/// bytes — that would be DROPPED when saving `doc` to `target_version` (or to
/// DXF). Returns 0 for a same-version DWG save, where they round-trip verbatim.
/// Used to warn the user before a lossy Save-As.
pub fn dropped_on_save_count(
doc: &acadrust::CadDocument,
target_version: acadrust::DxfVersion,
is_dxf: bool,
) -> usize {
if !is_dxf && doc.dwg_source_version == Some(target_version) {
return 0;
}
let mut n = doc
.objects
.values()
.filter(|object| match object {
acadrust::objects::ObjectType::Unknown {
raw_dxf_codes,
raw_dwg_data,
raw_dwg_version,
..
} => {
if is_dxf {
raw_dxf_codes.is_none()
} else {
raw_dwg_data.is_none()
|| raw_dwg_version.is_some_and(|source| source != target_version)
}
}
_ => false,
})
.count();
for e in doc.entities() {
let dropped = match e {
acadrust::EntityType::Unknown(entity) => {
if is_dxf {
entity.raw_dxf_codes.is_none()
} else {
entity.raw_dwg_data.is_none()
|| entity
.dwg_source_version
.is_some_and(|source| source != target_version)
}
}
_ => false,
};
if dropped {
n += 1;
}
}
n
}
/// Before overwriting `path`, copy the existing file to a sibling `<name>.bak`
/// so a faulty or accidental save can be recovered (#205). Best-effort: a
/// failed backup never blocks the save itself.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub fn write_backup(path: &std::path::Path) {
if path.exists() {
let _ = std::fs::copy(path, path.with_extension("bak"));
}
}
/// Structured native-save failure. The UI needs the OS error category after
/// the worker completes so file-sharing violations can offer recovery actions
/// instead of being flattened into an opaque command-line string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveFailure {
pub message: String,
pub file_in_use: bool,
pub externally_modified: bool,
}
impl SaveFailure {
pub fn other(message: impl Into<String>) -> Self {
Self {
message: message.into(),
file_in_use: false,
externally_modified: false,
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn file_in_use(message: impl Into<String>) -> Self {
Self {
message: message.into(),
file_in_use: true,
externally_modified: false,
}
}
fn replacing(path: &Path, error: std::io::Error) -> Self {
Self {
message: format!("replace {}: {error}", path.display()),
file_in_use: replace_error_is_file_in_use(&error),
externally_modified: false,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn externally_modified(path: &Path) -> Self {
Self {
message: format!("{} changed on disk after it was opened", path.display()),
file_in_use: false,
externally_modified: true,
}
}
}
impl std::fmt::Display for SaveFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for SaveFailure {}
const SUPPORTED_SAVE_FORMATS: &str = ".dwg, .dxf";
fn validate_save_extension(path: &Path) -> Result<(), SaveFailure> {
let extension = path
.extension()
.map(|value| value.to_string_lossy().to_lowercase())
.unwrap_or_default();
if matches!(extension.as_str(), "dwg" | "dxf" | "sv$") {
return Ok(());
}
let requested = if extension.is_empty() {
"<none>".to_string()
} else {
format!(".{extension}")
};
Err(SaveFailure::other(format!(
"unsupported output format {requested}; supported formats: {SUPPORTED_SAVE_FORMATS}"
)))
}
fn replace_error_is_file_in_use(error: &std::io::Error) -> bool {
#[cfg(target_os = "windows")]
{
// ERROR_SHARING_VIOLATION / ERROR_LOCK_VIOLATION. ReplaceFileW returns
// 32 for the common case where AutoCAD holds the DWG open (#498).
windows_replace_error_is_file_in_use(error.raw_os_error())
}
#[cfg(not(target_os = "windows"))]
{
// Advisory locks normally do not block rename on Unix, but filesystems
// may still report EBUSY or ETXTBSY for an active destination.
matches!(error.raw_os_error(), Some(16 | 26))
}
}
#[cfg(any(target_os = "windows", test))]
fn windows_replace_error_is_file_in_use(raw_os_error: Option<i32>) -> bool {
matches!(raw_os_error, Some(32 | 33))
}
#[cfg(test)]
mod save_failure_tests {
use super::{save_as_version, windows_replace_error_is_file_in_use};
#[test]
fn issue_498_recognizes_windows_file_sharing_errors() {
assert!(windows_replace_error_is_file_in_use(Some(32)));
assert!(windows_replace_error_is_file_in_use(Some(33)));
assert!(!windows_replace_error_is_file_in_use(Some(5)));
assert!(!windows_replace_error_is_file_in_use(None));
}
#[test]
fn unsupported_output_extension_is_rejected_before_write() {
let path = std::env::temp_dir().join(format!(
"ocs_unsupported_export_{}_{}.pdf",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let error = save_as_version(
&acadrust::CadDocument::new(),
&path,
acadrust::DxfVersion::AC1032,
)
.unwrap_err();
assert_eq!(
error,
"unsupported output format .pdf; supported formats: .dwg, .dxf"
);
assert!(!path.exists(), "unsupported export created an output file");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn external_change_prevents_atomic_replace() {
let path = std::env::temp_dir().join(format!(
"ocs_external_change_{}_{}.dwg",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, b"old").unwrap();
let expected = super::edit_lock::FileFingerprint::capture(&path).unwrap();
std::fs::write(&path, b"new").unwrap();
let error = super::save_owned_as_version_atomic(
acadrust::CadDocument::new(),
&path,
acadrust::DxfVersion::AC1032,
false,
Some(expected),
None,
)
.unwrap_err();
assert!(error.externally_modified);
assert_eq!(std::fs::read(&path).unwrap(), b"new");
let _ = std::fs::remove_file(path);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn external_path_replacement_prevents_atomic_replace() {
let path = std::env::temp_dir().join(format!(
"ocs_external_replace_{}_{}.dwg",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let replacement = path.with_extension("replacement.dwg");
std::fs::write(&path, b"old").unwrap();
let expected = super::edit_lock::FileFingerprint::capture(&path).unwrap();
let reader = std::fs::File::open(&path).unwrap();
std::fs::write(&replacement, b"new").unwrap();
std::fs::remove_file(&path).unwrap();
std::fs::rename(&replacement, &path).unwrap();
let error = super::save_owned_as_version_atomic(
acadrust::CadDocument::new(),
&path,
acadrust::DxfVersion::AC1032,
false,
Some(expected),
Some(reader),
)
.unwrap_err();
assert!(error.externally_modified);
assert_eq!(std::fs::read(&path).unwrap(), b"new");
let _ = std::fs::remove_file(path);
}
}
// ── Plot Style Table ──────────────────────────────────────────────────────
/// Show a file-open dialog and load the selected CTB or STB file.
/// `Ok(None)` means the user cancelled the dialog; `Err` carries a parse
/// failure so it reaches the command line instead of dying silently (the
/// legacy-CTB complaint in #1261d was partly that a file which failed to
/// parse produced no feedback at all). The table is paired with an optional
/// non-fatal note for the command line — currently the registration
/// collision message below.
pub async fn pick_plot_style(
) -> Result<Option<(plot_style::PlotStyleTable, Option<String>)>, String> {
let dialog = crate::sys::file_dialog()
.set_title(crate::t!("Load Plot Style Table").as_ref())
.add_filter(
crate::t!("Plot Style Tables").as_ref(),
&["ctb", "stb", "CTB", "STB"],
)
.add_filter(crate::t!("All Files").as_ref(), &["*"]);
#[cfg(not(target_arch = "wasm32"))]
let dialog = match plot_style::ensure_plot_styles_dir() {
Ok(dir) => dialog.set_directory(dir),
Err(_) => dialog,
};
let Some(handle) = dialog.pick_file().await else {
return Ok(None);
};
#[cfg(not(target_arch = "wasm32"))]
{
let path = crate::sys::handle_path(&handle);
let table = plot_style::PlotStyleTable::load(&path).map_err(|error| {
crate::tf!(
"Could not load plot style table '{}': {error}",
path.display()
)
.into_owned()
})?;
// Loading a table registers it, the way AutoCAD does: copy it next to the
// built-ins so it stays in the Plot dialog's table list after restart.
// Only real AutoCAD containers are registered — the permissive heuristic
// also accepts junk (any text file, a stray deflate stream), and writing
// those into the folder would pollute it permanently — so heuristic
// parses stay loadable for this session only. Failures are non-fatal —
// the table is already applied to this plot.
let note = should_register_plot_style(&table)
.then(|| register_picked_plot_style(&path))
.flatten();
Ok(Some((table, note)))
}
// Web: read the picked file's bytes now — the name-only path has nothing
// behind it — parse them, and remember them in the session registry so
// `load_named` resolves the name afterwards (the Plot dialog's table
// list and APPLY, plot settings, display plot style). There is no
// plot-styles folder to copy the file into on the web, so registration
// lasts for the session; the picked table is already applied to this
// plot either way.
#[cfg(target_arch = "wasm32")]
{
let name = handle.file_name();
let bytes: std::sync::Arc<Vec<u8>> = std::sync::Arc::new(handle.read().await);
let table = plot_style::PlotStyleTable::from_bytes(&name, &bytes).map_err(|error| {
crate::tf!("Could not load plot style table '{}': {error}", name).into_owned()
})?;
plot_style::register_session_table(&name, bytes);
Ok(Some((table, None)))
}
}
/// Whether a loaded table should be registered permanently: only real
/// AutoCAD CTB containers. Named (STB) tables are not offered in the
/// color-dependent list, and heuristic parses (plain text, a stray deflate
/// stream) must not be written into the plot-styles folder.
#[cfg(not(target_arch = "wasm32"))]
fn should_register_plot_style(table: &plot_style::PlotStyleTable) -> bool {
!table.is_stb && table.structured
}
/// Copy the picked CTB into the per-user plot-styles folder, reporting a
/// command-line note when the already-registered table was kept instead.
#[cfg(not(target_arch = "wasm32"))]
fn register_picked_plot_style(path: &Path) -> Option<String> {
plot_style::plot_styles_dir()
.ok()
.and_then(|dir| register_plot_style_into(&dir, path))
}
/// Copy `path` into `dir` unless a table with the same file name is already
/// registered there. A collision keeps the registered file — overwriting it
/// from a stray pick would silently destroy edits made through the Plot
/// Style panel — and returns a note naming the kept table so the user hears
/// about it. Copy failures stay silent: the table is already applied to the
/// current plot.
#[cfg(not(target_arch = "wasm32"))]
fn register_plot_style_into(dir: &Path, path: &Path) -> Option<String> {
let file_name = path.file_name()?;
let registered = dir.join(file_name);
if registered.exists() {
return Some(
crate::tf!(
"'{}' is already registered; the existing table was kept.",
file_name.to_string_lossy()
)
.into_owned(),
);
}
let _ = std::fs::copy(path, &registered);
None
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod plot_style_registration_tests {
use super::{register_plot_style_into, should_register_plot_style};
use crate::io::plot_style::{self, PlotStyleTable};
fn scratch_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"ocs_ctb_register_{tag}_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A name collision keeps the registered table and surfaces a note
/// naming it instead of silently discarding the pick.
#[test]
fn a_collision_keeps_the_registered_table_and_notes_it() {
let dir = scratch_dir("collide");
let picked = dir.join("picked.ctb");
std::fs::write(&picked, b"freshly picked").unwrap();
std::fs::write(dir.join("picked.ctb"), b"already registered").unwrap();
let note = register_plot_style_into(&dir, &picked).expect("collision note");
assert!(note.contains("picked.ctb"), "{note}");
assert!(note.contains("already registered"), "{note}");
assert!(note.contains("kept"), "{note}");
assert_eq!(
std::fs::read(dir.join("picked.ctb")).unwrap(),
b"already registered"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// Without a collision the picked table is copied and no note is pushed.
#[test]
fn a_fresh_table_is_copied_without_a_note() {
let dir = scratch_dir("fresh");
let picked = dir.join("source").join("new.ctb");
std::fs::create_dir_all(picked.parent().unwrap()).unwrap();
std::fs::write(&picked, b"fresh bytes").unwrap();
assert!(register_plot_style_into(&dir, &picked).is_none());
assert_eq!(std::fs::read(dir.join("new.ctb")).unwrap(), b"fresh bytes");
let _ = std::fs::remove_dir_all(&dir);
}
/// Registration is reserved for real AutoCAD containers: the permissive
/// heuristic loads junk files for the session but they must never be
/// written into the plot-styles folder.
#[test]
fn only_structured_containers_are_registrable() {
let builtin = PlotStyleTable::builtin(plot_style::DEFAULT_PLOT_STYLE).unwrap();
assert!(builtin.structured);
assert!(should_register_plot_style(&builtin));
let mut stb = builtin.clone();
stb.is_stb = true;
assert!(!should_register_plot_style(&stb));
// A loose zlib stream without the container header still parses…
use std::io::Write;
let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(b"description=\"junk\n").unwrap();
let mut loose = b"any first line\n".to_vec();
loose.extend_from_slice(&enc.finish().unwrap());
let junk = PlotStyleTable::from_bytes("junk.ctb", &loose).unwrap();
// …but stays session-only.
assert!(!junk.structured);
assert!(!should_register_plot_style(&junk));
}
}
// ── Image file picker ─────────────────────────────────────────────────────
/// Show a file-open dialog for raster images and decode the selected file.
/// Returns `(path, pixel_width, pixel_height)` or an error string.
///
/// The bytes are read through the dialog handle and registered against the
/// returned path (name-only on the web — there is no filesystem behind it),
/// mirroring the PDF-underlay pick. Decoding then comes from those bytes, and
/// the entity the IMAGE command commits resolves through the same registry
/// instead of a `image::open` on a path that only exists natively.
pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> {
let handle = crate::sys::file_dialog()
.set_title(crate::t!("Select Image File").as_ref())
.add_filter(
crate::t!("Images").as_ref(),
&["png", "jpg", "jpeg", "bmp", "tiff", "tif"],
)
.add_filter(crate::t!("PNG").as_ref(), &["png"])
.add_filter(crate::t!("JPEG").as_ref(), &["jpg", "jpeg"])
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await
.ok_or_else(|| "Cancelled".to_string())?;
let path = crate::sys::handle_path(&handle);
let bytes = std::sync::Arc::new(handle.read().await);
let reference = path.to_string_lossy().into_owned();
let short = std::path::Path::new(&reference)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| reference.clone());
crate::scene::model::image_model::register_source(&reference, bytes);
let decoded = crate::scene::model::image_model::resolve_image(&reference)
.ok_or_else(|| format!("Unsupported or corrupt image \"{short}\""))?;
Ok((path, decoded.width, decoded.height))
}
/// Save `doc` to `path` with the given DXF version, overriding `doc.version`.
/// Format is auto-detected from the extension (dwg / dxf).
pub fn save_as_version(
doc: &CadDocument,
path: &Path,
version: acadrust::DxfVersion,
) -> Result<(), String> {
let clone_started = iced::time::Instant::now();
let snapshot = doc.clone();
let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0;
save_owned_as_version_inner(snapshot, path, version, false, clone_ms, |_| Ok(()))
.map_err(|error| error.to_string())
}
/// Save an owned document snapshot. Preparation, serialization, compression and
/// disk I/O can therefore run on a worker without borrowing live editor state.
/// Output is written beside the destination and atomically renamed only after a
/// complete file exists, so a failed save cannot truncate the previous drawing.
#[cfg(not(target_arch = "wasm32"))]
pub fn save_owned_as_version_atomic(
doc: CadDocument,
path: &Path,
version: acadrust::DxfVersion,
backup: bool,
expected_fingerprint: Option<edit_lock::FileFingerprint>,
verify_reader: Option<std::fs::File>,
) -> Result<(), SaveFailure> {
save_owned_as_version_inner(doc, path, version, backup, 0.0, move |path| {
let Some(expected) = expected_fingerprint else {
return Ok(());
};
let current = match verify_reader {
Some(mut file) => match edit_lock::path_matches_file(path, &file) {
Ok(true) => edit_lock::FileFingerprint::capture_from(&mut file),
Ok(false) | Err(_) => return Err(SaveFailure::externally_modified(path)),
},
None => edit_lock::FileFingerprint::capture(path),
};
match current {
Ok(current) if current == expected => Ok(()),
_ => Err(SaveFailure::externally_modified(path)),
}
})
}
fn save_owned_as_version_inner<F>(
mut doc: CadDocument,
path: &Path,
version: acadrust::DxfVersion,
backup: bool,
clone_ms: f64,
before_replace: F,
) -> Result<(), SaveFailure>
where
F: FnOnce(&Path) -> Result<(), SaveFailure>,
{
validate_save_extension(path)?;
let perf = crate::perf::enabled();
let total_started = iced::time::Instant::now();
doc.version = version;
let styles_started = iced::time::Instant::now();
sync_current_styles_on_save(&mut doc);
let styles_ms = styles_started.elapsed().as_secs_f64() * 1000.0;
let dimensions_started = iced::time::Instant::now();
crate::modules::draw::modify::explode::bake_dimension_blocks(&mut doc);
let dimensions_ms = dimensions_started.elapsed().as_secs_f64() * 1000.0;
let temp_path = save_temp_path(path);
let ext = temp_path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let write_started = iced::time::Instant::now();
let result = match ext.as_str() {
"dxf" => DxfWriter::new(&doc)
.write_to_file(&temp_path)
.map_err(|e| SaveFailure::other(e.to_string())),
_ => DwgWriter::write_to_file(&temp_path, &doc)
.map_err(|e| SaveFailure::other(e.to_string())),
};
if let Err(error) = result {
let _ = std::fs::remove_file(&temp_path);
return Err(error);
}
if let Err(error) = before_replace(path) {
let _ = std::fs::remove_file(&temp_path);
return Err(error);
}
if backup {
write_backup(path);
}
if let Err(error) = replace_save_file(&temp_path, path) {
let _ = std::fs::remove_file(&temp_path);
return Err(SaveFailure::replacing(path, error));
}
if perf {
crate::perf_record!(
"[perf] save total={:.1}ms clone={:.1} styles={:.1} dimensions={:.1} write={:.1} entities={} objects={} path={}",
total_started.elapsed().as_secs_f64() * 1000.0,
clone_ms,
styles_ms,
dimensions_ms,
write_started.elapsed().as_secs_f64() * 1000.0,
doc.entities().count(),
doc.objects.len(),
path.display(),
);
}
Ok(())
}
fn save_temp_path(path: &Path) -> PathBuf {
static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let stem = path
.file_stem()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".to_string());
let extension = path
.extension()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "dwg".to_string());
let name = format!(
".{stem}.ocs-save-{}-{serial}.{extension}",
std::process::id()
);
path.parent().unwrap_or_else(|| Path::new(".")).join(name)
}
#[cfg(not(target_os = "windows"))]
fn replace_save_file(temp_path: &Path, path: &Path) -> std::io::Result<()> {
std::fs::rename(temp_path, path)
}
#[cfg(target_os = "windows")]
fn replace_save_file(temp_path: &Path, path: &Path) -> std::io::Result<()> {
if !path.exists() {
return std::fs::rename(temp_path, path);
}
use std::os::windows::ffi::OsStrExt;
let replaced: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
let replacement: Vec<u16> = temp_path.as_os_str().encode_wide().chain(Some(0)).collect();
let ok = unsafe {
windows_sys::Win32::Storage::FileSystem::ReplaceFileW(
replaced.as_ptr(),
replacement.as_ptr(),
std::ptr::null(),
0,
std::ptr::null(),
std::ptr::null(),
)
};
if ok == 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
/// Save using the document's existing version.
pub fn save(doc: &CadDocument, path: &Path) -> Result<(), String> {
save_as_version(doc, path, doc.version)
}
/// Serialize a document to an in-memory byte buffer, format chosen by `ext`
/// (`dxf` → DXF, anything else → DWG) at the given DXF version (overriding
/// `doc.version`). Used by the web build, which hands the bytes to a browser
/// download instead of writing a path — the byte-buffer counterpart to
/// [`save_as_version`].
///
/// This is the *inline* path (main thread on wasm). The web SAVE/AUTOSAVE
/// flows prefer [`prepare_document_for_save`] +
/// [`serialize_prepared_document`], which runs the writer half on the browser
/// worker; both routes share the same preparation and the same writer
/// dispatch (`ocs_save::serialize_document`), so the produced bytes are
/// identical.
#[allow(dead_code)] // web/wasm build only; unused in the native target
pub fn save_to_bytes(
doc: &CadDocument,
ext: &str,
version: acadrust::DxfVersion,
) -> Result<Vec<u8>, String> {
let perf = crate::perf::enabled();
let total_started = iced::time::Instant::now();
let doc = prepare_document_for_save(doc, version);
let write_started = iced::time::Instant::now();
let result = ocs_save::serialize_document(&doc, ext);
if perf {
let bytes = result.as_ref().map_or(0, Vec::len);
crate::perf_record!(
"[perf] save-bytes total={:.1}ms write={:.1} bytes={} entities={} objects={}",
total_started.elapsed().as_secs_f64() * 1000.0,
write_started.elapsed().as_secs_f64() * 1000.0,
bytes,
doc.entities().count(),
doc.objects.len(),
);
}
result
}
/// Prepare a document clone for saving: stamp the target DXF version, sync the
/// current-style markers into their header handles / dictionary variables, and
/// bake missing dimension geometry blocks. The app-side half of the save
/// pipeline — it must run where the live document lives (the worker crate
/// cannot reach the app's style/dimension modules), and it mutates only the
/// clone, leaving the live document untouched. Both [`save_to_bytes`] and the
/// web worker offload call this exactly once per save, so there is one
/// implementation of the preparation for every target.
pub fn prepare_document_for_save(
doc: &CadDocument,
version: acadrust::DxfVersion,
) -> CadDocument {
let perf = crate::perf::enabled();
let clone_started = perf.then(iced::time::Instant::now);
let mut doc = doc.clone();
let clone_ms = crate::perf::elapsed_ms(clone_started);
doc.version = version;
let styles_started = perf.then(iced::time::Instant::now);
sync_current_styles_on_save(&mut doc);
let styles_ms = crate::perf::elapsed_ms(styles_started);
let dimensions_started = perf.then(iced::time::Instant::now);
crate::modules::draw::modify::explode::bake_dimension_blocks(&mut doc);
let dimensions_ms = crate::perf::elapsed_ms(dimensions_started);
if perf {
crate::perf_record!(
"[perf] save-prepare clone={:.1} styles={:.1} dimensions={:.1} entities={} objects={}",
clone_ms,
styles_ms,
dimensions_ms,
doc.entities().count(),
doc.objects.len(),
);
}
doc
}
/// Web SAVE/AUTOSAVE: offload the DWG/DXF writer pass to the browser worker.
/// `prepared` comes from [`prepare_document_for_save`] — the serialization
/// itself (the expensive half) runs off the UI thread and the encoded bytes
/// come back here. Falls back to inline serialization when the worker cannot
/// spawn or errors, so a save never hard-fails just because the worker is
/// unavailable.
#[cfg(target_arch = "wasm32")]
pub async fn serialize_prepared_document(
prepared: &CadDocument,
ext: &str,
) -> Result<Vec<u8>, String> {
let encoded = match ocs_save::encode_request(prepared, ext) {
Ok(encoded) => encoded,
// The worker cannot help if the document cannot even be encoded —
// serialize inline from the document we still hold.
Err(error) => {
log::warn!("{error}; serializing inline");
return ocs_save::serialize_document(prepared, ext);
}
};
web_worker::serialize_document(encoded).await
}
// ── Post-load fixups ──────────────────────────────────────────────────────
// Resolve the current text / dimension / multiline style from the handle the
// DWG header stores into the *name* the app reads. DXF stores these as names
// directly ($TEXTSTYLE / $DIMSTYLE / $CMLSTYLE), but DWG only stores handles,
// so without this the current-style markers (and any code keyed on the name)
// fall back to "Standard". Only overrides when the handle resolves, leaving the
// DXF-provided names intact.
fn fix_current_style_names(doc: &mut CadDocument) {
use acadrust::objects::ObjectType;
let h = doc.header.current_text_style_handle;
if h.is_valid() {
if let Some(name) = doc
.text_styles
.iter()
.find(|s| s.handle == h)
.map(|s| s.name.clone())
{
doc.header.current_text_style_name = name;
}
}
let h = doc.header.current_dimstyle_handle;
if h.is_valid() {
if let Some(name) = doc
.dim_styles
.iter()
.find(|s| s.handle == h)
.map(|s| s.name.clone())
{
doc.header.current_dimstyle_name = name;
}
}
let h = doc.header.current_multiline_style_handle;
if h.is_valid() {
if let Some(name) = doc.objects.values().find_map(|o| match o {
ObjectType::MLineStyle(s) if s.handle == h => Some(s.name.clone()),
_ => None,
}) {
doc.header.multiline_style = name;
}
}
// Current table / multileader style. DXF carries these as $CTABLESTYLE /
// $CMLEADERSTYLE header vars (already read). DWG has no header field for
// them — they live in the variable dictionary as DICTIONARYVAR entries
// keyed "CTABLESTYLE" / "CMLEADERSTYLE". Resolve from there when present;
// a missing entry simply leaves the existing value untouched.
if let Some(v) = vardict_value(doc, "CTABLESTYLE") {
if !v.is_empty() {
doc.header.current_table_style_name = v;
}
}
if let Some(v) = vardict_value(doc, "CMLEADERSTYLE") {
if !v.is_empty() {
doc.header.current_mleader_style_name = v;
}
}
reflect_sketch_settings(doc);
}
/// Find the handle of the `DictionaryVariable` registered under `name` in any
/// of the document's dictionaries (the variable dictionary).
fn vardict_handle(doc: &CadDocument, name: &str) -> Option<acadrust::Handle> {
use acadrust::objects::ObjectType;
doc.objects.values().find_map(|o| {
let entries = match o {
ObjectType::Dictionary(d) => &d.entries,
ObjectType::DictionaryWithDefault(d) => &d.entries,
_ => return None,
};
entries
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, h)| *h)
})
}
/// Look up a system-variable value stored in the document's variable
/// dictionary.
fn vardict_value(doc: &CadDocument, name: &str) -> Option<String> {
use acadrust::objects::ObjectType;
let handle = vardict_handle(doc, name)?;
match doc.objects.get(&handle) {
Some(ObjectType::DictionaryVariable(v)) => Some(v.value.clone()),
_ => None,
}
}
pub(crate) fn drawing_variable(doc: &CadDocument, name: &str) -> Option<String> {
vardict_value(doc, name)
}
fn reflect_sketch_settings(doc: &mut CadDocument) {
let sketch_type = vardict_value(doc, "SKPOLY")
.and_then(|value| value.parse::<i16>().ok())
.unwrap_or(doc.header.sketch_type)
.clamp(0, 2);
let increment = vardict_value(doc, "SKETCHINC")
.and_then(|value| value.parse::<f64>().ok())
.filter(|value| value.is_finite() && *value > 0.0)
.unwrap_or(doc.header.sketch_increment);
let tolerance = vardict_value(doc, "SKTOLERANCE")
.and_then(|value| value.parse::<f64>().ok())
.filter(|value| value.is_finite() && (0.0..=1.0).contains(value))
.unwrap_or(doc.header.sketch_tolerance);
doc.header.sketch_type = sketch_type;
doc.header.sketch_increment = if increment.is_finite() && increment > 0.0 {
increment
} else {
0.1
};
doc.header.sketch_tolerance = if tolerance.is_finite() {
tolerance.clamp(0.0, 1.0)
} else {
0.5
};
}
/// Write a drawing variable, creating the variable dictionary and record when
/// needed so new drawings preserve the value too.
pub(crate) fn set_drawing_variable(doc: &mut CadDocument, name: &str, value: &str) {
use acadrust::objects::{Dictionary, DictionaryVariable, ObjectType};
if let Some(h) = vardict_handle(doc, name) {
if let Some(ObjectType::DictionaryVariable(v)) = doc.objects.get_mut(&h) {
v.value = value.to_string();
}
return;
}
let root = crate::scene::annotative::root_named_dict_handle(doc);
let variable_dictionary = crate::scene::annotative::as_dict(doc, root)
.and_then(|dictionary| dictionary.get("AcDbVariableDictionary"))
.filter(|handle| matches!(doc.objects.get(handle), Some(ObjectType::Dictionary(_))))
.unwrap_or_else(|| {
let handle = doc.allocate_handle();
let mut dictionary = Dictionary::new();
dictionary.handle = handle;
dictionary.owner = root;
doc.objects
.insert(handle, ObjectType::Dictionary(dictionary));
if let Some(ObjectType::Dictionary(root_dictionary)) = doc.objects.get_mut(&root) {
root_dictionary.add_entry("AcDbVariableDictionary", handle);
}
handle
});
let handle = doc.allocate_handle();
let mut variable = DictionaryVariable::new(name, value);
variable.handle = handle;
variable.owner_handle = variable_dictionary;
doc.objects
.insert(handle, ObjectType::DictionaryVariable(variable));
if let Some(ObjectType::Dictionary(dictionary)) = doc.objects.get_mut(&variable_dictionary) {
dictionary.add_entry(name, handle);
}
}
pub(crate) fn set_sketch_settings(
doc: &mut CadDocument,
sketch_type: i16,
increment: f64,
tolerance: f64,
) {
let sketch_type = sketch_type.clamp(0, 2);
let increment = if increment.is_finite() && increment > 0.0 {
increment
} else {
0.1
};
let tolerance = if tolerance.is_finite() {
tolerance.clamp(0.0, 1.0)
} else {
0.5
};
doc.header.sketch_type = sketch_type;
doc.header.sketch_increment = increment;
doc.header.sketch_tolerance = tolerance;
set_drawing_variable(doc, "SKPOLY", &sketch_type.to_string());
set_drawing_variable(doc, "SKETCHINC", &increment.to_string());
set_drawing_variable(doc, "SKTOLERANCE", &tolerance.to_string());
}
/// The layout tab that was active when the drawing was saved — the `CTAB`
/// system variable (stored in the variable dictionary as a `DICTIONARYVAR`).
/// `None` when the file recorded no current tab.
pub fn saved_active_layout(doc: &CadDocument) -> Option<String> {
vardict_value(doc, "CTAB").filter(|s| !s.is_empty())
}
/// Record `name` as the active layout tab (`CTAB`) so the next save round-trips
/// which space was open. Updates the existing `CTAB` variable in place, or
/// creates one under the root named-object dictionary when the drawing never
/// carried it (e.g. a document authored here from scratch) — otherwise the exact
/// paper layout would be lost and reopening fell back to the first paper tab.
pub fn set_saved_active_layout(doc: &mut CadDocument, name: &str) {
set_drawing_variable(doc, "CTAB", name);
}
/// Materialise the current-style choices into their format-specific storage
/// before saving, treating the current-style *names* as the single source of
/// truth:
///
/// * text / dim / multiline live in the DWG header as **handles** — the writer
/// keeps a stored handle if it's still valid and otherwise falls back to
/// "Standard", so a Set Current change (which only updates the name) would be
/// lost. Re-resolve the handle from the name here so the change persists.
/// * table / multileader live in the **variable dictionary** (DICTIONARYVAR).
///
/// DXF additionally writes its own header vars from the names, so this keeps
/// every representation consistent.
fn sync_current_styles_on_save(doc: &mut CadDocument) {
use acadrust::objects::ObjectType;
let th = doc
.text_styles
.get(&doc.header.current_text_style_name)
.map(|s| s.handle);
if let Some(h) = th {
doc.header.current_text_style_handle = h;
}
let dh = doc
.dim_styles
.get(&doc.header.current_dimstyle_name)
.map(|s| s.handle);
if let Some(h) = dh {
doc.header.current_dimstyle_handle = h;
}
let mname = doc.header.multiline_style.clone();
let mh = doc.objects.values().find_map(|o| match o {
ObjectType::MLineStyle(s) if s.name.eq_ignore_ascii_case(&mname) => Some(s.handle),
_ => None,
});
if let Some(h) = mh {
doc.header.current_multiline_style_handle = h;
}
let table = doc.header.current_table_style_name.clone();
let mleader = doc.header.current_mleader_style_name.clone();
set_drawing_variable(doc, "CTABLESTYLE", &table);
set_drawing_variable(doc, "CMLEADERSTYLE", &mleader);
let annotation = doc.header.current_annotation_scale.clone();
set_drawing_variable(doc, "CANNOSCALE", &annotation);
let sketch_type = doc.header.sketch_type;
let increment = doc.header.sketch_increment;
let tolerance = doc.header.sketch_tolerance;
set_sketch_settings(doc, sketch_type, increment, tolerance);
}
// ── Corrupt-entity guard ──────────────────────────────────────────────────
//
// acadrust's DWG parser occasionally desynchronises on certain files and
// produces entities with garbage fields: non-unit normals (components in
// 1e200+), nonsensical vertex counts (e.g. 100000), or infinite/NaN
// coordinates. Tessellating such entities triggers huge allocations and
// numerical blow-ups in the wire pipeline.
//
// `purge_corrupt_entities` scans the document and removes any entity that
// fails a cheap sanity check, returning the number dropped so the caller can
// surface it to the UI / log.
fn finite_unit_normal(n: &acadrust::types::Vector3) -> bool {
let (x, y, z) = (n.x, n.y, n.z);
if !x.is_finite() || !y.is_finite() || !z.is_finite() {
return false;
}
let mag2 = x * x + y * y + z * z;
// Accept anything within ~10% of unit length. Real files sometimes
// store slightly denormalised normals from rounding.
(mag2 - 1.0).abs() < 0.21
}
fn finite_coord(v: f64) -> bool {
v.is_finite() && v.abs() < 1.0e12
}
fn finite_vec3(v: &acadrust::types::Vector3) -> bool {
finite_coord(v.x) && finite_coord(v.y) && finite_coord(v.z)
}
/// Returns true if the entity looks like parser garbage and should be dropped.
pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool {
use acadrust::entities::EntityType as E;
// Reject polylines at or above this vertex count. Even valid drawings
// rarely use this many — and parser desync produces exactly-100_000-vertex
// junk records.
const MAX_VERTS: usize = 100_000;
match e {
E::LwPolyline(p) => {
!finite_unit_normal(&p.normal)
|| p.vertices.len() >= MAX_VERTS
|| !finite_coord(p.elevation)
|| p.elevation.abs() > 1.0e10
|| !finite_coord(p.thickness)
|| p.thickness.abs() > 1.0e10
|| p.vertices
.iter()
.any(|v| !finite_coord(v.location.x) || !finite_coord(v.location.y))
}
E::Polyline2D(p) => {
!finite_unit_normal(&p.normal)
|| p.vertices.len() >= MAX_VERTS
|| !finite_coord(p.elevation)
|| p.elevation.abs() > 1.0e10
|| !finite_coord(p.thickness)
|| p.thickness.abs() > 1.0e10
|| p.vertices.iter().any(|v| !finite_vec3(&v.location))
}
E::Polyline3D(p) => {
p.vertices.len() >= MAX_VERTS || p.vertices.iter().any(|v| !finite_vec3(&v.position))
}
E::Polyline(p) => {
p.vertices.len() >= MAX_VERTS || p.vertices.iter().any(|v| !finite_vec3(&v.location))
}
E::Line(l) => !finite_vec3(&l.start) || !finite_vec3(&l.end),
E::Circle(c) => {
!finite_vec3(&c.center)
|| !finite_coord(c.radius)
// Reject zero- or near-zero circles: they tessellate into a
// degenerate curve the tessellator cannot sample.
|| c.radius.abs() < 1.0e-10
|| c.radius.abs() > 1.0e10
}
E::Arc(a) => {
!finite_vec3(&a.center)
|| !finite_coord(a.radius)
|| !a.start_angle.is_finite()
|| !a.end_angle.is_finite()
|| a.radius.abs() < 1.0e-10
|| a.radius.abs() > 1.0e10
|| !finite_unit_normal(&a.normal)
}
E::Ellipse(e) => {
!finite_vec3(&e.center)
|| !finite_vec3(&e.major_axis)
|| !e.start_parameter.is_finite()
|| !e.end_parameter.is_finite()
|| (e.end_parameter - e.start_parameter).abs() < 1.0e-9
|| {
let m2 = e.major_axis.x * e.major_axis.x
+ e.major_axis.y * e.major_axis.y
+ e.major_axis.z * e.major_axis.z;
!m2.is_finite() || m2 < 1.0e-20 || m2 > 1.0e20
}
|| !e.minor_axis_ratio.is_finite()
|| e.minor_axis_ratio.abs() < 1.0e-10
}
E::Spline(s) => {
// Parser desync emits exactly-100_000-control-point splines with a
// garbage knot vector. Building a NURBS from one and
// tessellating it runs `parameter_division` into an unbounded
// allocation — single-threaded, 32 GB+ — long before the drawing
// finishes loading. Reject the desync signature plus any spline
// the kernel can't build: non-finite control points, or a knot vector
// that's non-finite, non-monotonic, or the wrong length
// (the kernel requires `knots.len() == ctrl.len() + degree + 1`).
let n = s.control_points.len();
let degree_bad = s.degree < 1;
let deg = s.degree.max(0) as usize;
let compact_periodic_knots = s.flags.periodic && s.knots.len() == n.saturating_add(1);
let knots_bad = !s.knots.is_empty()
&& (s.knots.iter().any(|k| !k.is_finite())
|| s.knots.windows(2).any(|w| w[1] < w[0])
|| (!compact_periodic_knots && s.knots.len() != n + deg + 1));
n >= MAX_VERTS
|| degree_bad
|| s.control_points.iter().any(|p| !finite_vec3(p))
|| knots_bad
}
_ => false,
}
}
pub fn purge_corrupt_entities(doc: &mut CadDocument) -> usize {
use crate::par::prelude::*;
// Detection is pure and read-only; the per-vertex finite/extent checks on
// large polylines dominate, so fan the scan out across cores. Gather
// entity references in one pass, test in parallel, then remove serially
// (`remove_entity` needs `&mut doc`).
let entities: Vec<&EntityType> = doc.entities().collect();
let bad: Vec<acadrust::Handle> = entities
.par_iter()
.filter(|e| is_entity_corrupt(e))
.map(|e| e.common().handle)
.collect();
let n = bad.len();
for h in bad {
doc.remove_entity(h);
}
n
}
/// acadrust's ViewportStatusFlags::from_bits() maps bit 0 → is_on and bit 15 → locked,
/// but the real DXF/DWG spec uses bit 15 (0x8000) → viewport on and bit 14 (0x4000) → locked.
/// Files from AutoCAD and other tools always set bit 15 for active viewports, leaving bit 0
/// clear, so acadrust reads every such viewport as off. Correct that here after loading.
fn fix_viewport_status_flags(doc: &mut CadDocument) {
for entity in doc.entities_mut() {
if let EntityType::Viewport(vp) = entity {
let bits = vp.status.to_bits();
// If bit 0 is not set but bit 15 is, this is an external-format viewport:
// treat bit 15 as "on" and bit 14 as "locked".
if (bits & 0x0001) == 0 && (bits & 0x8000) != 0 {
vp.status.is_on = true;
vp.status.locked = (bits & 0x4000) != 0;
}
}
}
}
/// The acadrust DXF reader stores several rotation fields directly from DXF
/// group code 50 in degrees, while DWG and our own creation code store radians.
/// Apply to_radians() on load so tessellation can call cos/sin uniformly.
fn fix_dxf_dimension_rotations(doc: &mut CadDocument) {
for entity in doc.entities_mut() {
match entity {
// Dimension angles (rotation / text / oblique) are converted
// degrees->radians inside the acadrust DXF reader now, so a
// dimension arm here would double-convert.
EntityType::AttributeDefinition(a) => {
a.rotation = a.rotation.to_radians();
}
EntityType::AttributeEntity(a) => {
a.rotation = a.rotation.to_radians();
}
EntityType::Shape(s) => {
s.rotation = s.rotation.to_radians();
}
_ => {}
}
}
}
/// Recover integer-valued AcDbPlotSettings fields that acadrust can leave at
/// their defaults when a DXF writer right-aligns the value with leading spaces.
/// The raw pairs are preserved on Layout, so trim and parse those authoritative
/// values after loading. In particular, losing code 73 turns a 90°/270° sheet
/// back to 0° and makes a landscape layout render as portrait (#505).
fn fix_dxf_layout_plot_settings(doc: &mut CadDocument) {
use acadrust::objects::{ObjectType, PlotFlags};
for object in doc.objects.values_mut() {
let ObjectType::Layout(layout) = object else {
continue;
};
let Some(codes) = layout.raw_plot_settings_codes.as_ref() else {
continue;
};
for (code, value) in codes {
match *code {
70 => {
if let Ok(value) = value.trim().parse::<i32>() {
layout.plot_flags = PlotFlags::from_bits(value);
}
}
72 => {
if let Ok(value) = value.trim().parse::<i16>() {
layout.plot_paper_units = value;
}
}
73 => {
if let Ok(value) = value.trim().parse::<i16>() {
layout.plot_rotation = value;
}
}
74 => {
if let Ok(value) = value.trim().parse::<i16>() {
layout.plot_type = value;
}
}
75 => {
if let Ok(value) = value.trim().parse::<i16>() {
layout.plot_scale_type = value;
}
}
76 => {
if let Ok(value) = value.trim().parse::<i16>() {
layout.shade_plot_mode = value;
}
}
77 => {
if let Ok(value) = value.trim().parse::<i16>() {
layout.shade_plot_resolution = value;
}
}
78 => {
if let Ok(value) = value.trim().parse::<i16>() {
layout.shade_plot_dpi = value;
}
}
_ => {}
}
}
}
}
#[cfg(test)]
mod layer_roundtrip_tests {
use super::*;
use acadrust::tables::layer::Layer as DocLayer;
// Add `count` new layers the way the UI does (allocate_handle, then add),
// round-trip through `ext`, and return whether every one survived.
fn roundtrip_layers(ext: &str, count: usize) -> bool {
let mut doc = CadDocument::new();
crate::io::linetypes::populate_document(&mut doc);
let names: Vec<String> = (0..count).map(|n| format!("Layer{}", n + 1)).collect();
for name in &names {
let mut dl = DocLayer::new(name);
dl.handle = doc.allocate_handle();
doc.layers.add(dl).unwrap();
}
let path = std::env::temp_dir().join(format!("ocs_layer_rt_{count}.{ext}"));
save_as_version(&doc, &path, acadrust::DxfVersion::AC1032).expect("save");
let loaded = load_file(&path).expect("load");
let _ = std::fs::remove_file(&path);
names.iter().all(|n| loaded.layers.contains(n))
}
#[test]
fn dwg_preserves_new_layer() {
assert!(
roundtrip_layers("dwg", 1),
"DWG dropped the new layer (issue #67)"
);
}
#[test]
fn dxf_preserves_new_layer() {
assert!(roundtrip_layers("dxf", 1), "DXF dropped the new layer");
}
// Each new layer must get a distinct handle, or they collide and all but
// the last are dropped on a handle-based DWG save (issue #67).
#[test]
fn dwg_preserves_multiple_new_layers() {
assert!(
roundtrip_layers("dwg", 3),
"DWG dropped colliding new layers (issue #67)"
);
}
// #252: an entity added (as a plugin does) on a layer that no LAYER command
// ever created must keep that layer across a DWG save — `Scene::add_entity`
// auto-registers it so the writer resolves a real handle instead of NULL
// (which reopens as layer "0").
#[test]
fn dwg_preserves_entity_layer_auto_registered_on_add() {
use acadrust::entities::Point;
use acadrust::EntityType;
let mut scene = crate::scene::Scene::new();
crate::io::linetypes::populate_document(&mut scene.document);
let mut pt = Point::new();
pt.common.layer = "PLUGIN-LAYER".to_string();
let h = scene.add_entity(EntityType::Point(pt));
assert!(!h.is_null(), "entity was not added");
let path = std::env::temp_dir().join("ocs_entity_layer_rt.dwg");
save_as_version(&scene.document, &path, acadrust::DxfVersion::AC1032).expect("save");
let loaded = load_file(&path).expect("load");
let _ = std::fs::remove_file(&path);
assert!(
loaded.layers.contains("PLUGIN-LAYER"),
"layer table dropped the auto-registered layer (#252)"
);
let ent = loaded
.get_entity(h)
.or_else(|| {
loaded
.entities()
.find(|e| matches!(e, EntityType::Point(_)))
})
.expect("point entity missing after round-trip");
assert_eq!(
ent.common().layer,
"PLUGIN-LAYER",
"entity collapsed to layer 0 on DWG save (#252)"
);
}
}
#[cfg(test)]
mod corrupt_guard_tests {
use super::*;
use acadrust::entities::{Arc, EntityType, Spline};
use acadrust::types::Vector3;
// Small but finite arcs are valid records. Kernel tessellation is bounded,
// so opening must retain them instead of treating their size as corruption.
#[test]
fn keeps_small_finite_arc() {
let mut a = Arc::new();
a.center = Vector3::new(2880.84, 891.83, 0.0);
a.radius = 0.0038974142851181423;
a.start_angle = 1.0401656235942365;
a.end_angle = 1.0401671831670538;
a.normal = Vector3::new(0.0, 0.0, 1.0);
assert!(!is_entity_corrupt(&EntityType::Arc(a)));
}
// A nearly straight arc still carries valid source geometry.
#[test]
fn keeps_nearly_straight_arc() {
let mut a = Arc::new();
a.center = Vector3::new(551435.3071786845, 4051623.7156955916, 0.0);
a.radius = 35.0;
a.start_angle = 5.823361856481176;
a.end_angle = 5.823362506017916;
a.normal = Vector3::new(0.0, 0.0, 1.0);
assert!(!is_entity_corrupt(&EntityType::Arc(a)));
}
// A large-radius small-sweep arc is still a visible curve and must survive:
// radius 1e6 × sweep 1e-4 ≈ 100 units of arc.
#[test]
fn keeps_large_radius_small_sweep_arc() {
let mut a = Arc::new();
a.radius = 1.0e6;
a.start_angle = 0.0;
a.end_angle = 1.0e-4;
a.normal = Vector3::new(0.0, 0.0, 1.0);
assert!(!is_entity_corrupt(&EntityType::Arc(a)));
}
// Parser desync emits 100_000-control-point splines; building a kernel
// NURBS from one and tessellating it OOMs. The control-point cap rejects it.
#[test]
fn rejects_desync_spline() {
let pts = vec![Vector3::new(0.0, 0.0, 0.0); 100_000];
let s = Spline::from_control_points(3, pts);
assert!(is_entity_corrupt(&EntityType::Spline(s)));
}
// A collapsed spline is still a valid source record. Kernel subdivision
// has a fixed depth bound and can process it safely.
#[test]
fn keeps_degenerate_point_spline() {
let pts = vec![Vector3::new(1e-12, -1e-12, 0.0); 9];
let s = Spline::from_control_points(3, pts);
assert!(!is_entity_corrupt(&EntityType::Spline(s)));
}
#[test]
fn keeps_compact_periodic_spline_knots() {
let pts = (0..12)
.map(|index| Vector3::new(index as f64, (index % 3) as f64, 0.0))
.collect();
let mut s = Spline::from_control_points(2, pts);
s.flags.closed = true;
s.flags.periodic = true;
s.knots = (0..13).map(|value| value as f64).collect();
assert!(!is_entity_corrupt(&EntityType::Spline(s)));
}
// A normal cubic spline (4 control points, valid clamped knots) survives.
#[test]
fn keeps_valid_spline() {
let pts = vec![
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 1.0, 0.0),
Vector3::new(2.0, -1.0, 0.0),
Vector3::new(3.0, 0.0, 0.0),
];
let s = Spline::from_control_points(3, pts);
assert!(!is_entity_corrupt(&EntityType::Spline(s)));
}
// A corrupt or adversarial MINSERT row/column pair (u16, so its unchecked
// product can reach into the billions) must be rejected before it can
// drive the render graph's per-instance allocation and expansion.
#[test]
fn preserves_large_minsert_data_while_rendering_is_bounded() {
let mut i = acadrust::entities::Insert::new("BLOCK", Vector3::ZERO);
i.row_count = u16::MAX;
i.column_count = u16::MAX;
assert!(!is_entity_corrupt(&EntityType::Insert(i)));
}
// An ordinary array insert, well under the budget, is valid source data.
#[test]
fn keeps_a_reasonable_minsert() {
let mut i = acadrust::entities::Insert::new("BLOCK", Vector3::ZERO);
i.row_count = 10;
i.column_count = 10;
i.row_spacing = 5.0;
i.column_spacing = 5.0;
assert!(!is_entity_corrupt(&EntityType::Insert(i)));
}
// A plain (non-array) INSERT is never treated as a MINSERT-count problem.
#[test]
fn keeps_a_plain_insert() {
let i = acadrust::entities::Insert::new("BLOCK", Vector3::ZERO);
assert!(!is_entity_corrupt(&EntityType::Insert(i)));
}
}
// ── Save-pipeline prepare tests ────────────────────────────────────────────
//
// `prepare_document_for_save` is the extracted app-side half of the save
// pipeline, shared by the inline `save_to_bytes` and the web worker offload —
// these pin the behaviours both routes depend on.
#[cfg(test)]
mod prepare_save_tests {
use super::*;
use acadrust::entities::{Dimension, DimensionLinear};
use acadrust::tables::TextStyle;
use acadrust::types::Vector3;
// The live document must never observe the save preparation: version
// stamp, style sync and dimension baking all happen on the clone only.
#[test]
fn prepare_stamps_version_and_leaves_live_document_untouched() {
let mut doc = CadDocument::new();
doc.version = acadrust::DxfVersion::AC1027;
let prepared = prepare_document_for_save(&doc, acadrust::DxfVersion::AC1032);
assert_eq!(prepared.version, acadrust::DxfVersion::AC1032);
assert_eq!(doc.version, acadrust::DxfVersion::AC1027);
}
// The current text style marker must resolve from its name to the table
// entry's handle on the prepared clone (the DWG header stores a handle),
// while the live document keeps its previous marker.
#[test]
fn prepare_syncs_current_style_markers() {
let mut doc = CadDocument::new();
crate::io::linetypes::populate_document(&mut doc);
let mut style = TextStyle::new("ANNOT");
style.handle = doc.allocate_handle();
let style_handle = style.handle;
doc.text_styles.add(style).unwrap();
doc.header.current_text_style_name = "ANNOT".to_string();
let live_handle = doc.header.current_text_style_handle;
let prepared = prepare_document_for_save(&doc, doc.version);
assert_eq!(prepared.header.current_text_style_handle, style_handle);
assert_eq!(doc.header.current_text_style_handle, live_handle);
}
// A dimension without its anonymous geometry block must get one baked on
// the clone (the writer emits the *D block), with the group-10 definition
// point mirrored into `base` — while the live document keeps neither.
#[test]
fn prepare_bakes_missing_dimension_blocks() {
let mut doc = CadDocument::new();
crate::io::linetypes::populate_document(&mut doc);
let mut dim = DimensionLinear::new(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
);
dim.definition_point = Vector3::new(5.0, 7.0, 0.0);
dim.base.definition_point = Vector3::ZERO;
dim.base.common.handle = doc.allocate_handle();
let handle = dim.base.common.handle;
doc.add_entity(EntityType::Dimension(Dimension::Linear(dim)))
.unwrap();
let prepared = prepare_document_for_save(&doc, doc.version);
let EntityType::Dimension(Dimension::Linear(linear)) =
prepared.get_entity(handle).unwrap()
else {
panic!("dimension missing from prepared clone");
};
assert!(
linear.base.block_name.starts_with("*D"),
"anonymous dimension block not baked: {}",
linear.base.block_name
);
assert!(prepared.block_records.get(&linear.base.block_name).is_some());
assert_eq!(linear.base.definition_point, linear.definition_point);
// The live document is untouched: no block name, stale group-10.
let EntityType::Dimension(Dimension::Linear(live)) =
doc.get_entity(handle).unwrap()
else {
panic!("dimension missing from live document");
};
assert!(live.base.block_name.is_empty());
assert_eq!(live.base.definition_point, Vector3::ZERO);
}
// The whole point of the split: the inline fallback (`save_to_bytes`) and
// the worker route (`prepare_document_for_save` → `ocs_save` on the
// worker) must produce identical bytes for the same input.
#[test]
fn save_to_bytes_equals_prepare_then_shared_serialize() {
let mut doc = CadDocument::new();
crate::io::linetypes::populate_document(&mut doc);
let mut dim = DimensionLinear::new(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
);
dim.base.common.handle = doc.allocate_handle();
doc.add_entity(EntityType::Dimension(Dimension::Linear(dim)))
.unwrap();
for ext in ["dwg", "dxf"] {
let inline = save_to_bytes(&doc, ext, acadrust::DxfVersion::AC1032).expect("inline");
let prepared = prepare_document_for_save(&doc, acadrust::DxfVersion::AC1032);
let shared = ocs_save::serialize_document(&prepared, ext).expect("shared");
assert_eq!(inline, shared, "inline and shared serialize diverge for {ext}");
}
}
}