fix: close the adversarial findings on the save/paste/merge wave

Three reviewers over the in-place save, the paste/print/capture
bundle, and the upstream merge: two blockers, six majors — all fixed.

In-place save (blocker + 2 majors):
- A stale retained handle could receive bytes serialized for a
  DIFFERENT format (Save As .dwg->.dxf whose re-pick failed wrote DXF
  bytes through the .dwg handle) or silently save in place while the
  user was told a copy was downloading. The serialized format now
  travels to completion (tab-keyed pending map), the write requires a
  matching handle extension AND a live tab, and a failed re-pick
  drops the old handle before the download fallback.
- Discarding an unsaved tab leaked its handle, and a serialization
  still in flight could then overwrite the real file the user just
  declined to save. Discard drops the handle; a tab-gone completion
  downloads instead of writing.
- Overlapping saves had no guard (native has one): a wasm in-flight
  slot refuses a second save with the same localized message, cleared
  on every exit path.

Paste/print/capture (blocker + 2 majors):
- pasted_image_name built its timestamp from Date::new(0) — a
  CONSTANT name, so every paste overwrote every earlier paste's
  image (the registry is last-wins; the first entity then displayed
  the second paste's pixels). Real clock, milliseconds, and a
  collision-suffixed unique name now, with the pure logic tested.
- The beforeprint snapshot read the canvas outside the frame — blank
  on WebGL2 (no preserveDrawingBuffer). The print image is now kept
  WARM: refreshed on redraw frames (throttled to 2 s, skipped when
  hidden), so it is ready before the browser ever prints.
- A capture op in a hidden tab wedged the ENTIRE automation channel
  (frames never fire, the pending op blocked every settle). Intake
  now fails fast when hidden, and a 5 s watchdog expires a parked
  capture with a clear error.

Merge residue (3 majors):
- The 9 upstream XREF-bind tests lost in resolution are restored
  verbatim (the bind feature shipped untested).
- The XREF manager panel reported registry-loaded xrefs as NotFound
  on web — stat now consults the registry first (wasm), with a
  regression test.
- Snap: our endpoint map gained the closing-segment wraparound pair
  (survey-scale closed polylines kept cm-level precision on the last
  edge), and the 3D-snap gate no longer suppresses the 2D edge wire
  when the F4 system is disabled (solid edges were snappable by
  NEITHER system); the setting is mirrored to the scene with
  invalidation, and the test that encoded the old bug now pins the
  fallback.
This commit is contained in:
Afonsop committed 2026-09-21 15:23:37 +01:00
1 parent 55e3a6a9e2
commit da9504604e
15 files changed
+1071 -84

No files matched your search

+47
View File
@@ -120,6 +120,14 @@ pub(super) struct State {
/// screenshot, which cannot run on wasm.
#[cfg(target_arch = "wasm32")]
pub(super) pending_capture: Option<String>,
/// 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<iced::time::Instant>,
}
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<Message> {
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,
+8
View File
@@ -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;
+37
View File
@@ -798,6 +798,11 @@ pub(super) struct OpenCADStudio {
main_window: Option<window::Id>,
/// Hides drawing UI overlays for one thumbnail capture frame.
thumbnail_capture_clean: bool,
/// Web: when the print-only snapshot `<img>` 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<Instant>,
#[cfg(not(target_arch = "wasm32"))]
pending_native_thumbnail_save: Option<PendingNativeThumbnailSave>,
#[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 `<img>` 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 `<img>` 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(
+23
View File
@@ -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 {
+69 -11
View File
@@ -2396,6 +2396,9 @@ impl OpenCADStudio {
) -> Task<Message> {
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<u8>,
) -> Task<Message> {
// 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<Message> {
// 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"
+20
View File
@@ -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()
}
+27 -7
View File
@@ -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
+12
View File
@@ -2603,6 +2603,17 @@ impl OpenCADStudio {
};
#[cfg(not(target_arch = "wasm32"))]
let control_capture = Subscription::none();
// Web print snapshot: keep the print-only `<img id="print-snapshot">`
// (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,
+112 -4
View File
@@ -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<HashMap<u64, SaveHandle>> =
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<Option<u64>> = 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<HashMap<u64, String>> =
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<u64> {
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<String> {
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));
}
}
+382 -2
View File
@@ -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<String, String> = 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.");
}
}
}
+53 -11
View File
@@ -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::Mutex<()>> = 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
+12
View File
@@ -466,6 +466,18 @@ pub fn register_source(path: &str, bytes: Arc<Vec<u8>>) {
}
}
/// 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.
+107 -5
View File
@@ -2873,6 +2873,35 @@ fn line_endpoint_map(wire: &WireModel) -> HashMap<LineGeomKey, (DVec3, DVec3)> {
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:?}"
);
});
}
}
+150 -15
View File
@@ -228,6 +228,66 @@ pub fn capture_canvas() -> Option<iced::window::Screenshot> {
))
}
/// 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 `<img id="print-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.<ext>`) plus the image bytes,
/// the registry name (`pasted-YYYYMMDD-HHMMSS-mmm[-N].<ext>`, 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<u8>)> {
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<String> = Vec::new();
let claim = |taken: &mut Vec<String>| {
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" {
+12 -29
View File
@@ -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 @@
<div class="title">Open <span>CAD</span> Studio</div>
<div class="sub">Loading…</div>
</div>
<!-- Print-only copy of the canvas; filled by beforeprint below. -->
<!-- Print-only copy of the canvas, kept ALWAYS WARM by the Rust side
(sys::refresh_print_snapshot runs off the frames subscription,
throttled to ~2 s): the snapshot must exist BEFORE beforeprint
fires, because the WebGL drawing buffer is cleared after each
composite and cannot be read from a beforeprint listener. No JS
here on purpose; the print CSS above does the rest. -->
<img id="print-snapshot" alt="" />
<script src="ocs-control.js" defer></script>
<script>
@@ -84,32 +93,6 @@
});
})();
</script>
<script>
// Print support. The drawing lives in a WebGL canvas, which browsers
// rasterize as blank on the print surface. beforeprint therefore copies
// it onto a 2D canvas (the same drawImage readback the Rust side's
// sys::capture_canvas uses — no Rust state involved, the canvas is
// located from the DOM) and shows the snapshot in the print-only
// <img>; afterprint drops it again to free the PNG.
(() => {
const snapshot = document.getElementById("print-snapshot");
if (!snapshot) return;
window.addEventListener("beforeprint", () => {
const canvas = document.querySelector("canvas");
if (!canvas || !canvas.width || !canvas.height) return;
const copy = document.createElement("canvas");
copy.width = canvas.width;
copy.height = canvas.height;
const context = copy.getContext("2d");
if (!context) return;
context.drawImage(canvas, 0, 0);
snapshot.src = copy.toDataURL("image/png");
});
window.addEventListener("afterprint", () => {
snapshot.removeAttribute("src");
});
})();
</script>
<script>
(() => {
const splash = document.getElementById("loading");