feat(io): add user-approved recovery

This commit is contained in:
Hakan Seven committed 2026-08-03 12:47:48 +03:00
1 parent 25515fa33c
commit 9e90e50662
25 files changed
+2723 -198

No files matched your search

+647 -68
View File
@@ -12,6 +12,7 @@ pub mod single_instance;
pub mod pdf_export;
pub mod plot_style;
pub mod print_to_printer;
pub mod recovery;
pub mod step;
pub mod stl;
pub mod xref;
@@ -28,7 +29,9 @@ pub(crate) mod web_recent;
use crate::scene::DerivedCaches;
use acadrust::entities::EntityType;
use acadrust::io::dwg::DwgReader;
use acadrust::{CadDocument, DwgWriter, DxfReader, DxfWriter};
use acadrust::{
CadDocument, DwgReadOptions, DwgWriter, DxfReader, DxfReaderConfiguration, DxfWriter,
};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU16, AtomicU32, AtomicU8, Ordering};
use std::sync::Arc;
@@ -75,6 +78,82 @@ impl OpenProgressState {
}
}
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.
@@ -101,22 +180,89 @@ pub async fn pick_open_path() -> Option<(PathBuf, u64)> {
/// 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), String> {
) -> 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 result = (|| -> Result<_, String> {
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();
@@ -133,12 +279,28 @@ pub async fn open_path_with_phase(
});
callback
};
let mut doc = load_file_with_progress(&path2, Some(parser_progress))?;
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() {
@@ -188,51 +350,143 @@ pub async fn open_path_with_phase(
xref_ms,
};
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 (prepared_doc, prepared_geometry) =
crate::scene::prepare_open_geometry(doc, &caches, model_bg);
doc = prepared_doc;
caches.prepared_geometry = Some(prepared_geometry);
progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9950, 1, 1);
Ok((doc, caches))
})();
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| format!("failed to start parser thread: {error}"))?;
.map_err(|error| OpenLoadError::from(format!("failed to start parser thread: {error}")))?;
let (doc, caches) = receiver
.await
.map_err(|_| "parser thread stopped without a result".to_string())??;
.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>,
) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> {
let handle = crate::sys::file_dialog()
) -> WebOpenOutcome {
let Some(handle) = crate::sys::file_dialog()
.set_title("Open CAD file")
.add_filter("CAD Files", &["dwg", "dxf", "DWG", "DXF"])
.add_filter("All Files", &["*"])
.pick_file()
.await
.ok_or_else(|| "Cancelled".to_string())?;
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 = handle.read().await;
let parsed = load_web_bytes(&name, &bytes, progress.clone());
let cached = web_recent::store(&name, &bytes);
let (result, cache_result) = iced::futures::future::join(parsed, cached).await;
if result.is_err() && cache_result.is_ok() {
let _ = web_recent::remove(&name).await;
let bytes: Arc<[u8]> = Arc::from(handle.read().await);
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,
}
result
}
/// Reopen a browser-private recent copy without showing the file picker.
@@ -240,21 +494,92 @@ pub async fn pick_and_load_web(
pub async fn open_recent_web(
path: PathBuf,
progress: Arc<OpenProgressState>,
) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> {
) -> 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())
.ok_or_else(|| "Recent drawing has no file name".to_string())?;
let bytes = web_recent::read(&name)
.await
.map_err(|error| format!("Recent copy unavailable for \"{name}\": {error}"))?;
.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(),
);
load_web_bytes(&name, &bytes, progress).await
let result = load_web_bytes(
&name,
&bytes,
progress,
recovery_mode,
&initial_error,
None,
)
.await;
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")]
@@ -262,12 +587,48 @@ async fn load_web_bytes(
name: &str,
bytes: &[u8],
progress: Arc<OpenProgressState>,
) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> {
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 mut doc = match web_worker::parse_document(name, bytes).await {
Ok(document) => document,
Err(error) => return Err(format!("Web parser worker: {error}")),
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;
if name.to_ascii_lowercase().ends_with(".dxf") {
fix_dxf_dimension_rotations(&mut doc);
fix_dxf_layout_plot_settings(&mut doc);
@@ -276,8 +637,23 @@ async fn load_web_bytes(
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,
});
}
let mut caches = crate::scene::build_derived_caches(&doc);
caches.corrupt_dropped = dropped;
caches.read_stats = Some(outcome.stats);
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))
@@ -334,13 +710,181 @@ fn sniff_dwg_or_dxf(path: &Path) -> String {
#[cfg(not(target_arch = "wasm32"))]
pub fn load_file(path: &Path) -> Result<CadDocument, String> {
load_file_with_progress(path, None)
load_file_with_progress(path, None).map(|outcome| outcome.document)
}
pub(crate) fn load_file_with_progress(
path: &Path,
_progress: Option<Arc<dyn Fn(u16) + Send + Sync>>,
) -> Result<CadDocument, String> {
) -> 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)
}
struct ReaderFailure {
message: String,
#[cfg(not(target_arch = "wasm32"))]
recoverable: bool,
}
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,
}
}
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())
@@ -355,47 +899,69 @@ pub(crate) fn load_file_with_progress(
};
match effective.as_str() {
"dwg" => {
#[cfg(not(target_arch = "wasm32"))]
let mut doc = {
let mut reader = DwgReader::from_mmap(path)
.map_err(|e| e.to_string())?;
if let Some(progress) = _progress {
reader.set_progress_callback(progress);
}
reader.read()
.map_err(|e| e.to_string())?
};
#[cfg(target_arch = "wasm32")]
let mut doc = DwgReader::from_file(path)
.map_err(|e| e.to_string())?
.read()
.map_err(|e| e.to_string())?;
normalize_block_origins(&mut doc);
fix_viewport_status_flags(&mut doc);
fix_current_style_names(&mut doc);
resolve_raster_image_paths(&mut doc, path.parent());
doc.source_path = Some(path.to_string_lossy().into_owned());
Ok(doc)
}
"dxf" => {
let mut doc = DxfReader::from_file(path)
.map_err(|e| e.to_string())?
.read()
.map_err(|e| e.to_string())?;
normalize_block_origins(&mut doc);
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);
resolve_raster_image_paths(&mut doc, path.parent());
doc.source_path = Some(path.to_string_lossy().into_owned());
Ok(doc)
}
_ => Err(format!("Unsupported file format: .{ext}")),
"dwg" => read_dwg_path(path, progress, failsafe),
"dxf" => read_dxf_path(path, failsafe),
_ => Err(ReaderFailure::terminal(format!(
"Unsupported file format: .{ext}"
))),
}
}
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)
}
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()
};
#[cfg(not(target_arch = "wasm32"))]
let mut reader = {
let mut reader = DwgReader::from_mmap(path).map_err(ReaderFailure::from_reader)?;
reader.options = options;
reader
};
#[cfg(target_arch = "wasm32")]
let mut reader = DwgReader::from_file_with_options(path, options)
.map_err(ReaderFailure::from_reader)?;
if let Some(progress) = progress {
reader.set_progress_callback(progress);
}
reader
.read_with_stats()
.map_err(ReaderFailure::from_reader)
}
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.
///
@@ -544,6 +1110,19 @@ pub fn canonical_save_format(format: &str) -> &'static str {
.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) {
+674
View File
@@ -0,0 +1,674 @@
use std::path::{Path, PathBuf};
use crate::io::xref::{XrefInfo, XrefStatus};
use crate::scene::OpenTimings;
#[cfg(not(target_arch = "wasm32"))]
use sha2::{Digest, Sha256};
const REPORT_SCHEMA_VERSION: u16 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecoveryStatus {
Recovered,
Failed,
}
#[derive(Debug, Clone)]
pub struct RecoveryReport {
pub report_id: String,
pub status: RecoveryStatus,
pub tab_id: Option<u64>,
pub file_name: String,
pub source_path: Option<PathBuf>,
pub size_bytes: u64,
pub source_sha256: Option<String>,
pub read_stats: Option<acadrust::ReadStats>,
pub entities_scanned: usize,
pub entities_removed: usize,
pub referenced_entities_removed: usize,
pub parser_errors_recovered: usize,
pub diagnostics: Vec<(String, String)>,
pub references_checked: usize,
pub references_loaded: usize,
pub references_recovered: usize,
pub references_missing: usize,
pub references_failed: usize,
pub references_skipped: usize,
pub reference_details: Vec<(String, String, String, Option<String>)>,
pub reference_stats: Vec<(String, acadrust::ReadStats)>,
pub timings: OpenTimings,
pub total_ms: u32,
pub failure_phase: Option<String>,
pub error: Option<String>,
pub save_as_required: bool,
pub log_path: Option<PathBuf>,
pub log_error: Option<String>,
pub created_unix_seconds: u64,
}
impl RecoveryReport {
pub fn recovered(
tab_id: u64,
path: &Path,
size_bytes: u64,
source_sha256: Option<String>,
read_stats: Option<acadrust::ReadStats>,
entities_scanned: usize,
entities_removed: usize,
referenced_entities_removed: usize,
references: &[XrefInfo],
notifications: &acadrust::notification::NotificationCollection,
save_as_required: bool,
timings: OpenTimings,
total_ms: u32,
) -> Self {
let references_loaded = references
.iter()
.filter(|item| matches!(item.status, XrefStatus::Loaded | XrefStatus::Recovered))
.count();
let references_recovered = references
.iter()
.filter(|item| item.status == XrefStatus::Recovered)
.count();
let references_missing = references
.iter()
.filter(|item| item.status == XrefStatus::NotFound)
.count();
let references_failed = references
.iter()
.filter(|item| item.status == XrefStatus::Failed)
.count();
let references_skipped = references
.iter()
.filter(|item| item.status == XrefStatus::Unloaded)
.count();
let reference_details = references
.iter()
.enumerate()
.map(|(index, item)| {
let status = match item.status {
XrefStatus::Loaded => "loaded",
XrefStatus::Recovered => "recovered",
XrefStatus::Failed => "failed",
XrefStatus::NotFound => "not found",
XrefStatus::Unloaded => "skipped",
};
(
format!("reference-{:02}", index + 1),
private_path_label(&item.path),
status.to_string(),
item.source_sha256.clone(),
)
})
.collect();
let reference_stats = references
.iter()
.enumerate()
.filter_map(|(index, item)| {
let mut stats = item.read_stats.clone()?;
for diagnostic in &mut stats.diagnostics {
diagnostic.message = redact_known_paths(
&diagnostic.message,
path,
references,
);
}
Some((format!("reference-{:02}", index + 1), stats))
})
.collect();
let parser_errors_recovered = read_stats
.as_ref()
.map(|stats| stats.recovered_errors)
.unwrap_or_else(|| {
notifications
.iter()
.filter(|item| {
item.notification_type
== acadrust::notification::NotificationType::Error
})
.count()
});
let mut diagnostics: Vec<(String, String)> = notifications
.iter()
.map(|item| {
(
item.notification_type.to_string(),
redact_known_paths(&item.message, path, references),
)
})
.collect();
if notifications.omitted_count() > 0 {
diagnostics.insert(0, (
"truncated".to_string(),
format!(
"{} additional parser notifications were omitted after the safety limit",
notifications.omitted_count()
),
));
}
for (index, reference) in references.iter().enumerate() {
diagnostics.extend(reference.diagnostics.iter().map(|message| {
(
format!("Reference {:02}", index + 1),
redact_known_paths(message, path, references),
)
}));
}
let created_unix_seconds = unix_seconds();
Self {
report_id: report_id(source_sha256.as_deref(), created_unix_seconds),
status: RecoveryStatus::Recovered,
tab_id: Some(tab_id),
file_name: display_name(path),
source_path: Some(path.to_path_buf()),
size_bytes,
source_sha256,
read_stats,
entities_scanned,
entities_removed,
referenced_entities_removed,
parser_errors_recovered,
diagnostics,
references_checked: references.len(),
references_loaded,
references_recovered,
references_missing,
references_failed,
references_skipped,
reference_details,
reference_stats,
timings,
total_ms,
failure_phase: None,
error: None,
save_as_required,
log_path: None,
log_error: None,
created_unix_seconds,
}
}
pub fn failed(
path: Option<PathBuf>,
file_name: String,
size_bytes: u64,
source_sha256: Option<String>,
read_stats: Option<acadrust::ReadStats>,
failure_phase: String,
error: String,
total_ms: u32,
) -> Self {
let created_unix_seconds = unix_seconds();
Self {
report_id: report_id(source_sha256.as_deref(), created_unix_seconds),
status: RecoveryStatus::Failed,
tab_id: None,
file_name,
source_path: path,
size_bytes,
source_sha256,
parser_errors_recovered: read_stats
.as_ref()
.map(|stats| stats.recovered_errors)
.unwrap_or(0),
read_stats,
entities_scanned: 0,
entities_removed: 0,
referenced_entities_removed: 0,
diagnostics: Vec::new(),
references_checked: 0,
references_loaded: 0,
references_recovered: 0,
references_missing: 0,
references_failed: 0,
references_skipped: 0,
reference_details: Vec::new(),
reference_stats: Vec::new(),
timings: OpenTimings::default(),
total_ms,
failure_phase: Some(failure_phase),
error: Some(error),
save_as_required: false,
log_path: None,
log_error: None,
created_unix_seconds,
}
}
pub fn removed_total(&self) -> usize {
self.entities_removed
.saturating_add(self.referenced_entities_removed)
}
pub fn issues_found(&self) -> usize {
self.parser_errors_recovered
.saturating_add(self.references_recovered)
.saturating_add(self.references_failed)
.saturating_add(self.references_missing)
.saturating_add(self.removed_total())
.saturating_add(usize::from(self.error.is_some()))
}
pub fn log_text(&self) -> String {
let mut lines = Vec::with_capacity(24);
lines.push("Open CAD Studio drawing recovery report".to_string());
lines.push(format!("Report schema: {}", REPORT_SCHEMA_VERSION));
lines.push(format!("Report ID: {}", self.report_id));
lines.push(format!("Application version: {}", env!("CARGO_PKG_VERSION")));
lines.push(format!("Application revision: {}", env!("OCS_GIT_REV")));
lines.push(format!("Build profile: {}", env!("OCS_BUILD_PROFILE")));
lines.push(format!("Build features: {}", env!("OCS_BUILD_FEATURES")));
lines.push(format!("Reader version: {}", acadrust::VERSION));
lines.push(format!("Reader revision: {}", reader_revision()));
lines.push(format!("Platform: {}", diagnostic_platform()));
lines.push(format!("Created (Unix seconds): {}", self.created_unix_seconds));
lines.push(format!(
"File: {}",
private_file_label(&self.file_name, self.source_sha256.as_deref())
));
lines.push(format!("Size: {} bytes", self.size_bytes));
lines.push(format!(
"Source SHA-256: {}",
self.source_sha256.as_deref().unwrap_or("unavailable")
));
lines.push(format!(
"Result: {}",
match self.status {
RecoveryStatus::Recovered => "opened with a recovery report",
RecoveryStatus::Failed => "could not be opened",
}
));
lines.push(String::new());
lines.push("Summary".to_string());
if let Some(stats) = &self.read_stats {
lines.push(format!(
"Format: {}",
stats
.source_format
.map(|format| format.to_string())
.unwrap_or_else(|| "unknown".to_string())
));
lines.push(format!("Format version: {}", stats.source_version));
lines.push(format!("Maintenance version: {}", stats.maintenance_version));
lines.push(format!("Recovery mode: {}", stats.recovery_mode));
lines.push(format!("Stream completed: {}", stats.stream_completed));
lines.push(format!("Source sections: {}", stats.source_sections));
lines.push(format!("Observed source records: {}", stats.source_records));
lines.push(format!(
"Successfully decoded source records: {}",
stats.decoded_source_records
));
lines.push(format!(
"Known skipped source records: {}",
stats.skipped_source_records
));
lines.push(format!("Output entities: {}", stats.output_entities));
lines.push(format!("Output objects: {}", stats.output_objects));
lines.push(format!(
"Output table records: {}",
stats.output_table_records
));
}
lines.push(format!("Entities scanned: {}", self.entities_scanned));
lines.push(format!("Entities removed: {}", self.entities_removed));
lines.push(format!(
"Referenced entities removed: {}",
self.referenced_entities_removed
));
lines.push(format!(
"Parser errors recovered: {}",
self.parser_errors_recovered
));
lines.push(format!("References checked: {}", self.references_checked));
lines.push(format!("References loaded: {}", self.references_loaded));
lines.push(format!(
"References recovered: {}",
self.references_recovered
));
lines.push(format!("References unavailable: {}", self.references_missing));
lines.push(format!("References failed: {}", self.references_failed));
lines.push(format!("References skipped: {}", self.references_skipped));
lines.push(format!(
"Save as new file required: {}",
if self.save_as_required { "yes" } else { "no" }
));
if !self.reference_details.is_empty() {
lines.push(String::new());
lines.push("References".to_string());
for (name, path, status, source_sha256) in &self.reference_details {
lines.push(format!(
"[{status}] {name}: {path}; SHA-256={}",
source_sha256.as_deref().unwrap_or("unavailable")
));
}
}
if !self.reference_stats.is_empty() {
lines.push(String::new());
lines.push("Referenced source statistics".to_string());
for (name, stats) in &self.reference_stats {
lines.push(format!(
"{name}: format={} version={} recovery={} completed={} observed-records={} decoded={} known-skipped={}",
stats
.source_format
.map(|format| format.to_string())
.unwrap_or_else(|| "unknown".to_string()),
stats.source_version,
stats.recovery_mode,
stats.stream_completed,
stats.source_records,
stats.decoded_source_records,
stats.skipped_source_records,
));
for diagnostic in stats.diagnostics.iter().take(50) {
lines.push(format!(
"{name} {}",
structured_diagnostic_line(diagnostic)
));
}
}
}
lines.push(String::new());
lines.push("Timing".to_string());
lines.push(format!("Parse: {} ms", self.timings.parse_ms));
lines.push(format!("Validation: {} ms", self.timings.purge_ms));
lines.push(format!("References: {} ms", self.timings.xref_ms));
lines.push(format!("Scene caches: {} ms", self.timings.caches_ms));
lines.push(format!("Total: {} ms", self.total_ms));
if !self.diagnostics.is_empty() {
lines.push(String::new());
lines.push("Diagnostics".to_string());
for (kind, message) in self.diagnostics.iter().take(200) {
lines.push(format!("[{kind}] {message}"));
}
if self.diagnostics.len() > 200 {
lines.push(format!(
"[truncated] {} additional diagnostics omitted",
self.diagnostics.len() - 200
));
}
}
if let Some(stats) = &self.read_stats {
if !stats.diagnostics.is_empty() {
lines.push(String::new());
lines.push("Structured reader diagnostics".to_string());
for diagnostic in stats.diagnostics.iter().take(100) {
lines.push(redact_source_path(
&structured_diagnostic_line(diagnostic),
self.source_path.as_deref(),
));
}
if stats.diagnostics.len() > 100 {
lines.push(format!(
"[truncated] {} additional diagnostics omitted",
stats.diagnostics.len() - 100
));
}
}
}
if let Some(error) = &self.error {
lines.push(String::new());
lines.push("Failure".to_string());
if let Some(phase) = &self.failure_phase {
lines.push(format!("Phase: {phase}"));
}
lines.push(redact_source_path(error, self.source_path.as_deref()));
}
lines.push(String::new());
lines.push("The source file was not modified by this operation.".to_string());
if self.save_as_required {
lines.push(
"Save the repaired drawing as a new file before continuing work.".to_string(),
);
}
lines.push(String::new());
lines.join("\n")
}
pub fn suggested_download_name(&self) -> String {
format!("drawing_recovery_{}.log", safe_stem(&self.report_id))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn persist(&mut self) {
match write_report(self) {
Ok(path) => self.log_path = Some(path),
Err(error) => self.log_error = Some(error),
}
}
#[cfg(target_arch = "wasm32")]
pub fn persist(&mut self) {}
}
fn display_name(path: &Path) -> String {
path.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn sha256_file(path: &Path) -> Result<String, String> {
use std::io::Read;
let mut file = std::fs::File::open(path).map_err(|error| error.to_string())?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 128 * 1024];
loop {
let count = file.read(&mut buffer).map_err(|error| error.to_string())?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
Ok(hex_digest(hasher.finalize()))
}
#[cfg(target_arch = "wasm32")]
pub fn sha256_file(_path: &Path) -> Result<String, String> {
Err("filesystem hashing is unavailable on this target".to_string())
}
#[cfg(not(target_arch = "wasm32"))]
fn hex_digest(bytes: impl AsRef<[u8]>) -> String {
let bytes = bytes.as_ref();
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
use std::fmt::Write;
let _ = write!(output, "{byte:02x}");
}
output
}
fn report_id(source_sha256: Option<&str>, created_unix_seconds: u64) -> String {
let fingerprint = source_sha256
.and_then(|value| value.get(..12))
.unwrap_or("nohash");
format!("R2-{created_unix_seconds}-{fingerprint}")
}
fn private_file_label(file_name: &str, source_sha256: Option<&str>) -> String {
let extension = Path::new(file_name)
.extension()
.and_then(|value| value.to_str())
.filter(|value| value.chars().all(|ch| ch.is_ascii_alphanumeric()))
.map(|value| format!(".{value}"))
.unwrap_or_default();
let fingerprint = source_sha256
.and_then(|value| value.get(..12))
.unwrap_or("redacted");
format!("drawing-{fingerprint}{extension}")
}
fn private_path_label(value: &str) -> String {
let normalized = value.replace('\\', "/");
let extension = Path::new(&normalized)
.extension()
.and_then(|extension| extension.to_str())
.filter(|extension| extension.chars().all(|ch| ch.is_ascii_alphanumeric()))
.map(|extension| format!(".{extension}"))
.unwrap_or_default();
format!("reference-redacted{extension}")
}
fn redact_known_paths(message: &str, source: &Path, references: &[XrefInfo]) -> String {
let mut redacted = redact_source_path(message, Some(source));
for reference in references {
let normalized = reference.path.replace('\\', "/");
let basename = Path::new(&normalized)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default();
for candidate in [&reference.path, &normalized, basename] {
if !candidate.is_empty() {
redacted = redacted.replace(candidate, "<reference-path>");
}
}
if !reference.name.is_empty() {
redacted = redacted.replace(&reference.name, "<reference-name>");
}
}
redacted
}
fn redact_source_path(message: &str, source: Option<&Path>) -> String {
let Some(source) = source else {
return message.to_string();
};
let mut redacted = message.replace(&source.to_string_lossy().to_string(), "<source-path>");
if let Some(file_name) = source.file_name().and_then(|value| value.to_str()) {
redacted = redacted.replace(file_name, "<source-file>");
}
redacted
}
fn structured_diagnostic_line(diagnostic: &acadrust::ReadDiagnostic) -> String {
let section = diagnostic.section.as_deref().unwrap_or("-");
let offset = diagnostic
.source_offset
.map(|value| format!("0x{value:X}"))
.unwrap_or_else(|| "-".to_string());
let offset_basis = diagnostic.source_offset_basis.as_deref().unwrap_or("-");
let line = diagnostic
.source_line
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string());
let handle = diagnostic
.record_handle
.map(|value| format!("0x{value:X}"))
.unwrap_or_else(|| "-".to_string());
let record_type = diagnostic
.record_type
.as_deref()
.unwrap_or("-");
format!(
"[code={} stage={} section={} offset={} offset-basis={} line={} handle={} type={}] {}",
diagnostic.code,
diagnostic.stage,
section,
offset,
offset_basis,
line,
handle,
record_type,
diagnostic.message
)
}
fn reader_revision() -> String {
include_str!("../../Cargo.toml")
.lines()
.find(|line| line.trim_start().starts_with("acadrust = { git ="))
.and_then(|line| line.split("rev = \"").nth(1))
.and_then(|value| value.split('"').next())
.filter(|value| !value.is_empty())
.unwrap_or("unknown")
.to_string()
}
fn diagnostic_platform() -> String {
format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH)
}
fn unix_seconds() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
fn safe_stem(value: &str) -> String {
let filtered: String = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
ch
} else {
'_'
}
})
.take(80)
.collect();
if filtered.is_empty() {
"drawing".to_string()
} else {
filtered
}
}
#[cfg(not(target_arch = "wasm32"))]
fn write_report(report: &RecoveryReport) -> Result<PathBuf, String> {
let file_name = report.suggested_download_name();
let body = report.log_text();
let mut errors = Vec::new();
if let Some(directory) = crate::config::config_dir().map(|path| path.join("recovery_logs")) {
if let Err(error) = std::fs::create_dir_all(&directory) {
errors.push(format!("Could not create private recovery-log directory: {error}"));
} else {
match write_unique(&directory, &file_name, &body) {
Ok(path) => return Ok(path),
Err(error) => errors.push(error),
}
}
}
Err(if errors.is_empty() {
"No private recovery-log directory was available".to_string()
} else {
errors.join("; ")
})
}
#[cfg(not(target_arch = "wasm32"))]
fn write_unique(directory: &Path, file_name: &str, body: &str) -> Result<PathBuf, String> {
use std::io::Write;
let stem = Path::new(file_name)
.file_stem()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing_recovery".to_string());
for suffix in 0..100u8 {
let candidate = if suffix == 0 {
directory.join(file_name)
} else {
directory.join(format!("{stem}_{suffix}.log"))
};
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&candidate) {
Ok(mut file) => {
file.write_all(body.as_bytes())
.map_err(|error| format!("Could not write recovery log: {error}"))?;
return Ok(candidate);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(format!("Could not create recovery log: {error}")),
}
}
Err("Could not allocate a unique recovery-log name".to_string())
}
+76
View File
@@ -7,6 +7,18 @@
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
struct OpenStoreState {
latest_id: u64,
latest_bytes: std::sync::Arc<[u8]>,
active_writers: usize,
}
thread_local! {
static OPEN_STORES: std::cell::RefCell<
std::collections::HashMap<String, OpenStoreState>,
> = std::cell::RefCell::new(std::collections::HashMap::new());
}
const RECENT_DIRECTORY: &str = "opencadstudio-recent";
const THUMBNAIL_MAGIC: &[u8; 4] = b"OCST";
const THUMBNAIL_MAX_DIM: u32 = 256;
@@ -35,6 +47,70 @@ pub async fn store(name: &str, bytes: &[u8]) -> Result<(), String> {
Ok(())
}
pub async fn store_open(
name: &str,
bytes: std::sync::Arc<[u8]>,
open_id: u64,
) -> Result<(), String> {
let key = name.to_string();
let (mut pending_id, mut pending_bytes) = OPEN_STORES.with(|stores| {
let mut stores = stores.borrow_mut();
let state = stores.entry(key.clone()).or_insert_with(|| OpenStoreState {
latest_id: open_id,
latest_bytes: std::sync::Arc::clone(&bytes),
active_writers: 0,
});
state.active_writers = state.active_writers.saturating_add(1);
if open_id > state.latest_id {
state.latest_id = open_id;
state.latest_bytes = bytes;
}
(state.latest_id, std::sync::Arc::clone(&state.latest_bytes))
});
loop {
if let Err(error) = store(name, &pending_bytes).await {
OPEN_STORES.with(|stores| {
let mut stores = stores.borrow_mut();
let remove = if let Some(state) = stores.get_mut(&key) {
state.active_writers = state.active_writers.saturating_sub(1);
state.active_writers == 0
} else {
false
};
if remove {
stores.remove(&key);
}
});
return Err(error);
}
let latest = OPEN_STORES.with(|stores| {
let mut stores = stores.borrow_mut();
let Some(state) = stores.get_mut(&key) else {
return None;
};
if state.latest_id != pending_id {
return Some((
state.latest_id,
std::sync::Arc::clone(&state.latest_bytes),
));
}
state.active_writers = state.active_writers.saturating_sub(1);
if state.active_writers == 0 {
stores.remove(&key);
}
None
});
match latest {
Some((latest_id, latest_bytes)) => {
pending_id = latest_id;
pending_bytes = latest_bytes;
}
None => return Ok(()),
}
}
}
async fn write_entry(
directory: &web_sys::FileSystemDirectoryHandle,
key: &str,
+129 -13
View File
@@ -1,18 +1,29 @@
use std::cell::RefCell;
use std::rc::Rc;
use acadrust::CadDocument;
use js_sys::{Array, Object, Reflect, Uint8Array};
use wasm_bindgen::closure::Closure;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{ErrorEvent, MessageEvent, Worker, WorkerOptions, WorkerType};
pub(super) async fn parse_document(name: &str, bytes: &[u8]) -> Result<CadDocument, String> {
const HASH_MARKER: &str = "\nreport-source-sha256:";
const PROTOCOL_VERSION: u16 = 3;
const WORKER_URL: &str = "ocs-parse-worker.js?v=3";
pub(super) async fn parse_document(
name: &str,
bytes: &[u8],
recovery_mode: bool,
initial_error: &str,
) -> Result<(acadrust::ReadOutcome, Option<String>), super::OpenLoadError> {
let options = WorkerOptions::new();
options.set_type(WorkerType::Module);
let worker = Worker::new_with_options("ocs-parse-worker.js", &options).map_err(js_error)?;
let worker = Worker::new_with_options(WORKER_URL, &options)
.map_err(|error| super::OpenLoadError::from(js_error(error)))?;
let (sender, receiver) = iced::futures::channel::oneshot::channel();
let (sender, receiver) = iced::futures::channel::oneshot::channel::<
Result<(acadrust::ReadOutcome, Option<String>), super::OpenLoadError>,
>();
let sender = Rc::new(RefCell::new(Some(sender)));
let message_sender = sender.clone();
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |event: MessageEvent| {
@@ -23,16 +34,41 @@ pub(super) async fn parse_document(name: &str, bytes: &[u8]) -> Result<CadDocume
.unwrap_or(false);
let result = if ok {
Reflect::get(&data, &JsValue::from_str("data"))
.map_err(js_error)
.map_err(|error| super::OpenLoadError::from(js_error(error)))
.and_then(|value| {
let bytes = Uint8Array::new(&value).to_vec();
bincode::deserialize(&bytes).map_err(|error| error.to_string())
let payload: (
u16,
Result<
acadrust::ReadOutcome,
(String, Option<acadrust::ReadStats>),
>,
Option<String>,
bool,
) = bincode::deserialize(&bytes)
.map_err(|error| super::OpenLoadError::from(error.to_string()))?;
if payload.0 != PROTOCOL_VERSION {
return Err(super::OpenLoadError::from(format!(
"parser worker protocol mismatch: expected {}, received {}",
PROTOCOL_VERSION, payload.0
)));
}
match payload.1 {
Ok(outcome) => Ok((outcome, payload.2)),
Err((message, read_stats)) => Err(super::OpenLoadError {
message,
source_sha256: payload.2,
read_stats,
recovery_available: payload.3,
}),
}
})
} else {
Err(Reflect::get(&data, &JsValue::from_str("error"))
let message = Reflect::get(&data, &JsValue::from_str("error"))
.ok()
.and_then(|value| value.as_string())
.unwrap_or_else(|| "CAD parser worker failed".to_string()))
.unwrap_or_else(|| "CAD parser worker failed".to_string());
Err(decode_worker_error(message))
};
if let Some(sender) = message_sender.borrow_mut().take() {
let _ = sender.send(result);
@@ -43,7 +79,7 @@ pub(super) async fn parse_document(name: &str, bytes: &[u8]) -> Result<CadDocume
let error_sender = sender;
let on_error = Closure::<dyn FnMut(ErrorEvent)>::new(move |event: ErrorEvent| {
if let Some(sender) = error_sender.borrow_mut().take() {
let _ = sender.send(Err(event.message()));
let _ = sender.send(Err(super::OpenLoadError::from(event.message())));
}
});
worker.set_onerror(Some(on_error.as_ref().unchecked_ref()));
@@ -54,22 +90,102 @@ pub(super) async fn parse_document(name: &str, bytes: &[u8]) -> Result<CadDocume
&JsValue::from_str("name"),
&JsValue::from_str(name),
)
.map_err(js_error)?;
.map_err(|error| super::OpenLoadError::from(js_error(error)))?;
Reflect::set(
&payload,
&JsValue::from_str("recoveryMode"),
&JsValue::from_bool(recovery_mode),
)
.map_err(|error| super::OpenLoadError::from(js_error(error)))?;
Reflect::set(
&payload,
&JsValue::from_str("initialError"),
&JsValue::from_str(initial_error),
)
.map_err(|error| super::OpenLoadError::from(js_error(error)))?;
let input = Uint8Array::from(bytes);
Reflect::set(&payload, &JsValue::from_str("bytes"), &input.buffer()).map_err(js_error)?;
Reflect::set(&payload, &JsValue::from_str("bytes"), &input.buffer())
.map_err(|error| super::OpenLoadError::from(js_error(error)))?;
let transfer = Array::new();
transfer.push(&input.buffer());
worker
.post_message_with_transfer(&payload, &transfer)
.map_err(js_error)?;
.map_err(|error| super::OpenLoadError::from(js_error(error)))?;
let result = receiver
.await
.map_err(|_| "CAD parser worker closed without a result".to_string())?;
.map_err(|_| super::OpenLoadError::from("CAD parser worker closed without a result"))?;
worker.terminate();
result
}
pub(super) async fn sha256_document(bytes: &[u8]) -> Result<String, super::OpenLoadError> {
let options = WorkerOptions::new();
options.set_type(WorkerType::Module);
let worker = Worker::new_with_options(WORKER_URL, &options)
.map_err(|error| super::OpenLoadError::from(js_error(error)))?;
let (sender, receiver) = iced::futures::channel::oneshot::channel::<
Result<String, super::OpenLoadError>,
>();
let sender = Rc::new(RefCell::new(Some(sender)));
let message_sender = sender.clone();
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |event: MessageEvent| {
let data = event.data();
let result = Reflect::get(&data, &JsValue::from_str("digest"))
.map_err(|error| super::OpenLoadError::from(js_error(error)))
.and_then(|value| {
value
.as_string()
.ok_or_else(|| super::OpenLoadError::from("hash worker returned no digest"))
});
if let Some(sender) = message_sender.borrow_mut().take() {
let _ = sender.send(result);
}
});
worker.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
let error_sender = sender;
let on_error = Closure::<dyn FnMut(ErrorEvent)>::new(move |event: ErrorEvent| {
if let Some(sender) = error_sender.borrow_mut().take() {
let _ = sender.send(Err(super::OpenLoadError::from(event.message())));
}
});
worker.set_onerror(Some(on_error.as_ref().unchecked_ref()));
let payload = Object::new();
Reflect::set(
&payload,
&JsValue::from_str("action"),
&JsValue::from_str("hash"),
)
.map_err(|error| super::OpenLoadError::from(js_error(error)))?;
let input = Uint8Array::from(bytes);
Reflect::set(&payload, &JsValue::from_str("bytes"), &input.buffer())
.map_err(|error| super::OpenLoadError::from(js_error(error)))?;
let transfer = Array::new();
transfer.push(&input.buffer());
worker
.post_message_with_transfer(&payload, &transfer)
.map_err(|error| super::OpenLoadError::from(js_error(error)))?;
let result = receiver
.await
.map_err(|_| super::OpenLoadError::from("hash worker closed without a result"))?;
worker.terminate();
result
}
fn decode_worker_error(message: String) -> super::OpenLoadError {
let Some((message, digest)) = message.rsplit_once(HASH_MARKER) else {
return super::OpenLoadError::from(message);
};
let digest = digest.trim_start().get(..64).unwrap_or_default();
let source_sha256 = (digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()))
.then(|| digest.to_ascii_lowercase());
super::OpenLoadError::new(message.to_string(), source_sha256)
}
fn js_error(value: JsValue) -> String {
value
.as_string()
+126 -11
View File
@@ -8,11 +8,20 @@ use acadrust::{CadDocument, EntityType};
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
use std::path::{Path, PathBuf};
#[cfg(not(target_arch = "wasm32"))]
type SourceFingerprint = crate::io::edit_lock::FileFingerprint;
#[cfg(target_arch = "wasm32")]
type SourceFingerprint = ();
/// Status of an external reference block.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum XrefStatus {
/// File was found and loaded successfully.
Loaded,
/// File was loaded with recoverable parser errors.
Recovered,
/// File was found but could not produce usable drawing data.
Failed,
/// File path is set but the file could not be found or read.
NotFound,
/// XRef is marked Unloaded in the host DWG — we honor that and
@@ -29,6 +38,10 @@ pub struct XrefInfo {
/// Resolved file path (or raw path if not found).
pub path: String,
pub status: XrefStatus,
/// Reader diagnostics retained for the recovery report.
pub diagnostics: Vec<String>,
pub read_stats: Option<acadrust::ReadStats>,
pub source_sha256: Option<String>,
}
/// Scan `doc` for XREF block-records, resolve their paths relative to
@@ -78,11 +91,25 @@ pub fn resolve_xrefs_with_progress(
.map(|_| std::sync::atomic::AtomicU16::new(0))
.collect(),
);
let parsed: Vec<(String, String, Handle, Option<PathBuf>, Option<CadDocument>)> = xref_entries
let parsed: Vec<(
String,
String,
Handle,
Option<PathBuf>,
Option<Result<acadrust::ReadOutcome, String>>,
Option<String>,
Option<SourceFingerprint>,
)> = xref_entries
.into_par_iter()
.enumerate()
.map(|(xref_index, (block_name, raw_path, br_handle))| {
let resolved = resolve_path(&raw_path, base_dir);
#[cfg(not(target_arch = "wasm32"))]
let initial_fingerprint = resolved.as_ref().and_then(|path| {
crate::io::edit_lock::FileFingerprint::capture(path).ok()
});
#[cfg(target_arch = "wasm32")]
let initial_fingerprint = None;
let units = std::sync::Arc::clone(&parse_units);
let nested_progress = progress.as_ref().map(|progress| {
let progress = std::sync::Arc::clone(progress);
@@ -98,7 +125,31 @@ pub fn resolve_xrefs_with_progress(
});
callback
});
let xref_doc = resolved.as_ref().and_then(|p| super::load_file_with_progress(p, nested_progress).ok());
let xref_outcome = resolved
.as_ref()
.map(|path| super::load_file_with_progress(path, nested_progress));
let source_sha256 = resolved.as_ref().and_then(|path| {
let needs_fingerprint = match &xref_outcome {
Some(Ok(outcome)) => {
outcome.stats.recovered()
|| outcome.stats.skipped_source_records > 0
|| !outcome.stats.stream_completed
}
Some(Err(_)) => true,
None => false,
};
if !needs_fingerprint {
return None;
}
#[cfg(not(target_arch = "wasm32"))]
{
super::stable_sha256_file(path, initial_fingerprint.as_ref())
}
#[cfg(target_arch = "wasm32")]
{
crate::io::recovery::sha256_file(path).ok()
}
});
parse_units[xref_index].store(1000, std::sync::atomic::Ordering::Relaxed);
if let Some(progress) = &progress {
let completed = parse_units
@@ -107,7 +158,15 @@ pub fn resolve_xrefs_with_progress(
.sum();
progress(completed, total_units);
}
(block_name, raw_path, br_handle, resolved, xref_doc)
(
block_name,
raw_path,
br_handle,
resolved,
xref_outcome,
source_sha256,
initial_fingerprint,
)
})
.collect();
@@ -115,15 +174,68 @@ pub fn resolve_xrefs_with_progress(
// block order (par_iter preserves it), so handle allocation is deterministic.
let mut result = Vec::with_capacity(parsed.len());
let mut dropped = 0usize;
for (merge_index, (block_name, raw_path, br_handle, resolved, xref_doc)) in parsed.into_iter().enumerate()
#[allow(unused_mut, unused_variables)]
for (
merge_index,
(
block_name,
raw_path,
br_handle,
resolved,
xref_outcome,
mut source_sha256,
initial_fingerprint,
),
) in parsed.into_iter().enumerate()
{
let status = if let Some(xref_doc) = xref_doc {
ensure_block_entities(doc, &block_name);
dropped += merge_xref_into_block(doc, &block_name, br_handle, xref_doc);
XrefStatus::Loaded
} else {
// Path unresolved or the file failed to parse — both are NotFound.
XrefStatus::NotFound
let (status, diagnostics, read_stats) = match xref_outcome {
Some(Ok(mut outcome)) => {
let recovered = outcome.stats.recovered()
|| outcome.stats.skipped_source_records > 0
|| !outcome.stats.stream_completed
|| outcome.document.notifications.iter().any(|item| {
item.notification_type == acadrust::notification::NotificationType::Error
});
let mut diagnostics: Vec<String> = outcome
.document
.notifications
.iter()
.map(ToString::to_string)
.collect();
diagnostics.extend(
outcome
.stats
.diagnostics
.iter()
.map(|diagnostic| diagnostic.message.clone()),
);
let invalid = super::purge_corrupt_entities(&mut outcome.document);
if invalid > 0 {
diagnostics.push(format!(
"normal read found {invalid} structurally invalid reference records"
));
#[cfg(not(target_arch = "wasm32"))]
if source_sha256.is_none() {
source_sha256 = resolved.as_ref().and_then(|path| {
super::stable_sha256_file(path, initial_fingerprint.as_ref())
});
}
}
if recovered || invalid > 0 {
(XrefStatus::Failed, diagnostics, Some(outcome.stats))
} else {
ensure_block_entities(doc, &block_name);
dropped += merge_xref_into_block(
doc,
&block_name,
br_handle,
outcome.document,
);
(XrefStatus::Loaded, diagnostics, Some(outcome.stats))
}
}
Some(Err(error)) => (XrefStatus::Failed, vec![error], None),
None => (XrefStatus::NotFound, Vec::new(), None),
};
result.push(XrefInfo {
@@ -132,6 +244,9 @@ pub fn resolve_xrefs_with_progress(
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or(raw_path),
status,
diagnostics,
read_stats,
source_sha256,
});
if let Some(progress) = &progress {
progress(