web: raster images, WBLOCK/SAVEALL/SCRIPT/CTB/LAYTRANS/INSERT, filled TTF text

A fresh web-vs-desktop audit found the remaining gaps; this closes the
top ten.

Raster images: IMAGE attach read the picked file via image::open on a
name-only path (dead on wasm), and raster references inside opened
drawings decoded the same way. Picked bytes are now registered in a
name-keyed store (the PDF-underlay pattern), decoding runs from memory,
and dropped .png/.jpg/.bmp/.tif files register by name so unresolved
references in opened drawings re-resolve and render (and export to PDF).

Command gaps fixed with the bytes-at-pick / download_bytes pattern:
- WBLOCK now downloads the extracted block instead of failing on fs.
- SAVEALL saves every dirty tab instead of printing a no-op message.
- SCRIPT picks and reads the .scr file through the browser picker.
- CENTERLTYPEFILE picks the .lin/.dnl file and populates from text.
- Custom .ctb tables picked on web now parse, register for the
  session, appear in the plot dialog list, and apply (load_named
  previously returned builtin-only on wasm).
- LAYTRANS 'Load standards file' parses the picked drawing on the
  existing web worker.
- INSERT 'from file' in the block palette parses picked bytes and
  feeds the shared import path.

Text fidelity: TTF-named styles rendered as thin stroke glyphs on web
because the font database was empty. Fetched Noto faces are now
registered into it with a family alias table (Arial, Times New Roman,
Cambria, ... -> Noto Sans/Serif), so those styles render filled, shape,
export to PDF as vector fills, and appear in the style picker.
ViewCube labels request semibold again on web.

docs/native-vs-web.md: the hatch and font rows were stale (both work on
web now), the GPU row claimed WebGPU is used where available (the
backend is pinned to OpenGL), and the parallelism row missed the parse
worker — all corrected.

(cherry picked from commit c643a10e)

