web: XREFs, data links & extraction, ARCHIVE transmittals, worker-side saves

The last four big web-vs-desktop gaps.

XREFs: XATTACH reads the picked drawing's bytes and registers them
name-keyed (the image/PDF-underlay pattern), pre-parses on the web
worker, and the commit merge produces the same xref block flow as
native. Opened drawings resolve xrefs from the registry (trailing-name
match) before cache build; unresolved references produce ONE warning
listing the names and the fix — drop the referenced files on the page
(drops and picker opens now register their bytes) and reopen or
XRELOAD. XRELOAD works off the registry on unsaved drawings too.

Data Link & Extraction: spreadsheet links read picked xlsx/csv bytes
(calamine from slices / CSV from string) through a session registry,
so preview, refresh, and table-insert work on web; extraction takes
external drawings via a multi-file picker parsed on the worker, and
outputs CSV/TXT/.dxex via download_bytes. The disabled 'Add folder'
button explains why.

ARCHIVE/ETRANSMIT: assembles the drawing plus registered images, PDF
underlays, and session plot tables into a ZIP delivered as
{stem}_transmit.zip; skipped dependencies are reported by name.

Save/autosave off the UI thread: the parse worker gains a serialize
action (protocol v5) — the document is prepared once on the main
thread (clone, styles, dimension bake), then the DWG/DXF writer pass,
the dominant cost, runs on the worker; any worker failure falls back
to inline serialization with the identical shared implementation.
The save path also clears the dirty flag only when the tab didn't
change mid-write, and the runtime-fields sidecar now carries
graphic_data, fixing a pre-existing loss of raw DWG round-trip bytes
on web-opened documents.

(cherry picked from commit 2e100634)

