diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index a2da42581..1c81de32e 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -120,6 +120,14 @@ pub(super) struct State { /// screenshot, which cannot run on wasm. #[cfg(target_arch = "wasm32")] pub(super) pending_capture: Option, + /// When the parked `capture` above gives up: `window::frames()` never + /// fires in a hidden tab (rAF is parked) or a visible one with nothing + /// to redraw, and `control_settle` refuses to finish while + /// `pending_capture` is set — so one stuck capture would block every + /// later op forever. The 50 ms `PollWebControl` tick checks this + /// deadline (see `expire_stale_web_capture`) and fails the op instead. + #[cfg(target_arch = "wasm32")] + pub(super) capture_deadline: Option, } impl State { pub(super) fn new() -> Self { @@ -956,7 +964,21 @@ impl OpenCADStudio { // until ControlScreenshot lands from that frame. #[cfg(target_arch = "wasm32")] { + // Fail fast when the tab cannot present frames at all: + // rAF never fires while the document is hidden, so a + // parked capture would just sit there until the + // watchdog. Refusing without parking gives the caller + // the same error immediately. + if !crate::sys::page_visible() { + return Err(failure( + "capture_not_visible", + "capture requires a visible/repainting tab", + )); + } self.control.pending_capture = Some(path); + self.control.capture_deadline = Some( + iced::time::Instant::now() + std::time::Duration::from_secs(5), + ); Task::none() } #[cfg(not(target_arch = "wasm32"))] @@ -1176,6 +1198,7 @@ impl OpenCADStudio { /// canvas in — one frame after the request, before the buffer clears. #[cfg(target_arch = "wasm32")] pub(super) fn on_control_capture_frame(&mut self) -> Task { + self.control.capture_deadline = None; if let Some(path) = self.control.pending_capture.take() { let screenshot = crate::sys::capture_canvas(); self.control_screenshot(path, screenshot); @@ -1184,6 +1207,30 @@ impl OpenCADStudio { Task::none() } + /// Web capture watchdog, run from every `PollWebControl` tick (50 ms): a + /// parked `capture` waits for a presented frame, which NEVER arrives in a + /// hidden tab (rAF parked) or a visible one with nothing redrawing — and + /// `control_settle` refuses to finish while `pending_capture` is set, so + /// one such capture would block every later automation op forever. Past + /// the 5 s deadline the op fails with a clear error instead. Background + /// tabs throttle the poll timer to ~1 s, still well inside the deadline. + #[cfg(target_arch = "wasm32")] + pub(super) fn expire_stale_web_capture(&mut self) { + let Some(deadline) = self.control.capture_deadline else { + return; + }; + if self.control.pending_capture.is_none() || iced::time::Instant::now() < deadline { + return; + } + self.control.pending_capture = None; + self.control.capture_deadline = None; + // push_error bumps `error_revision`, which `control_settle` reads as + // the failed verdict and reports as the op's `last_error`. + self.command_line + .push_error("capture requires a visible/repainting tab"); + self.control_settle(); + } + pub(super) fn control_screenshot( &mut self, path: String, diff --git a/src/app/drafting_settings.rs b/src/app/drafting_settings.rs index 04f5753ed..2c47ba1b5 100644 --- a/src/app/drafting_settings.rs +++ b/src/app/drafting_settings.rs @@ -100,6 +100,14 @@ impl OpenCADStudio { self.snapper.enabled = state.snap_modes.clone(); self.snapper.snap3d_enabled = state.osnap3d_on; self.snapper.enabled3d = state.snap3d_modes.clone(); + // Publish the 3D master to the scene's solid edge-wire gate (same + // rationale as Message::ToggleSnap3dEnabled): with the 3D system + // off the 2D edge-wire fallback must come back, and cached wires + // baked with the old gate decision must re-tessellate. + crate::scene::set_snap3d_enabled(state.osnap3d_on); + for tab in &mut self.tabs { + tab.scene.bump_geometry_no_blocks(); + } self.dyn_input = state.dyn_input_on; self.quick_properties = state.quick_props_on; self.selection_cycling = state.selection_cycling_on; diff --git a/src/app/mod.rs b/src/app/mod.rs index 1d6707939..a2d1049a9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -798,6 +798,11 @@ pub(super) struct OpenCADStudio { main_window: Option, /// Hides drawing UI overlays for one thumbnail capture frame. thumbnail_capture_clean: bool, + /// Web: when the print-only snapshot `` was last refreshed (see + /// [`crate::sys::refresh_print_snapshot`]). `None` = never captured, so + /// the first presented frame fills it. + #[cfg(target_arch = "wasm32")] + print_snapshot_at: Option, #[cfg(not(target_arch = "wasm32"))] pending_native_thumbnail_save: Option, #[cfg(target_arch = "wasm32")] @@ -2099,6 +2104,12 @@ pub enum Message { /// directly and never emits this. #[cfg(target_arch = "wasm32")] ControlCaptureFrame, + /// Web: a frame was presented, so the print-only snapshot `` can be + /// refreshed from the still-readable canvas (see + /// [`crate::sys::refresh_print_snapshot`]). The handler throttles to one + /// capture every 2 s; native never emits this. + #[cfg(target_arch = "wasm32")] + PrintSnapshotFrame, ControlToggle, Tick(Instant), /// Periodic drain of plugin-to-host requests that arrived outside a host @@ -4264,6 +4275,8 @@ impl OpenCADStudio { pending_click_snap: None, main_window: None, thumbnail_capture_clean: false, + #[cfg(target_arch = "wasm32")] + print_snapshot_at: None, #[cfg(not(target_arch = "wasm32"))] pending_native_thumbnail_save: None, #[cfg(target_arch = "wasm32")] @@ -4644,6 +4657,30 @@ impl OpenCADStudio { app } + /// Web: keep the print-only snapshot `` always warm + /// ([`crate::sys::refresh_print_snapshot`]). Runs on every presented + /// frame but captures at most once every 2 s, and only while the page is + /// visible — a hidden tab presents no frames anyway, and skipping keeps a + /// backgrounded app from burning encode cycles it cannot need. Frames + /// only arrive while the window is actually redrawing, so an idle app + /// performs no captures at all; the printout may thus lag the canvas by + /// up to the throttle window. + #[cfg(target_arch = "wasm32")] + fn on_print_snapshot_frame(&mut self) { + const THROTTLE: std::time::Duration = std::time::Duration::from_secs(2); + if self + .print_snapshot_at + .is_some_and(|at| at.elapsed() < THROTTLE) + { + return; + } + if !crate::sys::page_visible() { + return; + } + self.print_snapshot_at = Some(Instant::now()); + crate::sys::refresh_print_snapshot(); + } + /// Install `cmd` as the active interactive command for tab `tab`. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) fn set_active_command( diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index c705fc779..bd3046b14 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -212,6 +212,14 @@ impl OpenCADStudio { // The tab is already gone; nothing to discard. return close_win; }; + // …and, on the web, its retained in-place save handle — the + // same hook on_tab_close runs: the user declined the save, + // so the picked file must never receive a later write, and + // the next tab to reuse this id must start with a fresh + // pick instead of silently writing over the discarded + // drawing's file. + #[cfg(target_arch = "wasm32")] + crate::io::fs_access::drop_handle(id); // Discarded — drop this tab's autosave recovery copy. #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(self.autosave_target(idx)); @@ -328,6 +336,21 @@ impl OpenCADStudio { #[cfg(target_arch = "wasm32")] { + // Same guard the native half runs against active_save_jobs + // above: while a web save is between capture and completion, + // refuse a second one (the window is seconds — refuse, don't + // queue) instead of letting two serializer rounds race for the + // same tab's bookkeeping. + if let Some(busy_tab) = crate::io::fs_access::web_save_in_flight() { + if self.tabs.iter().any(|tab| tab.id == busy_tab) { + self.command_line + .push_info(crate::t!("Save already running for this drawing.").as_ref()); + return Task::none(); + } + // Stale marker (its tab closed before settling): clear it + // rather than block this save forever. + crate::io::fs_access::end_web_save(busy_tab); + } match self.pending_close.take() { Some(crate::app::PendingClose::Tab(id)) => { let Some(idx) = self.tabs.iter().position(|tab| tab.id == id) else { diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 307f83f07..b4c32bbb6 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2396,6 +2396,9 @@ impl OpenCADStudio { ) -> Task { self.thumbnail_capture_clean = false; let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else { + // The tab closed while its capture was queued: the save is over + // (no serialization follows), so release the in-flight slot. + crate::io::fs_access::end_web_save(tab_id); return Task::none(); }; let preview = screenshot.as_ref().and_then(|screenshot| { @@ -2443,11 +2446,12 @@ impl OpenCADStudio { } /// Web SAVE completion: the serializer worker (or inline fallback) has the - /// drawing bytes. When the tab holds a File System Access handle the - /// bytes go to it in place (createWritable needs no user gesture, so the + /// drawing bytes. When the tab still exists AND holds a File System + /// Access handle picked for the format these bytes were serialized as, + /// they go to it in place (createWritable needs no user gesture, so the /// write may happen long after the pick); completion continues in - /// `on_web_save_written`. Otherwise the legacy download fires — the only - /// artifact of the save, launched even if the tab was closed while + /// `on_web_save_written`. Anything else takes the legacy download — the + /// only artifact of the save, launched even if the tab was closed while /// serialization was in flight. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_save_serialized( @@ -2466,15 +2470,37 @@ impl OpenCADStudio { Err(error) => { self.command_line .push_error(crate::tf!("Save failed: {error}").as_ref()); + crate::io::fs_access::end_web_save(tab_id); return self.finish_web_save(false); } }; - // In-place write through the handle picked at the confirm gesture - // (or an earlier one). The bookkeeping runs at write completion - // (WebSaveWritten) so a failed write cannot clear the dirty flag; - // the bytes ride back with the completion so the OPFS recent store - // gets the same copy the download path uses. - if let Some(picked_name) = crate::io::fs_access::handle_name(tab_id) { + // In-place write eligibility, all three required: + // * the tab still exists — a tab that closed while serialization ran + // means the user declined this save (discard/close), and the + // legacy contract for closed tabs is download-only; + // * a handle is held AND it was picked for the format these bytes + // were serialized for (the pending ext recorded at capture time): + // a Save As whose re-pick failed falls back to the download, and + // the stale handle from the previous format must not receive the + // fallback bytes (a .dwg file must never silently hold DXF). + let tab_exists = self.tabs.iter().position(|tab| tab.id == tab_id).is_some(); + let target_ext = crate::io::fs_access::take_pending_save_ext(tab_id); + let write_in_place = tab_exists + && crate::io::fs_access::write_or_download( + crate::io::fs_access::handle_ext(tab_id).as_deref(), + target_ext.as_deref(), + ); + if !write_in_place { + // Whatever handle is held is stale for these bytes (wrong + // format, unverifiable format, or a tab that declined the + // save): drop it so the next save re-picks instead of hitting + // the same mismatch. No handle held: the drop is a no-op. + crate::io::fs_access::drop_handle(tab_id); + } else if let Some(picked_name) = crate::io::fs_access::handle_name(tab_id) { + // The bookkeeping runs at write completion (WebSaveWritten) so a + // failed write cannot clear the dirty flag; the bytes ride back + // with the completion message so the OPFS recent store gets the + // same copy the download path uses. let download_name = picked_name.clone(); return Task::perform( async move { @@ -2574,6 +2600,9 @@ impl OpenCADStudio { camera_generation: u64, bytes: Vec, ) -> Task { + // The save settled (download or in-place write): release the + // in-flight slot the on_save_file guard refuses against. + crate::io::fs_access::end_web_save(tab_id); let cache_name = std::path::Path::new(&filename) .file_name() .map(|name| name.to_string_lossy().into_owned()) @@ -3352,6 +3381,20 @@ impl OpenCADStudio { // once after the destination/version is known. #[cfg(target_arch = "wasm32")] { + // The web counterpart of native's active_save_jobs guard: one + // capture → serialize → write/download chain at a time. The + // window is seconds, so refuse rather than queue — same + // localized message the native flow shows. + if let Some(busy_tab) = crate::io::fs_access::web_save_in_flight() { + if self.tabs.iter().any(|tab| tab.id == busy_tab) { + self.command_line + .push_info(crate::t!("Save already running for this drawing.").as_ref()); + return Task::none(); + } + // Stale marker (its tab closed before the save could + // settle): clear it instead of blocking every later save. + crate::io::fs_access::end_web_save(busy_tab); + } self.sync_view_state_for_save(i); self.stamp_header_sysvars(i); } @@ -3579,6 +3622,12 @@ impl OpenCADStudio { ext: String, version: acadrust::DxfVersion, ) -> Task { + // The save is now committed to this tab and this format: mark it + // in flight (the web counterpart of native's active_save_jobs — + // on_save_file refuses a second save while the slot is held) and + // record the intended ext, which the completion half re-checks + // against the retained handle before writing. + crate::io::fs_access::begin_web_save(tab_id, &ext); let bounds = crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID); let Some(bounds) = bounds else { return Task::done(Message::WebSaveScreenshot { @@ -3607,7 +3656,9 @@ impl OpenCADStudio { /// any previous one — Save As re-picks); `Ok(None)` (dismissed picker) /// cancels the save like a native save-dialog cancel; `Err` keeps the /// save alive through the legacy download, with a note that the copy - /// was downloaded. + /// was downloaded — after dropping any previously retained handle, + /// which was picked for the old format and must not receive the + /// fallback bytes. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_save_handle_picked( &mut self, @@ -3633,6 +3684,13 @@ impl OpenCADStudio { Task::none() } Err(error) => { + // The picker failed and the download fallback is about to + // run with bytes serialized for `ext` — but any handle still + // held was picked for (possibly) the OTHER format (Save As + // to DXF on a .dwg handle, …). Drop it now so the stale + // handle cannot receive the fallback bytes when they land; + // the next save re-picks. + crate::io::fs_access::drop_handle(tab_id); self.command_line.push_error( crate::tf!( "Save destination picker failed: {error}; downloading a copy instead" diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 6a84e1614..24113b8d8 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -466,6 +466,11 @@ impl OpenCADStudio { } #[cfg(target_arch = "wasm32")] Message::PollWebControl => { + // Watchdog first: a parked web `capture` waits for a frame + // that never arrives in a hidden/idle tab, and while it + // waits `control_settle` blocks every later op — expire it + // past its deadline before taking new work. + self.expire_stale_web_capture(); let control = if let Some(request) = super::control::web_request() { self.update(Message::ControlRequest(request)) } else { @@ -574,6 +579,13 @@ impl OpenCADStudio { // control::on_control_capture_frame). #[cfg(target_arch = "wasm32")] Message::ControlCaptureFrame => self.on_control_capture_frame(), + // Web: a frame was presented; refresh the always-warm print + // snapshot if the throttle allows it (see on_print_snapshot_frame). + #[cfg(target_arch = "wasm32")] + Message::PrintSnapshotFrame => { + self.on_print_snapshot_frame(); + Task::none() + } Message::ControlToggle => { self.control.enabled = !self.control.enabled; Task::none() @@ -4327,6 +4339,14 @@ impl OpenCADStudio { } Message::ToggleSnap3dEnabled => { self.snapper.toggle_snap3d(); + // Publish to the scene's solid edge-wire gate: with the 3D + // system off, the 2D edge-wire fallback must come back, or + // solids would be served by NEITHER snap system. The gate + // bakes into cached wires, so re-tessellate every tab. + crate::scene::set_snap3d_enabled(self.snapper.snap3d_enabled); + for tab in &mut self.tabs { + tab.scene.bump_geometry_no_blocks(); + } self.sync_vport_display(self.active_tab); Task::none() } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c5279e5a8..4586bd6e4 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -6943,18 +6943,26 @@ mod selection_preview_tests { ); } - /// With the 3D master off, solid corners are invisible even when the - /// Vertex mode is configured — and the 2D system must not catch them. + /// With the 3D master off, the 3D system serves nothing — so the solid's + /// 2D edge-wire fallback must come back and 2D Endpoint finds the corner. + /// (Before the gate fix the wire stayed suppressed and NEITHER system + /// served the solid.) #[test] - fn box_corner_ignores_3d_master_off() { + fn box_corner_snaps_via_2d_edge_wire_when_3d_master_off() { use acadrust::{entities::Solid3D, EntityType}; use crate::snap::SnapType; let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let i = app.active_tab; + // The master mirror is process-global; hold the toggle guard and + // restore the default before releasing. + let _toggle = crate::scene::snap3d_toggle_guard(); app.snapper.snap_enabled = true; app.snapper.snap3d_enabled = false; + // Mirror the app toggle path: publish the master to the scene gate + // and drop wire caches baked with the old decision. + crate::scene::set_snap3d_enabled(false); app.snapper.enabled3d.insert(SnapType::Vertex); app.snapper.grid_snap_on = false; app.snapper.otrack_enabled = false; @@ -7004,11 +7012,23 @@ mod selection_preview_tests { .unwrap(); let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); - assert!( - app.tabs[i].snap_result.is_none(), - "3D master off must hide solid corners, got {:?}", - app.tabs[i].snap_result.map(|s| (s.snap_type, s.world)) + let hit = app + .tabs[i] + .snap_result + .expect("2D edge wire must serve the corner with the 3D master off"); + assert_eq!( + hit.snap_type, + SnapType::Endpoint, + "expected a 2D endpoint on the box corner, got {:?} at {:?}", + hit.snap_type, + hit.world ); + assert!( + (hit.world - target).length() < 1e-6, + "snap missed the corner: {:?} vs {target:?}", + hit.world + ); + crate::scene::set_snap3d_enabled(true); } /// Face centres snap as their own 3D type at the loop average (exact diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 82e32f892..ac6f4134c 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2603,6 +2603,17 @@ impl OpenCADStudio { }; #[cfg(not(target_arch = "wasm32"))] let control_capture = Subscription::none(); + // Web print snapshot: keep the print-only `` + // (web-app.html's print CSS shows it instead of the canvas) always + // warm — a `beforeprint` readback is either outside the frame (blank: + // wgpu clears the WebGL drawing buffer after composite) or too late + // (async, printing already started). Frames only arrive while the + // window is actually redrawing, so an idle app captures nothing; the + // handler throttles to one snapshot every 2 s while they do flow. + #[cfg(target_arch = "wasm32")] + let print_snapshot = window::frames().map(|_| Message::PrintSnapshotFrame); + #[cfg(not(target_arch = "wasm32"))] + let print_snapshot = Subscription::none(); // Blink the MText preview caret while the editor is open. let caret_blink = if self.mtext_editor.is_some() { iced::time::every(std::time::Duration::from_millis(530)) @@ -2823,6 +2834,7 @@ impl OpenCADStudio { gpu_probe, thumbnail_capture, control_capture, + print_snapshot, caret_blink, web_fonts, autosave, diff --git a/src/io/fs_access.rs b/src/io/fs_access.rs index 4d710df75..15673a842 100644 --- a/src/io/fs_access.rs +++ b/src/io/fs_access.rs @@ -82,6 +82,20 @@ fn exts_match(a: &str, b: &str) -> bool { strip(a) == strip(b) } +/// Whether bytes serialized for `target_ext` may be written through a +/// handle picked for `handle_ext` (`None` = no handle held; a `None` +/// target = the intended format is unknown). This is the completion-half +/// counterpart of `save_route`: routing happens at the confirm gesture, +/// but the bytes arrive later — possibly serialized for a *different* +/// format than the retained handle (a Save As whose re-pick failed falls +/// back to the download while the old handle stays in the registry), and +/// a `.dwg` handle must never receive DXF bytes (or vice versa) just +/// because it is still held. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +pub fn write_or_download(handle_ext: Option<&str>, target_ext: Option<&str>) -> bool { + matches!((handle_ext, target_ext), (Some(handle), Some(target)) if exts_match(handle, target)) +} + /// `showSaveFilePicker` rejects with a `DOMException` named "AbortError" /// when the user dismisses the dialog. That name check is the whole cancel /// classification — kept pure so it compiles and tests on every target. @@ -128,10 +142,25 @@ mod browser { /// not an App field or a Message payload — owns the handles because /// iced requires `Message: Send` even on wasm, and a JsValue-backed /// handle is not Send. (Same pattern as web_recent's OPEN_STORES.) - /// Closing a tab, and a failed write, drop its entry; Save As - /// replaces it. + /// Closing a tab, a failed write, a failed re-pick and a format + /// mismatch drop its entry; Save As replaces it. static SAVE_HANDLES: RefCell> = RefCell::new(HashMap::new()); + + /// The web save currently between `web_save_capture` and its + /// completion (serialized → written/downloaded) — the web + /// counterpart of native's `active_save_jobs` guard, kept here for + /// the same Send reason as the handles. One slot: a second save + /// while one is in flight is refused, not queued (the window is + /// seconds). + static WEB_SAVE_IN_FLIGHT: RefCell> = RefCell::new(None); + + /// The format each tab's in-flight save is being serialized for — + /// the `target_ext` input of `write_or_download`, recorded at + /// capture time because the completion messages cannot carry it + /// (the bytes may outlive several rounds of tab state). + static PENDING_SAVE_EXTS: RefCell> = + RefCell::new(HashMap::new()); } /// The extension the tab's retained handle was picked for, if any — @@ -161,6 +190,49 @@ mod browser { }); } + /// Mark `tab_id`'s save as in flight and remember the format its bytes + /// will be serialized for. Called at `web_save_capture` — the moment a + /// save is committed to a tab and a format — so `on_save_file` can + /// refuse a second concurrent save and the completion half can check + /// the bytes against the retained handle (`write_or_download`). + pub fn begin_web_save(tab_id: u64, ext: &str) { + PENDING_SAVE_EXTS.with(|exts| { + exts.borrow_mut().insert( + tab_id, + ext.trim_start_matches('.').to_ascii_lowercase(), + ); + }); + WEB_SAVE_IN_FLIGHT.with(|in_flight| in_flight.borrow_mut().replace(tab_id)); + } + + /// End `tab_id`'s in-flight save — completion, error or cancel. The + /// single slot is only released when it still names this tab: a newer + /// save that took the slot over must not have its lifecycle cut short. + pub fn end_web_save(tab_id: u64) { + WEB_SAVE_IN_FLIGHT.with(|in_flight| { + let mut slot = in_flight.borrow_mut(); + if *slot == Some(tab_id) { + *slot = None; + } + }); + PENDING_SAVE_EXTS.with(|exts| { + exts.borrow_mut().remove(&tab_id); + }); + } + + /// The tab whose web save is between capture and completion, if any — + /// what the save entry points refuse against while it is set. + pub fn web_save_in_flight() -> Option { + WEB_SAVE_IN_FLIGHT.with(|in_flight| *in_flight.borrow()) + } + + /// Take the format `tab_id`'s serialized bytes were intended for — + /// consumed (once) by the completion handler to decide + /// write-through-handle vs download via `write_or_download`. + pub fn take_pending_save_ext(tab_id: u64) -> Option { + PENDING_SAVE_EXTS.with(|exts| exts.borrow_mut().remove(&tab_id)) + } + /// Feature-detect `showSaveFilePicker` — never assume it: Firefox and /// Safari keep the download flow precisely because this returns false. pub fn supported() -> bool { @@ -314,12 +386,13 @@ mod browser { #[cfg(target_arch = "wasm32")] pub use browser::{ - drop_handle, handle_ext, handle_name, pick_save_handle, supported, write_handle, + begin_web_save, drop_handle, end_web_save, handle_ext, handle_name, pick_save_handle, + supported, take_pending_save_ext, web_save_in_flight, write_handle, }; #[cfg(test)] mod tests { - use super::{is_cancel_error_name, save_route, SaveRoute}; + use super::{is_cancel_error_name, save_route, write_or_download, SaveRoute}; /// Firefox/Safari: no showSaveFilePicker — every save stays a download, /// whatever handle state the tab happens to hold. @@ -374,4 +447,39 @@ mod tests { assert!(!is_cancel_error_name("TypeError")); assert!(!is_cancel_error_name("")); } + + /// The completion-half decision: bytes serialized for the same format + /// the retained handle was picked for may be written through it. + #[test] + fn matching_serialized_ext_writes_in_place() { + assert!(write_or_download(Some("dwg"), Some("dwg"))); + assert!(write_or_download(Some("dxf"), Some("dxf"))); + assert!(write_or_download(Some(".DWG"), Some("dwg"))); + assert!(write_or_download(Some("dxf"), Some(".DXF"))); + } + + /// The BLOCKER scenario: a Save As whose re-pick failed continues as a + /// download, but the OLD handle (picked for the other format) is still + /// in the registry — those bytes must not be written through it. + #[test] + fn stale_handle_of_other_format_downloads() { + assert!(!write_or_download(Some("dwg"), Some("dxf"))); + assert!(!write_or_download(Some("dxf"), Some("dwg"))); + } + + /// No handle held (Firefox/Safari, first save, dropped handle) — the + /// legacy download carries the bytes. + #[test] + fn no_handle_downloads() { + assert!(!write_or_download(None, Some("dwg"))); + assert!(!write_or_download(None, Some("dxf"))); + } + + /// Unknown intended format (no pending ext recorded for the save): an + /// unverifiable handle must not receive the bytes — download. + #[test] + fn unknown_target_ext_downloads() { + assert!(!write_or_download(Some("dwg"), None)); + assert!(!write_or_download(None, None)); + } } diff --git a/src/io/xref.rs b/src/io/xref.rs index 91b36f07f..ce65e957e 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -1180,12 +1180,25 @@ pub fn collect_entries_with_prev( /// The filesystem stat is desktop-only by `cfg` (not a runtime check): the /// wasm binary contains no `std::fs::metadata` call at all, so a web /// session can never block on — or fail from — a host-filesystem probe. - /// Path-based web references resolve through the file-handle loader and - /// simply stay `NotFound` when unresolvable. + /// Path-based web references resolve through the file-handle loader: a + /// source present in that registry reports Loaded here, and only truly + /// unregistered ones stay `NotFound`. fn stat_into(entry: &mut ReferenceEntry, raw: &str, base_dir: &Path) { if raw.is_empty() { return; } + // Web (and the native tests exercising it): `resolve_path` probes the + // filesystem, which the browser does not have — consult the source + // registry instead. A registered drawing IS loaded; its byte length + // comes free from the in-memory bytes (no read), and there is no + // mtime to report. + #[cfg(any(target_arch = "wasm32", test))] + if let Some(key) = find_registered_source(raw) { + entry.size_bytes = registered_bytes(&key).map(|b| b.len() as u64); + entry.found_at = Some(key); + entry.status = RefStatus::Loaded; + return; + } let Some(found) = resolve_path(raw, base_dir) else { return; }; @@ -1672,6 +1685,8 @@ fn bind_dwg( .find(|b| b.handle == br_handle) .map(|b| b.xref_path.clone()) .unwrap_or_default(); + // Native-only path: `resolve_path` reads the real filesystem, so DWG + // bind stays desktop-only (the web has no fs to load a bind source from). let Some(found) = resolve_path(&saved, base_dir) else { return Err(bind_failed(name, "file not found")); }; @@ -1851,6 +1866,8 @@ fn collect_bind_images( let mut planned: HashMap = HashMap::default(); let mut ops: Vec<(Handle, PathBuf, String)> = Vec::new(); for (handle, saved) in &defs { + // Native-only path: `resolve_path` stats the real filesystem (and + // the copy below reads it), so image COLLECT stays desktop-only. let Some(resolved) = resolve_path(saved, source_dir) else { return Err(bind_failed(ref_name, format!("image not found: {saved}"))); }; @@ -2463,6 +2480,27 @@ mod tests { ); } + /// Web XREF panel: a source registered through the file-handle loader is + /// loaded, but `resolve_path` cannot see it (no filesystem on wasm), so + /// the panel used to show NotFound. `stat_into` consults the registry + /// first; the foreign saved path matches by trailing name only. + #[test] + fn collect_reports_registered_source_as_loaded() { + let _registry = registry_guard(); + let name = "Registry-Status-Plan.dwg"; + register_source(name, Arc::new(vec![7; 42])); + let (doc, _) = xref_doc_with("PLAN", &format!(r"E:\gone\{name}")); + let entries = collect_entries_with_prev( + &doc, + std::path::Path::new("."), + &std::collections::HashSet::new(), + &std::collections::HashMap::new(), + ); + assert_eq!(entries[0].status, RefStatus::Loaded); + assert_eq!(entries[0].found_at.as_deref(), Some(name)); + assert_eq!(entries[0].size_bytes, Some(42)); + } + #[test] fn unresolved_xref_names_lists_not_found_entries_only() { let infos = vec![ @@ -2876,4 +2914,346 @@ mod tests { ); assert_eq!(entries[0].status, RefStatus::NotFound); } + + // ── BIND (Task 9) ──────────────────────────────────────────────────── + mod bind_tests { + use super::super::bind_reference; + use super::xref_doc_with; + + fn bind_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "ocs_xref_bind_{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 + } + + fn write_xref_file( + dir: &std::path::Path, + name: &str, + build: impl FnOnce(&mut acadrust::CadDocument), + ) -> String { + let mut doc = acadrust::CadDocument::new(); + build(&mut doc); + let bytes = crate::io::save_to_bytes(&doc, "dwg", doc.version).expect("save xref"); + let path = dir.join(name); + std::fs::write(&path, &bytes).expect("write xref"); + path.to_string_lossy().into_owned() + } + + fn line_on(layer: &str) -> acadrust::EntityType { + let mut line = acadrust::entities::Line::new(); + line.common.layer = layer.to_string(); + acadrust::EntityType::Line(line) + } + + fn add_layer(doc: &mut acadrust::CadDocument, name: &str) { + doc.layers + .add(acadrust::tables::Layer::new(name)) + .expect("add layer"); + } + + #[test] + fn bind_rename_basic() { + let dir = bind_dir("basic"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + add_layer(doc, "WALLS"); + doc.add_entity(line_on("WALLS")).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind"); + assert_eq!(outcome.name, "PLAN"); + assert_eq!(outcome.unremapped, 0); + let br = host.block_records.get("PLAN").unwrap(); + assert!(!br.flags.is_xref && !br.flags.is_xref_overlay); + assert!(br.xref_path.is_empty()); + assert!(host.layers.get("PLAN$0$WALLS").is_some()); + let owned: Vec<_> = host + .entities() + .filter(|e| e.common().owner_handle == br.handle) + .collect(); + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].common().layer, "PLAN$0$WALLS"); + assert!(!host.layers.names().any(|n| n.contains('|'))); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_collision_increments() { + let dir = bind_dir("collision"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + add_layer(doc, "WALLS"); + doc.add_entity(line_on("WALLS")).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + add_layer(&mut host, "PLAN$0$WALLS"); + let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind"); + assert_eq!(outcome.name, "PLAN"); + assert!(host.layers.get("PLAN$1$WALLS").is_some()); + let br = host.block_records.get("PLAN").unwrap(); + let owned: Vec<_> = host + .entities() + .filter(|e| e.common().owner_handle == br.handle) + .collect(); + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].common().layer, "PLAN$1$WALLS"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_transitive_chain() { + let dir = bind_dir("chain"); + let detail_path = write_xref_file(&dir, "detail.dwg", |doc| { + add_layer(doc, "WALLS"); + doc.add_entity(line_on("WALLS")).unwrap(); + }); + let plan_path = write_xref_file(&dir, "plan.dwg", |doc| { + add_layer(doc, "PLANLAYER"); + doc.add_entity(line_on("PLANLAYER")).unwrap(); + let mut br = acadrust::tables::BlockRecord::new("DETAIL"); + br.flags.is_xref = true; + br.xref_path = detail_path.clone(); + doc.block_records.add(br).expect("add nested xref"); + }); + let (mut host, key) = xref_doc_with("PLAN", &plan_path); + let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind"); + assert_eq!(outcome.name, "PLAN"); + assert!(host.layers.get("PLAN$0$DETAIL$0$WALLS").is_some()); + assert!(host.layers.get("PLAN$0$PLANLAYER").is_some()); + let child = host.block_records.get("PLAN$0$DETAIL").expect("bound child block"); + assert!(!child.flags.is_xref && !child.flags.is_xref_overlay); + assert!(!host + .block_records + .iter() + .any(|b| b.flags.is_xref || b.flags.is_xref_overlay)); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_remaps_hatch_dim_block_refs() { + let dir = bind_dir("refs"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + add_layer(doc, "WALLS"); + // Nested block with content + a model-space INSERT of it. + let mut door = acadrust::tables::BlockRecord::new("DOOR"); + door.handle = doc.allocate_handle(); + let door_h = door.handle; + doc.block_records.add(door).expect("add door"); + let mut inner = acadrust::entities::Line::new(); + inner.common.layer = "WALLS".to_string(); + inner.common.owner_handle = door_h; + doc.add_entity(acadrust::EntityType::Line(inner)).unwrap(); + let ins = acadrust::entities::Insert::new( + "DOOR", + acadrust::types::Vector3::ZERO, + ); + doc.add_entity(acadrust::EntityType::Insert(ins)).unwrap(); + // Hatch (pattern names have no table — preserved verbatim). + let mut hatch = acadrust::entities::Hatch::new(); + hatch.pattern.name = "ANSI31".to_string(); + doc.add_entity(acadrust::EntityType::Hatch(hatch)).unwrap(); + // Dim-style reference via a tolerance entity. + doc.dim_styles + .add(acadrust::tables::DimStyle::new("ARCH")) + .expect("add dimstyle"); + let mut tol = acadrust::entities::Tolerance::new(); + tol.dimension_style_name = "ARCH".to_string(); + doc.add_entity(acadrust::EntityType::Tolerance(tol)).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + bind_reference(&mut host, key, &dir, &dir).expect("bind"); + let door = host.block_records.get("PLAN$0$DOOR").expect("bound block"); + assert!(!door.flags.is_xref); + let inserts: Vec<_> = host + .entities() + .filter_map(|e| match e { + acadrust::EntityType::Insert(i) => Some(i.block_name.clone()), + _ => None, + }) + .collect(); + assert!(inserts.iter().any(|n| n == "PLAN$0$DOOR"), "{inserts:?}"); + let door_lines: Vec<_> = host + .entities() + .filter(|e| e.common().owner_handle == door.handle) + .collect(); + assert_eq!(door_lines.len(), 1); + assert_eq!(door_lines[0].common().layer, "PLAN$0$WALLS"); + let br = host.block_records.get("PLAN").unwrap(); + let hatches: Vec<_> = host + .entities() + .filter(|e| { + e.common().owner_handle == br.handle + && matches!(e, acadrust::EntityType::Hatch(_)) + }) + .collect(); + assert_eq!(hatches.len(), 1); + assert!(host.dim_styles.get("PLAN$0$ARCH").is_some()); + let tols: Vec<_> = host + .entities() + .filter_map(|e| match e { + acadrust::EntityType::Tolerance(t) => Some(t.dimension_style_name.clone()), + _ => None, + }) + .collect(); + assert_eq!(tols, vec!["PLAN$0$ARCH".to_string()]); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_remaps_text_styles_and_counts_unmapped() { + let dir = bind_dir("styles"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + doc.text_styles + .add(acadrust::tables::TextStyle::new("NOTE")) + .expect("add text style"); + let mut text = acadrust::entities::Text::new(); + text.style = "NOTE".to_string(); + doc.add_entity(acadrust::EntityType::Text(text)).unwrap(); + // Two foreign handles that cannot be remapped (plotstyle_flags + // set as a real writer would — otherwise the round-trip + // drops the handle). + let mut line = acadrust::entities::Line::new(); + line.common.plotstyle_flags = 0b11; + line.common.plotstyle_handle = Some(doc.allocate_handle()); + doc.add_entity(acadrust::EntityType::Line(line)).unwrap(); + let mut mline = acadrust::entities::MLine::new(); + mline.style_name = "STD".to_string(); + mline.style_handle = Some(doc.allocate_handle()); + doc.add_entity(acadrust::EntityType::MLine(mline)).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind"); + assert_eq!(outcome.unremapped, 2); + assert!(host.text_styles.get("PLAN$0$NOTE").is_some()); + let styles: Vec<_> = host + .entities() + .filter_map(|e| match e { + acadrust::EntityType::Text(t) => Some(t.style.clone()), + _ => None, + }) + .collect(); + assert_eq!(styles, vec!["PLAN$0$NOTE".to_string()]); + let br = host.block_records.get("PLAN").unwrap(); + let lines: Vec<_> = host + .entities() + .filter(|e| { + e.common().owner_handle == br.handle + && matches!(e, acadrust::EntityType::Line(_)) + }) + .collect(); + assert_eq!(lines.len(), 1); + assert!(lines[0].common().plotstyle_handle.is_none()); + let mlines: Vec<_> = host + .entities() + .filter_map(|e| match e { + acadrust::EntityType::MLine(m) => Some(m.style_handle), + _ => None, + }) + .collect(); + assert_eq!(mlines, vec![None]); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_image_collect_copies_file() { + use acadrust::objects::{ImageDefinition, ObjectType}; + let src_dir = bind_dir("imgsrc"); + let host_dir = bind_dir("imghost"); + std::fs::write(src_dir.join("pixel.png"), b"fakepng").unwrap(); + let xref_path = write_xref_file(&src_dir, "plan.dwg", |doc| { + let h = doc.allocate_handle(); + let mut def = ImageDefinition::with_dimensions("pixel.png", 8, 8); + def.handle = h; + doc.objects.insert(h, ObjectType::ImageDefinition(def)); + let mut img = acadrust::entities::RasterImage::new( + "pixel.png", + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + img.definition_handle = Some(h); + doc.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + bind_reference(&mut host, key, &src_dir, &host_dir).expect("bind"); + let copied = host_dir.join("pixel.png"); + assert!(copied.exists()); + assert_eq!(std::fs::read(&copied).unwrap(), b"fakepng"); + let defs: Vec<_> = host + .objects + .values() + .filter_map(|o| match o { + ObjectType::ImageDefinition(d) => Some(d.file_name.clone()), + _ => None, + }) + .collect(); + assert_eq!(defs, vec!["pixel.png".to_string()]); + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&host_dir).ok(); + } + + #[test] + fn bind_image_missing_errors_without_partial_state() { + use acadrust::objects::{ImageDefinition, ObjectType}; + let dir = bind_dir("imgmissing"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + let h = doc.allocate_handle(); + let mut def = ImageDefinition::with_dimensions("ghost.png", 8, 8); + def.handle = h; + doc.objects.insert(h, ObjectType::ImageDefinition(def)); + let mut img = acadrust::entities::RasterImage::new( + "ghost.png", + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + img.definition_handle = Some(h); + doc.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + let err = bind_reference(&mut host, key, &dir, &dir).unwrap_err(); + assert!(err.contains("ghost.png"), "got: {err:?}"); + // No partial state: the definition is untouched. + let br = host.block_records.get("PLAN").unwrap(); + assert!(br.flags.is_xref); + assert_eq!(br.xref_path, xref_path); + assert!(!host.layers.names().any(|n| n.contains("PLAN$0"))); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_pdf_errors() { + use acadrust::objects::{ObjectType, UnderlayDefinition}; + let mut host = acadrust::CadDocument::new(); + let h = host.allocate_handle(); + let mut def = UnderlayDefinition::pdf("doc.pdf", "1"); + def.handle = h; + host.objects.insert(h, ObjectType::UnderlayDefinition(def)); + let err = + bind_reference(&mut host, h.value(), std::path::Path::new("."), std::path::Path::new(".")) + .unwrap_err(); + assert_eq!( + err, + "XREF: PDF bind (vector import) is not available in this version." + ); + } + + #[test] + fn bind_unknown_key_errors() { + let (mut host, _) = xref_doc_with("PLAN", "missing.dwg"); + let err = bind_reference( + &mut host, + 0xDEAD_BEEF, + std::path::Path::new("."), + std::path::Path::new("."), + ) + .unwrap_err(); + assert_eq!(err, "XREF: no loaded reference with that key."); + } + } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index cffd57aa4..338559b1b 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -69,6 +69,40 @@ pub(crate) use convert::tess::{ entity_aabb, entity_world_aabb_f64, is_unindexable_entity, tessellate_entity_dim_text, }; +/// Mirror of the app snapper's F4 3D object-snap master (`snap3d_enabled`). +/// +/// The snapper lives on the app while the solid edge-wire gate in +/// [`tessellate_entity`] runs deep inside wire assembly, below any app +/// state — so the toggle is published here, mirroring how `sdf_atlas` +/// holds TEXTFILL. The gate's decision is baked into tessellated wire +/// sets, so a flip must also invalidate cached wires +/// (`Scene::bump_geometry_no_blocks`); the app's toggle path does that. +static SNAP3D_ENABLED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(true); + +/// Whether the separate 3D object-snap system (F4) is currently enabled. +pub(crate) fn snap3d_enabled() -> bool { + SNAP3D_ENABLED.load(std::sync::atomic::Ordering::Relaxed) +} + +/// Publish the F4 3D object-snap master for the scene's solid edge-wire +/// gate. Callers must invalidate cached scene wires afterwards. +pub fn set_snap3d_enabled(on: bool) { + SNAP3D_ENABLED.store(on, std::sync::atomic::Ordering::Relaxed); +} + +/// Tests only: the master mirror is process-global and the default harness +/// runs tests in parallel, so a test that flips it holds this lock for its +/// duration (same pattern as the xref source-registry guard) and restores +/// the default before releasing. +#[cfg(test)] +pub(crate) fn snap3d_toggle_guard() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + /// Keep construction-history curves in the same per-entity tessellation memo /// as their parent, so navigation never rebuilds them on every frame. #[allow(clippy::too_many_arguments)] @@ -145,17 +179,21 @@ pub(crate) fn tessellate_entity( // treat it exactly like any 2-D entity's geometry. // // EXCEPT kernel B-rep solids: when the mesh cache carries real - // `geometry_edges`, the separate 3D object-snap system (F4 master + - // `enabled3d`, via `attach_solid_snaps`) already provides the - // corners and edge midpoints, and those must stay invisible to the - // 2D modes. The 2D edge wire is the FALLBACK for solids the 3D - // system cannot serve (e.g. an OBJ import's triangle-only cache). - let three_d_covered = matches!( - entity, - EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) - ) && volume_meshes - .and_then(|meshes| meshes.get(&handle)) - .is_some_and(|set| !set.geometry_edges().0.is_empty()); + // `geometry_edges` AND the separate 3D object-snap system (F4 master + // + `enabled3d`, via `attach_solid_snaps`) is actually enabled, that + // system already provides the corners and edge midpoints, and those + // must stay invisible to the 2D modes. The 2D edge wire is the + // FALLBACK whenever the 3D system cannot serve the solid — a + // triangle-only cache (e.g. an OBJ import), or the F4 master off, + // which would otherwise leave NEITHER system serving the solid. + let three_d_covered = snap3d_enabled() + && matches!( + entity, + EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) + ) + && volume_meshes + .and_then(|meshes| meshes.get(&handle)) + .is_some_and(|set| !set.geometry_edges().0.is_empty()); if !three_d_covered { let stored = match entity { EntityType::Solid3D(solid) => solid.wires.as_slice(), @@ -10358,6 +10396,10 @@ impl Scene { mix(channel.to_bits() as u64); } mix(avp.map(|h| h.value()).unwrap_or(0)); + // The solid edge-wire gate bakes the F4 3D master into every + // wire set; a toggle must re-tessellate (the app's bump also + // drops the outer caches — this covers the per-entity memo). + mix(snap3d_enabled() as u64); // SDF glyph quads bake the atlas UV of each tile, so a growth or // a re-bake (which rescale / rewind every UV) makes memoized text // address the wrong tile — garbage on screen, and a silent miss in diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 5d07cfed6..d36cafde8 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -466,6 +466,18 @@ pub fn register_source(path: &str, bytes: Arc>) { } } +/// Is a source registered under exactly this reference? The web paste path +/// uses it to keep its generated `pasted-…` names unique: the registry is +/// last-wins, so a reused name would silently re-point every earlier paste's +/// entity at the newest pixels. Exact key match — generated names embed their +/// own extension and are all lower-case digits. +pub fn has_source(path: &str) -> bool { + source_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .contains_key(path) +} + /// Every reference with bytes currently registered (picked or dropped). /// Used after a web open settles to catch images registered while the load — /// which builds the raster models — was already running. diff --git a/src/snap.rs b/src/snap.rs index a10c007b8..c3345345e 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -2873,6 +2873,35 @@ fn line_endpoint_map(wire: &WireModel) -> HashMap { let key = line_geom_key(a.as_vec3().to_array(), b.as_vec3().to_array()); map.entry(key).or_insert((a, b)); } + // A closed analytic polyline stores its vertices UNREPEATED — the closing + // segment runs from the last key vertex back to the first — so the pair + // its Line geom looks up is the one `windows(2)` never yields; without it + // the closing segment falls back to its f32-widened endpoints, which + // drift by centimetres at survey scale. The wire records closure nowhere + // else, so detect it by that very geom (matched on the same 1-ulp grid + // the lookup verifies with). Open vertex lists (block entries, center + // marks) carry no last→first geom and gain no entry, and a hit is still + // verified against the geom's own f32 endpoints at lookup. + if wire.key_vertices.len() >= 2 { + let (first, last) = ( + DVec3::from_array(wire.key_vertices[0]), + DVec3::from_array(wire.key_vertices[wire.key_vertices.len() - 1]), + ); + let near = |v: DVec3, p: [f32; 3]| { + let ulp = |x: f32, y: f32| (x - y).abs() <= y.abs().max(1.0) * f32::EPSILON; + ulp(v.x as f32, p[0]) && ulp(v.y as f32, p[1]) && ulp(v.z as f32, p[2]) + }; + let is_closing = |geom: &TangentGeom| match geom { + TangentGeom::Line { p1, p2 } => near(last, *p1) && near(first, *p2), + _ => false, + }; + if (first.is_finite() && last.is_finite()) + && wire.tangent_geoms.iter().any(is_closing) + { + let key = line_geom_key(last.as_vec3().to_array(), first.as_vec3().to_array()); + map.entry(key).or_insert((last, first)); + } + } map } @@ -4937,6 +4966,71 @@ mod ext_tests { ); } + /// The 2D solid edge wire is suppressed ONLY while the separate 3D + /// object-snap system can actually serve the solid. With feature edges + /// cached and the F4 master ON, the wire stays out (the 3D system owns + /// the corners and edge midpoints); switching the master OFF must bring + /// the wire back — otherwise NEITHER system serves the solid. + #[test] + fn solid_edge_wire_suppressed_only_while_3d_system_enabled() { + use crate::scene::Scene; + use acadrust::entities::Solid3D; + + // The master mirror is process-global; hold the toggle guard and + // restore the default before releasing. + let _toggle = crate::scene::snap3d_toggle_guard(); + crate::scene::set_snap3d_enabled(true); + + let mut scene = Scene::new(); + let handle = scene.add_entity(acadrust::EntityType::Solid3D(Solid3D::new())); + // Feature edges in the display cache: the case the F4 system covers + // when it is enabled. + let mut set = square_mesh_lod_set(&handle.value().to_string()); + set.edge_verts = vec![ + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [10.0, 10.0, 0.0], + [10.0, 10.0, 0.0], + [0.0, 10.0, 0.0], + [0.0, 10.0, 0.0], + [0.0, 0.0, 0.0], + ]; + scene.meshes.insert(handle, set); + let name = handle.value().to_string(); + let has_edge_wire = + |wires: &[WireModel]| wires.iter().any(|w| w.name == name && !w.points.is_empty()); + + // 3D system enabled (the default): the 2D edge wire stays out. + assert!( + !has_edge_wire(&scene.hit_test_wires()), + "the 3D system covers this solid; the 2D edge wire must not duplicate it" + ); + + // F4 off — published and invalidated exactly as the app toggle does: + // the 3D system never fires, so the 2D fallback must return. + crate::scene::set_snap3d_enabled(false); + scene.bump_geometry_no_blocks(); + let wires_off = scene.hit_test_wires(); + crate::scene::set_snap3d_enabled(true); + assert!( + has_edge_wire(&wires_off), + "with the 3D system off neither system serves the solid; the 2D edge wire must come back" + ); + + // And it serves 2D Endpoint snap at a feature-edge vertex. + let owned = wires_off.as_ref().clone(); + let mut snapper = Snapper::default(); + snapper.enabled = [SnapType::Endpoint].into_iter().collect(); + snap_one( + &mut snapper, + &owned, + DVec3::new(0.1, 0.2, 0.0), + SnapType::Endpoint, + DVec3::new(0.0, 0.0, 0.0), + ); + } + #[test] fn closest_point_on_triangle_2d_handles_inside_edge_and_vertex() { let (a, b, c) = ([0.0f32, 0.0], [10.0, 0.0], [0.0, 10.0]); @@ -5300,12 +5394,20 @@ mod ext_tests { ..Default::default() }; - // Centred on the closing edge x = ox, so both crossings sit on it. - let pts = exact_curve_intersections(&square, &xy_circle(ox, oy + 5.0, 1.0)) + // Centred ON the closing edge y = oy (the last→first segment, which + // `windows(2)` never indexes), so BOTH crossings sit on it; the + // f32-widened fallback puts them ~10 cm off in y at this scale. + let pts = exact_curve_intersections(&square, &xy_circle(ox + 5.0, oy, 1.0)) .expect("the circle crosses the closing edge"); assert_eq!(pts.len(), 2, "{pts:?}"); - for p in &pts { - assert!((p.x - ox).abs() < 1e-9, "closing edge fell back to f32 endpoints: {p:?}"); - } + pts.iter().for_each(|p| { + assert!((p.y - oy).abs() < 1e-9, "closing edge fell back to f32 endpoints: {p:?}"); + }); + pts.iter().for_each(|p| { + assert!( + ((p.x - (ox + 4.0)).abs() < 1e-9 || (p.x - (ox + 6.0)).abs() < 1e-9), + "unexpected crossing: {p:?}" + ); + }); } } diff --git a/src/sys.rs b/src/sys.rs index 26fa38e92..abf3679e8 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -228,6 +228,66 @@ pub fn capture_canvas() -> Option { )) } +/// Web: is the page visible? `requestAnimationFrame` — and therefore the +/// frames subscription and any canvas readback — never runs while +/// `document.hidden`, so the print-snapshot warmer skips hidden pages and +/// the control `capture` op refuses to park work that could never finish. +#[cfg(target_arch = "wasm32")] +pub fn page_visible() -> bool { + !web_sys::window() + .and_then(|window| window.document()) + .is_some_and(|document| document.hidden()) +} + +/// Web: refresh the print-only snapshot `` (the +/// web-app.html print CSS shows it instead of the canvas) from the canvas. +/// +/// The refresh must run inside the frame task, before the browser composites +/// and clears the WebGL drawing buffer (wgpu runs without +/// `preserveDrawingBuffer`) — which is why this hangs off the frames +/// subscription rather than a `beforeprint` listener: by the time +/// `beforeprint` fires, a synchronous readback happens outside the frame +/// (blank) and an async one lands after printing has started. The img is +/// therefore kept ALWAYS WARM — refreshed while frames flow — so the +/// printout already sits in the DOM before any `beforeprint` event. +/// +/// Cost: one full-canvas PNG encode + base64 data URL per refresh, the same +/// payload the old `beforeprint` listener produced per print. There is no +/// render-generation counter to skip unchanged frames, so the throttle is +/// purely time-based (2 s, in the caller); an idle app presents no frames at +/// all and captures nothing. +#[cfg(target_arch = "wasm32")] +pub fn refresh_print_snapshot() { + let Some(screenshot) = capture_canvas() else { + return; + }; + let Some(image) = image::RgbaImage::from_raw( + screenshot.size.width, + screenshot.size.height, + screenshot.rgba.to_vec(), + ) else { + return; + }; + let mut png = Vec::new(); + if image + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .is_err() + { + return; + } + use base64::Engine as _; + let url = format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(&png), + ); + if let Some(img) = web_sys::window() + .and_then(|window| window.document()) + .and_then(|document| document.get_element_by_id("print-snapshot")) + { + let _ = img.set_attribute("src", &url); + } +} + /// Web: read text from the system clipboard via the async Clipboard API. /// iced's own `clipboard::read` is a no-op on the web (the browser clipboard is /// async + permission-gated), so the editor paste paths use this instead. The @@ -271,7 +331,8 @@ fn clipboard_image_extensions_cover_the_paste_flavors() { /// Web: read an image from the system clipboard via the async Clipboard API /// (`navigator.clipboard.read()`), used when a paste found no text. Returns -/// the registry name (`pasted-YYYYMMDD-HHMMSS.`) plus the image bytes, +/// the registry name (`pasted-YYYYMMDD-HHMMSS-mmm[-N].`, unique against +/// the registry) plus the image bytes, /// or `None` when the clipboard holds no image item or the read is denied. /// Like `read_clipboard_text`, the Ctrl+V keypress that reaches it is a user /// gesture, which is what makes the permission-gated read allowed at all. @@ -311,29 +372,103 @@ pub async fn read_clipboard_image() -> Option<(String, Vec)> { if bytes.is_empty() { return None; } - return Some((pasted_image_name(&mime_type), bytes)); + let name = pasted_image_name(&mime_type); + // Register under the chosen name BEFORE returning: no other wasm + // code can run between the uniqueness check and this insert (one + // thread), so a second paste landing in the same millisecond sees + // this name as taken and takes a `-2` suffix instead of overwriting + // these bytes. The paste handler's own `register_source` call with + // the same name is then an idempotent overwrite of identical bytes. + crate::scene::model::image_model::register_source( + &name, + std::sync::Arc::new(bytes.clone()), + ); + return Some((name, bytes)); } None } -/// Registry name for a pasted clipboard image: `pasted-YYYYMMDD-HHMMSS.ext` -/// stamped from the wall clock (local time, the same clock a screenshot file -/// name uses) with the extension derived from the MIME type. +/// Registry name for a pasted clipboard image: +/// `pasted-YYYYMMDD-HHMMSS-mmm.ext` stamped from the wall clock (local time, +/// the same clock a screenshot file name uses) at millisecond precision, with +/// the extension derived from the MIME type. Milliseconds alone don't +/// guarantee uniqueness (two pastes inside one ms), so the name is pushed +/// through [`unique_pasted_image_name`] against the live registry — a reused +/// key would overwrite the earlier paste's bytes (the registry is last-wins) +/// and make its entity display the newer pixels. #[cfg(target_arch = "wasm32")] fn pasted_image_name(mime_type: &str) -> String { - let now = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(0.0)); - format!( - "pasted-{:04}{:02}{:02}-{:02}{:02}{:02}.{}", - now.get_full_year(), - now.get_month() + 1, - now.get_date(), - now.get_hours(), - now.get_minutes(), - now.get_seconds(), - clipboard_image_extension(mime_type) + let now = js_sys::Date::new_0(); + unique_pasted_image_name( + &format!( + "pasted-{:04}{:02}{:02}-{:02}{:02}{:02}-{:03}", + now.get_full_year(), + now.get_month() + 1, + now.get_date(), + now.get_hours(), + now.get_minutes(), + now.get_seconds(), + (now.get_time() as u64) % 1000, + ), + clipboard_image_extension(mime_type), + crate::scene::model::image_model::has_source, ) } +/// Turn `base` + `ext` into a registry name not taken yet: the bare +/// `base.ext` first, then `-2`, `-3`, … inserted before the extension while +/// the candidate already names a registered source. The taken-check is +/// injectable so this — the part worth locking with tests — runs natively; +/// wasm passes [`crate::scene::model::image_model::has_source`]. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // tested everywhere, used on wasm +pub(crate) fn unique_pasted_image_name( + base: &str, + ext: &str, + taken: impl Fn(&str) -> bool, +) -> String { + let first = format!("{base}.{ext}"); + if !taken(&first) { + return first; + } + for suffix in 2u64.. { + let candidate = format!("{base}-{suffix}.{ext}"); + if !taken(&candidate) { + return candidate; + } + } + unreachable!("an unbounded suffix space always yields a free name") +} + +#[test] +fn pasted_image_names_never_reuse_a_taken_registry_key() { + let mut taken: Vec = Vec::new(); + let claim = |taken: &mut Vec| { + let name = unique_pasted_image_name("pasted-20260915-101500-123", "png", |candidate| { + taken.iter().any(|used| used == candidate) + }); + taken.push(name.clone()); + name + }; + // Fresh registry: the bare millisecond-stamped name is used as-is. + assert_eq!(claim(&mut taken), "pasted-20260915-101500-123.png"); + // A second paste inside the same millisecond suffixes instead of + // overwriting — each name below must differ from every earlier one. + assert_eq!(claim(&mut taken), "pasted-20260915-101500-123-2.png"); + assert_eq!(claim(&mut taken), "pasted-20260915-101500-123-3.png"); + // The extension is part of the key: a jpg landing in the same + // millisecond does not collide with the pngs above. + assert_eq!( + unique_pasted_image_name("pasted-20260915-101500-123", "jpg", |candidate| { + taken.iter().any(|used| used == candidate) + }), + "pasted-20260915-101500-123.jpg" + ); + // Freed suffixes are reused in order: with `-2` unregistered again, the + // next paste takes `-2` back before walking further. + taken.remove(1); + assert_eq!(claim(&mut taken), "pasted-20260915-101500-123-2.png"); +} + #[cfg(target_arch = "wasm32")] #[wasm_bindgen::prelude::wasm_bindgen(module = "/web/clipboard.js")] extern "C" { diff --git a/web-app.html b/web-app.html index 571446372..a46c92aec 100644 --- a/web-app.html +++ b/web-app.html @@ -39,9 +39,13 @@ } @keyframes ocs-spin { to { transform: rotate(360deg); } } /* Browser-menu print support: hidden on screen, the only thing shown on - paper. beforeprint snapshots the WebGL canvas into it (below), so the + paper. The Rust side keeps the img synchronized with the WebGL canvas + via a frame-callback readback (sys::refresh_print_snapshot), so the printout is the drawing instead of a blank page — WebGL canvases do - not composite into the print surface on their own. */ + not composite into the print surface on their own. A `beforeprint` + listener cannot do this: wgpu runs without preserveDrawingBuffer, so + by then the drawing buffer is already cleared after composite, and + an async capture inside beforeprint finishes after printing started. */ #print-snapshot { display: none; } @media print { body > *:not(#print-snapshot) { display: none !important; } @@ -66,7 +70,12 @@
Open CAD Studio
Loading…
- + -