Ported onto main (#1717 review): conflicts resolved toward main — the
font-file lookup, PDF searchable text, purge service, block palette and
plot-style copy stay as they are on main; the web pieces (font registry
and aliases, session plot styles, SCRIPT/SAVEALL/CENTERLTYPEFILE picks,
block-from-file on the web) are layered on top.
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:04 +03:00
1 parent 69fa7cae4f
commit 9cd898a17a
42 files changed
+1315 -155

No files matched your search

+38
View File
@@ -51,6 +51,44 @@ impl OpenCADStudio {
self.persist_settings_if_changed();
}
BlockPaletteMsg::Refresh => self.refresh_block_palette(),
// Web: read the picked file's bytes and parse them right away —
// the picker's name-only path has nothing behind it to read later.
#[cfg(target_arch = "wasm32")]
BlockPaletteMsg::PickFile => {
return Task::perform(
async {
crate::io::pick_and_parse_web(
crate::t!("Select Drawing to Insert as Block").as_ref(),
crate::t!("DWG/DXF Files").as_ref(),
&["dwg", "dxf", "DWG", "DXF"],
)
.await
.and_then(|picked| picked.ok_or_else(|| "Cancelled".to_string()))
},
|r| Message::BlockPalette(BlockPaletteMsg::FilePickedDoc(r)),
);
}
#[cfg(target_arch = "wasm32")]
BlockPaletteMsg::FilePickedDoc(Ok((name, document))) => {
let stem = std::path::Path::new(&name)
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_else(|| "Block".to_string());
match self.import_document_block(document, stem, false) {
Ok(name) => {
self.refresh_block_palette();
return self.start_palette_insert(&name, None);
}
Err(e) => self.command_line.push_error(&e),
}
}
#[cfg(target_arch = "wasm32")]
BlockPaletteMsg::FilePickedDoc(Err(e)) => {
if e != "Cancelled" {
self.command_line.push_error(&e);
}
}
#[cfg(not(target_arch = "wasm32"))]
BlockPaletteMsg::PickFile => {
return Task::perform(
async {
+2 -1
View File
@@ -13,6 +13,7 @@ use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip
use crate::scene::{
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
};
use crate::ui::window::block_palette::BlockPaletteMsg;
use crate::ui::PropertiesPanel;
use codec::types::Color as AcadColor;
use codec::{EntityType as AcadEntityType, Handle};
@@ -395,7 +396,7 @@ impl OpenCADStudio {
self.import_document_block(doc, stem, false)
}
fn import_document_block(
pub(super) fn import_document_block(
&mut self,
doc: codec::CadDocument,
stem: String,
+87 -8
View File
@@ -2298,12 +2298,95 @@ impl OpenCADStudio {
crate::modules::insert::wblock::extract_block_to_doc(&document, &worker_name)
}
.map_err(|e| e.to_string())?;
crate::io::save(&document, &worker_path).map_err(|e| e.to_string())
#[cfg(not(target_arch = "wasm32"))]
{
crate::io::save(&document, &worker_path).map_err(|e| e.to_string())
}
// Web: no filesystem behind the name-only path — serialize to
// bytes (the same document version `save` would pick, format
// from the chosen file name) and hand the browser a download,
// the way the other web exports do.
#[cfg(target_arch = "wasm32")]
{
let ext = worker_path
.extension()
.map(|ext| ext.to_string_lossy().into_owned())
.unwrap_or_else(|| "dwg".to_string());
let bytes = crate::io::save_to_bytes(&document, &ext, document.version)?;
let name = worker_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "block.dwg".into());
crate::sys::download_bytes(&name, &bytes);
Ok(())
}
},
move |result| Message::WblockWriteFinished(block_name, path, result),
)
}
/// Web SAVEALL: every dirty tab that already has a name-only path is
/// serialized and downloaded in one pass — the same
/// `save_to_bytes` + `download_bytes` route a single Save takes. The
/// browser may ask once for permission to download several files, which
/// is the platform's price. Clean tabs are skipped (re-downloading
/// identical bytes is noise); tabs with no path still need SAVEAS.
#[cfg(target_arch = "wasm32")]
pub(in crate::app) fn saveall_web(&mut self) {
let mut saved = 0usize;
let mut skipped = 0usize;
for t in 0..self.tabs.len() {
if self.tabs[t].is_start {
continue;
}
let Some(path) = self.tabs[t].current_path.clone() else {
skipped += 1;
continue;
};
if !self.tabs[t].dirty {
continue;
}
// The same preparation a single web Save makes on the tab.
self.sync_view_state_for_save(t);
sync_annotation_scale_header(&mut self.tabs[t].scene);
self.stamp_header_sysvars(t);
self.sync_solid_models_for_save(t);
self.tabs[t].scene.sync_native_parametric_graph();
let ext = path
.extension()
.map(|ext| ext.to_string_lossy().into_owned())
.unwrap_or_else(|| "dwg".to_string());
let version = self.tabs[t].scene.document.version;
let filename = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
match crate::io::save_to_bytes(&self.tabs[t].scene.document, &ext, version) {
Ok(bytes) => {
crate::sys::download_bytes(&filename, &bytes);
self.tabs[t].scene.document.version = version;
self.tabs[t].dirty = false;
self.tabs[t].recovery_save_as_required = false;
saved += 1;
}
Err(error) => self
.command_line
.push_error(crate::tf!("SAVEALL: {} failed: {error}", path.display()).as_ref()),
}
}
self.command_line.push_output(
crate::tf!(
"SAVEALL: saved {saved} drawing(s){}.",
if skipped > 0 {
crate::tf!("; {skipped} need SAVEAS (no file path yet)").into_owned()
} else {
String::new()
}
)
.as_ref(),
);
}
pub(super) fn on_stl_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
// Re-build STL bytes (we can't easily pass them through the message).
let i = self.active_tab;
@@ -3538,9 +3621,7 @@ impl OpenCADStudio {
/// single derivation every web snapshot/remove site must agree on, or a
/// remove misses the snapshot a round wrote (and vice versa).
#[cfg(target_arch = "wasm32")]
pub(super) fn web_autosave_key(
tab: &crate::app::document::DocumentTab,
) -> String {
pub(super) fn web_autosave_key(tab: &crate::app::document::DocumentTab) -> String {
tab.current_path
.as_ref()
.and_then(|path| path.file_name())
@@ -3784,10 +3865,8 @@ impl OpenCADStudio {
// survives in OPFS and is re-offered on the next boot.
if !crate::io::web_drop::enqueue_restore(open_name, bytes, key) {
self.command_line.push_warning(
crate::tf!(
"Autosave copy could not be queued: too many pending opens"
)
.as_ref(),
crate::tf!("Autosave copy could not be queued: too many pending opens")
.as_ref(),
);
self.queue_next_web_autosave_prompt();
}
+127 -15
View File
@@ -686,6 +686,23 @@ impl OpenCADStudio {
} else {
Task::none()
};
// Dropped raster images register their bytes (name-keyed) and
// re-resolve any RasterImage the open drawings reference but
// couldn't load — the web stand-in for the drawing folder the
// images live beside natively.
let mut registered: Vec<String> = Vec::new();
while let Some((name, bytes)) = crate::io::web_drop::take_dropped_image() {
crate::scene::model::image_model::register_source(
&name,
std::sync::Arc::new(bytes),
);
registered.push(name);
}
if !registered.is_empty() {
for tab in self.tabs.iter_mut() {
tab.scene.resolve_registered_image_names(&registered);
}
}
Task::batch([control, dropped])
}
#[cfg(not(target_arch = "wasm32"))]
@@ -1115,13 +1132,11 @@ impl OpenCADStudio {
// open's id so cancelling an unrelated open leaves a pending
// restore marker alone.
#[cfg(target_arch = "wasm32")]
if cancelled_id
.is_some_and(|id| {
self.web_autosave_consumed
.as_ref()
.is_some_and(|(_, recorded)| *recorded == id)
})
&& self.web_autosave_consumed.take().is_some()
if cancelled_id.is_some_and(|id| {
self.web_autosave_consumed
.as_ref()
.is_some_and(|(_, recorded)| *recorded == id)
}) && self.web_autosave_consumed.take().is_some()
{
self.queue_next_web_autosave_prompt();
}
@@ -1140,9 +1155,7 @@ impl OpenCADStudio {
// warning only (the drop path has no user-facing
// surface for rejections by design — see QUEUE_CAP).
let _ = match restore_key {
Some(key) => {
crate::io::web_drop::enqueue_restore(name, bytes, key)
}
Some(key) => crate::io::web_drop::enqueue_restore(name, bytes, key),
None => crate::io::web_drop::enqueue_open(name, bytes),
};
return Task::none();
@@ -1247,7 +1260,20 @@ impl OpenCADStudio {
if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) {
return Task::none();
}
self.on_file_opened(name, path, doc, caches)
let task = self.on_file_opened(name, path, doc, caches);
// Web: raster files dropped while this open was in flight
// registered after its caches were built — re-resolve any
// image the (possibly new) drawing references by name.
#[cfg(target_arch = "wasm32")]
{
let names = crate::scene::model::image_model::registered_image_names();
if !names.is_empty() {
for tab in self.tabs.iter_mut() {
tab.scene.resolve_registered_image_names(&names);
}
}
}
task
}
Message::FileOpened(open_id, Err(e)) => {
@@ -2810,6 +2836,39 @@ impl OpenCADStudio {
Message::ScriptLine(line) => self.feed_script_line(&line),
// Web SCRIPT: the picked .scr file arrived — run it through the
// same runner the native path feeds.
#[cfg(target_arch = "wasm32")]
Message::ScriptFilePicked(Some((name, text))) => self.run_script(&name, &text),
#[cfg(target_arch = "wasm32")]
Message::ScriptFilePicked(None) => Task::none(),
// Web CENTERLTYPEFILE: the picked .lin/.dnl file's text arrived —
// load its linetypes, then record the setting under the file's
// name (the native command reads the typed path inside
// `set_centerline_setting` instead).
#[cfg(target_arch = "wasm32")]
Message::CenterlineLinetypeFilePicked(Some((name, source))) => {
let i = self.active_tab;
crate::io::linetypes::populate_document_from_source(
&mut self.tabs[i].scene.document,
&source,
);
match self.tabs[i]
.scene
.set_centerline_setting("CENTERLTYPEFILE", &name)
{
Ok(message) => {
self.tabs[i].dirty = true;
self.command_line.push_output(&message);
}
Err(error) => self.command_line.push_error(&error),
}
Task::none()
}
#[cfg(target_arch = "wasm32")]
Message::CenterlineLinetypeFilePicked(None) => Task::none(),
Message::ToggleLayers => {
if self.show_layers {
return self.on_dock(crate::ui::dock::DockMsg::Close(
@@ -3113,10 +3172,61 @@ impl OpenCADStudio {
}
// ── Layer Translator (#624) ──────────────────────────────────
Message::LayerTranslatorLoad => {
Task::perform(crate::io::pick_layer_standard_path(), |path| match path {
Some(path) => Message::LayerTranslatorLoaded(path),
None => Message::Noop,
})
#[cfg(not(target_arch = "wasm32"))]
{
Task::perform(crate::io::pick_layer_standard_path(), |path| match path {
Some(path) => Message::LayerTranslatorLoaded(path),
None => Message::Noop,
})
}
// Web: read the picked file's bytes and parse them on the
// worker right away — a name-only path has nothing behind it
// for `load_targets` to read later.
#[cfg(target_arch = "wasm32")]
{
Task::perform(
async {
crate::io::pick_and_parse_web(
crate::t!("Load layer standard").as_ref(),
crate::t!("Drawings and standards").as_ref(),
&["dwg", "dws", "dwt", "dxf", "DWG", "DWS", "DWT", "DXF"],
)
.await
.and_then(|picked| {
picked.map_or(Ok(None), |(name, document)| {
crate::modules::draw::layers::laytrans::targets_from_document(
&document,
)
.map(|targets| Some((name, targets)))
})
})
},
|loaded| Message::LayerTranslatorLoadedWeb(loaded),
)
}
}
#[cfg(target_arch = "wasm32")]
Message::LayerTranslatorLoadedWeb(loaded) => {
match loaded {
Ok(Some((source_file, targets))) => {
let state = self.layer_translator.get_or_insert_with(Default::default);
state.source_file = source_file;
state.targets = targets;
// A target set that no longer contains a mapped name
// would translate onto nothing.
let names: Vec<String> =
state.targets.iter().map(|t| t.name.clone()).collect();
state
.mappings
.retain(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.to)));
state.selected_to = None;
}
Err(why) => self
.command_line
.push_error(crate::tf!("LAYTRANS: {why}.").as_ref()),
Ok(None) => {}
}
Task::none()
}
Message::LayerTranslatorLoaded(path) => {
use crate::modules::draw::layers::laytrans;
@@ -10162,6 +10272,7 @@ impl OpenCADStudio {
.unwrap_or_else(|| "drawing".into());
// The save dialog follows the dialog's file device, so the
// SVG choice proposes `stem.svg` up front.
#[cfg(not(target_arch = "wasm32"))]
let svg = self.plot_dialog.file_svg;
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
{
@@ -10199,6 +10310,7 @@ impl OpenCADStudio {
.and_then(|p: &std::path::Path| p.file_stem())
.map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
#[cfg(not(target_arch = "wasm32"))]
let svg = self.plot_dialog.file_svg;
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
{