Ported onto main (#1717 review): the new ocs_save crate builds on the
pinned codec; the hand-written ZIP writer is replaced by the zip crate
already in the tree; ARCHIVE packs registered xref bytes too and
serializes document_for_save() like a web Save; the web save prepares
document_for_save() (prepare_document_for_save now takes the copy by
value, so no second clone); the wasm resolve_path consults the xref
registry, so every reference lookup — not just resolve — finds dropped
files; XRELOAD keeps main's undo, unload and stale-baseline handling;
native data extraction streams the source drawings one at a time.
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:04 +03:00
1 parent 9cd898a17a
commit 61c32c7f74
43 files changed
+2187 -336

No files matched your search

+164 -64
View File
@@ -1958,11 +1958,18 @@ impl OpenCADStudio {
crate::tf!("XREF Recovered with warnings: \"{}\"", info.name).as_ref(),
);
}
// Web: NotFound xrefs are summarized in one warning after the
// loop (see below) — the per-reference line carries a
// filesystem path no browser can open, and a drawing with
// several unresolved xrefs would repeat the same hint.
#[cfg(not(target_arch = "wasm32"))]
crate::io::xref::XrefStatus::NotFound => {
self.command_line.push_error(
crate::tf!("XREF Not found: \"{}\" ({})", info.name, info.path).as_ref(),
);
}
#[cfg(target_arch = "wasm32")]
crate::io::xref::XrefStatus::NotFound => {}
crate::io::xref::XrefStatus::Failed => {
self.command_line.push_error(
crate::tf!("XREF Recovery failed: \"{}\" ({})", info.name, info.path)
@@ -1976,6 +1983,11 @@ impl OpenCADStudio {
}
}
}
// Web: one warning per open lists every unresolved xref and the way
// to fix it — dropping the files on the page registers their bytes,
// and reopening the drawing (or XRELOAD) then resolves them.
#[cfg(target_arch = "wasm32")]
self.warn_unresolved_xrefs(&caches.xrefs);
#[cfg(not(target_arch = "wasm32"))]
let thumbs_task = self.push_recent(path.clone());
#[cfg(target_arch = "wasm32")]
@@ -2589,83 +2601,144 @@ impl OpenCADStudio {
self.tabs[i].scene.document.preview = Some(preview);
}
let mut recent_task = Task::none();
// The name the autosave snapshot for this tab is keyed by — capture it
// before the save re-points `current_path` at the download name.
let autosave_key = Self::web_autosave_key(&self.tabs[i]);
let document = self.tabs[i].scene.document_for_save();
let saved = match crate::io::save_to_bytes(&document, &ext, version) {
Ok(bytes) => {
crate::sys::download_bytes(&filename, &bytes);
let cache_name = std::path::Path::new(&filename)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| filename.clone());
let path = std::path::PathBuf::from(cache_name);
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].scene.document.version = version;
self.tabs[i].dirty = false;
self.tabs[i].recovery_save_as_required = false;
recent_task = Task::perform(
async move {
crate::io::web_recent::store(&path.to_string_lossy(), &bytes)
.await
.map(|_| path)
},
Message::WebRecentStored,
);
// A clean save supersedes any crash-recovery snapshot for this
// drawing — drop the old key and the new one so stale copies
// don't resurface in the next boot scan. But an autosave
// round may still be writing the same key: its write would
// land AFTER the remove and resurrect the snapshot (with a
// newer mtime, so the next scan would offer it as
// recoverable). Defer the removes to the end of the round.
let new_key = self.tabs[i]
.current_path
.as_ref()
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_default();
if self.web_autosave_in_flight {
self.web_autosave_clear_after.push(autosave_key);
if !new_key.is_empty() {
self.web_autosave_clear_after.push(new_key);
}
} else {
recent_task = Task::batch([
recent_task,
Task::perform(
async move {
let _ = crate::io::web_autosave::remove(&autosave_key).await;
let _ = crate::io::web_autosave::remove(&new_key).await;
},
|_| Message::Noop,
),
]);
}
self.command_line
.push_output(crate::tf!("Saved: {filename}").as_ref());
true
}
// Race guard (the web counterpart of native's `snapshot_is_current`):
// completion only clears the dirty flag if the tab did not change
// while the writer ran off-thread.
let geometry_epoch = self.tabs[i].scene.geometry_epoch;
let edit_revision = self.tabs[i].edit_revision;
let camera_generation = self.tabs[i].scene.camera_generation;
// Prepare the document here (clone + style sync + dimension bake — the
// app-side half of the pipeline), then hand the DWG/DXF writer pass to
// the browser worker so the UI thread stays responsive on large
// drawings. Completion (download + bookkeeping) continues in
// `on_web_save_serialized`.
let prepared =
crate::io::prepare_document_for_save(self.tabs[i].scene.document_for_save(), version);
Task::perform(
async move { crate::io::serialize_prepared_document(&prepared, &ext).await },
move |result| Message::WebSaveSerialized {
tab_id,
filename,
autosave_key,
version,
geometry_epoch,
edit_revision,
camera_generation,
result,
},
)
}
/// Web SAVE completion: the serializer worker (or inline fallback) has the
/// drawing bytes. The download and tab bookkeeping that used to run
/// synchronously inside `on_web_save_screenshot`, unchanged in semantics —
/// except the dirty flag is only cleared when the serialized snapshot is
/// still the tab's current state.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_save_serialized(
&mut self,
tab_id: u64,
filename: String,
autosave_key: String,
version: codec::DxfVersion,
geometry_epoch: u64,
edit_revision: u64,
camera_generation: u64,
result: Result<Vec<u8>, String>,
) -> Task<Message> {
let bytes = match result {
Ok(bytes) => bytes,
Err(error) => {
self.command_line
.push_error(crate::tf!("Save failed: {error}").as_ref());
false
return self.finish_web_save(false);
}
};
// The download is the only artifact of the save — fire it even if the
// tab was closed while serialization was in flight.
crate::sys::download_bytes(&filename, &bytes);
let cache_name = std::path::Path::new(&filename)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| filename.clone());
let path = std::path::PathBuf::from(cache_name);
let mut recent_task = Task::perform(
async move {
crate::io::web_recent::store(&path.to_string_lossy(), &bytes)
.await
.map(|_| path)
},
Message::WebRecentStored,
);
let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
return recent_task;
};
self.tabs[i].current_path = Some(
std::path::Path::new(&filename)
.file_name()
.map(|name| std::path::PathBuf::from(name.to_string_lossy().into_owned()))
.unwrap_or_else(|| std::path::PathBuf::from(filename.clone())),
);
self.tabs[i].scene.document.version = version;
self.tabs[i].recovery_save_as_required = false;
let snapshot_is_current = self.tabs[i].scene.geometry_epoch == geometry_epoch
&& self.tabs[i].edit_revision == edit_revision
&& self.tabs[i].scene.camera_generation == camera_generation;
if snapshot_is_current {
self.tabs[i].dirty = false;
}
// A clean save supersedes any crash-recovery snapshot for this
// drawing — drop the old key and the new one so stale copies don't
// resurface in the next boot scan. But an autosave round may still be
// writing the same key: its write would land AFTER the remove and
// resurrect the snapshot (with a newer mtime, so the next scan would
// offer it as recoverable). Defer the removes to the end of the round.
let new_key = self.tabs[i]
.current_path
.as_ref()
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_default();
if self.web_autosave_in_flight {
self.web_autosave_clear_after.push(autosave_key);
if !new_key.is_empty() {
self.web_autosave_clear_after.push(new_key);
}
} else {
recent_task = Task::batch([
recent_task,
Task::perform(
async move {
let _ = crate::io::web_autosave::remove(&autosave_key).await;
let _ = crate::io::web_autosave::remove(&new_key).await;
},
|_| Message::Noop,
),
]);
}
self.command_line
.push_output(crate::tf!("Saved: {filename}").as_ref());
Task::batch([recent_task, self.finish_web_save(true)])
}
/// Tail of the web save flow: continue a pending close when the save
/// succeeded, or re-open the unsaved-changes dialog when it failed.
#[cfg(target_arch = "wasm32")]
fn finish_web_save(&mut self, saved: bool) -> Task<Message> {
if self.save_dialog_for_unsaved {
if saved {
if let Some(crate::app::PendingClose::Tab(tab_id)) = self.pending_close.take() {
let continuation = self.update(Message::TabClose(tab_id));
let rest = self.continue_tab_close_queue();
return Task::batch([recent_task, continuation, rest]);
return Task::batch([continuation, rest]);
}
} else if self.pending_close.is_some() {
let retry = self.open_unsaved_dialog_window();
return Task::batch([recent_task, retry]);
return self.open_unsaved_dialog_window();
}
}
recent_task
Task::none()
}
#[cfg(not(target_arch = "wasm32"))]
@@ -3661,7 +3734,10 @@ impl OpenCADStudio {
/// native `.sv$` copy — best-effort and non-destructive: no download, no
/// recents update, dirty flag untouched. Snapshots run serially in one
/// round; while it lasts (or a web save's thumbnail capture is pending)
/// further ticks are dropped, not queued.
/// further ticks are dropped, not queued. Each tab is prepared (clone +
/// style sync + dimension bake) once up front, then the DWG/DXF writer
/// pass runs on the serializer worker — the UI thread only awaits the
/// result between OPFS writes.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_autosave(&mut self) -> Task<Message> {
if self.web_autosave_in_flight || self.pending_web_thumbnail_save.is_some() {
@@ -3688,7 +3764,11 @@ impl OpenCADStudio {
let is_dxf = crate::io::source_is_dxf(tab.current_path.as_deref(), &tab.scene.document);
let ext = if is_dxf { "dxf" } else { "dwg" }.to_string();
let version = tab.scene.document.version;
pending.push((name, ext, version, tab.scene.document.clone()));
pending.push((
name,
ext,
crate::io::prepare_document_for_save(tab.scene.document.clone(), version),
));
}
if pending.is_empty() {
return Task::none();
@@ -3697,10 +3777,10 @@ impl OpenCADStudio {
Task::perform(
async move {
let mut failure = None;
for (name, ext, version, doc) in pending {
for (name, ext, prepared) in pending {
// Autosave exists to recover geometry, not pretty recents:
// no thumbnail capture, just bytes into OPFS.
match crate::io::save_to_bytes(&doc, &ext, version) {
match crate::io::serialize_prepared_document(&prepared, &ext).await {
Ok(bytes) => {
if let Err(error) =
crate::io::web_autosave::snapshot(&name, &bytes, None).await
@@ -8132,3 +8212,23 @@ mod background_task_tests {
assert!(error.contains("worker exploded"), "error = {error}");
}
}
impl OpenCADStudio {
/// Web: one warning lists every unresolved xref and the way to fix it —
/// dropping the files on the page registers their bytes, and reopening
/// the drawing (or XRELOAD) then resolves them.
#[cfg(target_arch = "wasm32")]
pub(in crate::app) fn warn_unresolved_xrefs(&mut self, infos: &[crate::io::xref::XrefInfo]) {
let unresolved = crate::io::xref::unresolved_xref_names(infos);
if unresolved.is_empty() {
return;
}
let names = unresolved.join(", ");
self.command_line.push_warning(
crate::tf!(
"XREF Unresolved external references: {names} — drop the referenced DWG/DXF files on the page to register them, then reopen this drawing or run XRELOAD."
)
.as_ref(),
);
}
}
+80 -26
View File
@@ -1580,26 +1580,46 @@ impl OpenCADStudio {
Task::none()
}
Message::XAttachPick => Task::perform(
async {
let 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;
match handle {
Some(h) => Ok(crate::sys::handle_path(&h)),
None => Err("Cancelled".to_string()),
}
},
Message::XAttachPickResult,
),
Message::XAttachPick => {
// Web: the browser hands back bytes, not a path — read them,
// register them name-keyed (so reloads and re-opens of the
// host drawing re-resolve the xref in-session) and pre-parse
// on the worker in one step. The returned name-only PathBuf
// is what the native branch's `handle_path` yields there.
#[cfg(target_arch = "wasm32")]
{
Task::perform(
crate::io::pick_and_register_xref_web(),
Message::XAttachPickResult,
)
}
#[cfg(not(target_arch = "wasm32"))]
{
Task::perform(
async {
let 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;
match handle {
Some(h) => Ok(crate::sys::handle_path(&h)),
None => Err("Cancelled".to_string()),
}
},
Message::XAttachPickResult,
)
}
}
Message::XAttachPickResult(Ok(path)) => {
self.open_xref_attach_dialog(path);
@@ -1692,9 +1712,13 @@ impl OpenCADStudio {
self.on_data_extraction_browse_settings_result(path)
}
Message::DataExtractionAddDrawings => self.on_data_extraction_add_drawings(),
Message::DataExtractionAddDrawingsResult(paths) => {
Message::DataExtractionAddDrawingsResult(Ok(paths)) => {
self.on_data_extraction_add_drawings_result(paths)
}
Message::DataExtractionAddDrawingsResult(Err(error)) => {
self.data_extraction.error = error;
Task::none()
}
Message::DataExtractionAddFolder => self.on_data_extraction_add_folder(),
Message::DataExtractionAddFolderResult(path) => {
self.on_data_extraction_add_folder_result(path)
@@ -1728,13 +1752,22 @@ impl OpenCADStudio {
}
Message::DataExtractionSaveResult(csv, Some(path)) => {
match std::fs::write(&path, csv.as_bytes()) {
let fname = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
// The browser has no writable path behind the picked name:
// the CSV leaves as a download instead of an fs::write.
#[cfg(target_arch = "wasm32")]
let write_result: Result<(), String> = {
crate::sys::download_bytes(&fname, csv.as_bytes());
Ok(())
};
#[cfg(not(target_arch = "wasm32"))]
let write_result = std::fs::write(&path, csv.as_bytes()).map_err(|e| e.to_string());
match write_result {
Ok(()) => {
let rows = csv.lines().count().saturating_sub(1);
let fname = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
self.command_line.push_output(
crate::tf!("DATAEXTRACTION {rows} rows → \"{fname}\"").as_ref(),
);
@@ -10103,6 +10136,27 @@ impl OpenCADStudio {
screenshot,
} => self.on_web_save_screenshot(tab_id, filename, ext, version, bounds, screenshot),
#[cfg(target_arch = "wasm32")]
Message::WebSaveSerialized {
tab_id,
filename,
autosave_key,
version,
geometry_epoch,
edit_revision,
camera_generation,
result,
} => self.on_web_save_serialized(
tab_id,
filename,
autosave_key,
version,
geometry_epoch,
edit_revision,
camera_generation,
result,
),
#[cfg(not(target_arch = "wasm32"))]
Message::SaveFileInUseRetry => self.on_save_file_in_use_retry(),