forked from 1b-agent/opencadstudio-upstream
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:
1 parent
55e3a6a9e2
commit
da9504604e
15 files changed
+1071
-84
No files matched your search
@@ -120,6 +120,14 @@ pub(super) struct State {
|
|||||||
/// screenshot, which cannot run on wasm.
|
/// screenshot, which cannot run on wasm.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub(super) pending_capture: Option<String>,
|
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 {
|
impl State {
|
||||||
pub(super) fn new() -> Self {
|
pub(super) fn new() -> Self {
|
||||||
@@ -956,7 +964,21 @@ impl OpenCADStudio {
|
|||||||
// until ControlScreenshot lands from that frame.
|
// until ControlScreenshot lands from that frame.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[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.pending_capture = Some(path);
|
||||||
|
self.control.capture_deadline = Some(
|
||||||
|
iced::time::Instant::now() + std::time::Duration::from_secs(5),
|
||||||
|
);
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -1176,6 +1198,7 @@ impl OpenCADStudio {
|
|||||||
/// canvas in — one frame after the request, before the buffer clears.
|
/// canvas in — one frame after the request, before the buffer clears.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub(super) fn on_control_capture_frame(&mut self) -> Task<Message> {
|
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() {
|
if let Some(path) = self.control.pending_capture.take() {
|
||||||
let screenshot = crate::sys::capture_canvas();
|
let screenshot = crate::sys::capture_canvas();
|
||||||
self.control_screenshot(path, screenshot);
|
self.control_screenshot(path, screenshot);
|
||||||
@@ -1184,6 +1207,30 @@ impl OpenCADStudio {
|
|||||||
Task::none()
|
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(
|
pub(super) fn control_screenshot(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: String,
|
path: String,
|
||||||
|
|||||||
@@ -100,6 +100,14 @@ impl OpenCADStudio {
|
|||||||
self.snapper.enabled = state.snap_modes.clone();
|
self.snapper.enabled = state.snap_modes.clone();
|
||||||
self.snapper.snap3d_enabled = state.osnap3d_on;
|
self.snapper.snap3d_enabled = state.osnap3d_on;
|
||||||
self.snapper.enabled3d = state.snap3d_modes.clone();
|
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.dyn_input = state.dyn_input_on;
|
||||||
self.quick_properties = state.quick_props_on;
|
self.quick_properties = state.quick_props_on;
|
||||||
self.selection_cycling = state.selection_cycling_on;
|
self.selection_cycling = state.selection_cycling_on;
|
||||||
|
|||||||
@@ -798,6 +798,11 @@ pub(super) struct OpenCADStudio {
|
|||||||
main_window: Option<window::Id>,
|
main_window: Option<window::Id>,
|
||||||
/// Hides drawing UI overlays for one thumbnail capture frame.
|
/// Hides drawing UI overlays for one thumbnail capture frame.
|
||||||
thumbnail_capture_clean: bool,
|
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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pending_native_thumbnail_save: Option<PendingNativeThumbnailSave>,
|
pending_native_thumbnail_save: Option<PendingNativeThumbnailSave>,
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
@@ -2099,6 +2104,12 @@ pub enum Message {
|
|||||||
/// directly and never emits this.
|
/// directly and never emits this.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
ControlCaptureFrame,
|
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,
|
ControlToggle,
|
||||||
Tick(Instant),
|
Tick(Instant),
|
||||||
/// Periodic drain of plugin-to-host requests that arrived outside a host
|
/// Periodic drain of plugin-to-host requests that arrived outside a host
|
||||||
@@ -4264,6 +4275,8 @@ impl OpenCADStudio {
|
|||||||
pending_click_snap: None,
|
pending_click_snap: None,
|
||||||
main_window: None,
|
main_window: None,
|
||||||
thumbnail_capture_clean: false,
|
thumbnail_capture_clean: false,
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
print_snapshot_at: None,
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pending_native_thumbnail_save: None,
|
pending_native_thumbnail_save: None,
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
@@ -4644,6 +4657,30 @@ impl OpenCADStudio {
|
|||||||
app
|
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`.
|
/// Install `cmd` as the active interactive command for tab `tab`.
|
||||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||||
pub(crate) fn set_active_command(
|
pub(crate) fn set_active_command(
|
||||||
|
|||||||
@@ -212,6 +212,14 @@ impl OpenCADStudio {
|
|||||||
// The tab is already gone; nothing to discard.
|
// The tab is already gone; nothing to discard.
|
||||||
return close_win;
|
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.
|
// Discarded — drop this tab's autosave recovery copy.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let _ = std::fs::remove_file(self.autosave_target(idx));
|
let _ = std::fs::remove_file(self.autosave_target(idx));
|
||||||
@@ -328,6 +336,21 @@ impl OpenCADStudio {
|
|||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[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() {
|
match self.pending_close.take() {
|
||||||
Some(crate::app::PendingClose::Tab(id)) => {
|
Some(crate::app::PendingClose::Tab(id)) => {
|
||||||
let Some(idx) = self.tabs.iter().position(|tab| tab.id == id) else {
|
let Some(idx) = self.tabs.iter().position(|tab| tab.id == id) else {
|
||||||
|
|||||||
+69
-11
@@ -2396,6 +2396,9 @@ impl OpenCADStudio {
|
|||||||
) -> Task<Message> {
|
) -> Task<Message> {
|
||||||
self.thumbnail_capture_clean = false;
|
self.thumbnail_capture_clean = false;
|
||||||
let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
|
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();
|
return Task::none();
|
||||||
};
|
};
|
||||||
let preview = screenshot.as_ref().and_then(|screenshot| {
|
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
|
/// Web SAVE completion: the serializer worker (or inline fallback) has the
|
||||||
/// drawing bytes. When the tab holds a File System Access handle the
|
/// drawing bytes. When the tab still exists AND holds a File System
|
||||||
/// bytes go to it in place (createWritable needs no user gesture, so the
|
/// 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
|
/// write may happen long after the pick); completion continues in
|
||||||
/// `on_web_save_written`. Otherwise the legacy download fires — the only
|
/// `on_web_save_written`. Anything else takes the legacy download — the
|
||||||
/// artifact of the save, launched even if the tab was closed while
|
/// only artifact of the save, launched even if the tab was closed while
|
||||||
/// serialization was in flight.
|
/// serialization was in flight.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub(super) fn on_web_save_serialized(
|
pub(super) fn on_web_save_serialized(
|
||||||
@@ -2466,15 +2470,37 @@ impl OpenCADStudio {
|
|||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.command_line
|
self.command_line
|
||||||
.push_error(crate::tf!("Save failed: {error}").as_ref());
|
.push_error(crate::tf!("Save failed: {error}").as_ref());
|
||||||
|
crate::io::fs_access::end_web_save(tab_id);
|
||||||
return self.finish_web_save(false);
|
return self.finish_web_save(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// In-place write through the handle picked at the confirm gesture
|
// In-place write eligibility, all three required:
|
||||||
// (or an earlier one). The bookkeeping runs at write completion
|
// * the tab still exists — a tab that closed while serialization ran
|
||||||
// (WebSaveWritten) so a failed write cannot clear the dirty flag;
|
// means the user declined this save (discard/close), and the
|
||||||
// the bytes ride back with the completion so the OPFS recent store
|
// legacy contract for closed tabs is download-only;
|
||||||
// gets the same copy the download path uses.
|
// * a handle is held AND it was picked for the format these bytes
|
||||||
if let Some(picked_name) = crate::io::fs_access::handle_name(tab_id) {
|
// 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();
|
let download_name = picked_name.clone();
|
||||||
return Task::perform(
|
return Task::perform(
|
||||||
async move {
|
async move {
|
||||||
@@ -2574,6 +2600,9 @@ impl OpenCADStudio {
|
|||||||
camera_generation: u64,
|
camera_generation: u64,
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
) -> Task<Message> {
|
) -> 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)
|
let cache_name = std::path::Path::new(&filename)
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|name| name.to_string_lossy().into_owned())
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
@@ -3352,6 +3381,20 @@ impl OpenCADStudio {
|
|||||||
// once after the destination/version is known.
|
// once after the destination/version is known.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[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.sync_view_state_for_save(i);
|
||||||
self.stamp_header_sysvars(i);
|
self.stamp_header_sysvars(i);
|
||||||
}
|
}
|
||||||
@@ -3579,6 +3622,12 @@ impl OpenCADStudio {
|
|||||||
ext: String,
|
ext: String,
|
||||||
version: acadrust::DxfVersion,
|
version: acadrust::DxfVersion,
|
||||||
) -> Task<Message> {
|
) -> 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 bounds = crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID);
|
||||||
let Some(bounds) = bounds else {
|
let Some(bounds) = bounds else {
|
||||||
return Task::done(Message::WebSaveScreenshot {
|
return Task::done(Message::WebSaveScreenshot {
|
||||||
@@ -3607,7 +3656,9 @@ impl OpenCADStudio {
|
|||||||
/// any previous one — Save As re-picks); `Ok(None)` (dismissed picker)
|
/// any previous one — Save As re-picks); `Ok(None)` (dismissed picker)
|
||||||
/// cancels the save like a native save-dialog cancel; `Err` keeps the
|
/// cancels the save like a native save-dialog cancel; `Err` keeps the
|
||||||
/// save alive through the legacy download, with a note that the copy
|
/// 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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub(super) fn on_web_save_handle_picked(
|
pub(super) fn on_web_save_handle_picked(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -3633,6 +3684,13 @@ impl OpenCADStudio {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Err(error) => {
|
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(
|
self.command_line.push_error(
|
||||||
crate::tf!(
|
crate::tf!(
|
||||||
"Save destination picker failed: {error}; downloading a copy instead"
|
"Save destination picker failed: {error}; downloading a copy instead"
|
||||||
|
|||||||
@@ -466,6 +466,11 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
Message::PollWebControl => {
|
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() {
|
let control = if let Some(request) = super::control::web_request() {
|
||||||
self.update(Message::ControlRequest(request))
|
self.update(Message::ControlRequest(request))
|
||||||
} else {
|
} else {
|
||||||
@@ -574,6 +579,13 @@ impl OpenCADStudio {
|
|||||||
// control::on_control_capture_frame).
|
// control::on_control_capture_frame).
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
Message::ControlCaptureFrame => self.on_control_capture_frame(),
|
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 => {
|
Message::ControlToggle => {
|
||||||
self.control.enabled = !self.control.enabled;
|
self.control.enabled = !self.control.enabled;
|
||||||
Task::none()
|
Task::none()
|
||||||
@@ -4327,6 +4339,14 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
Message::ToggleSnap3dEnabled => {
|
Message::ToggleSnap3dEnabled => {
|
||||||
self.snapper.toggle_snap3d();
|
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);
|
self.sync_vport_display(self.active_tab);
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6943,18 +6943,26 @@ mod selection_preview_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// With the 3D master off, solid corners are invisible even when the
|
/// With the 3D master off, the 3D system serves nothing — so the solid's
|
||||||
/// Vertex mode is configured — and the 2D system must not catch them.
|
/// 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]
|
#[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 acadrust::{entities::Solid3D, EntityType};
|
||||||
use crate::snap::SnapType;
|
use crate::snap::SnapType;
|
||||||
|
|
||||||
let mut app = OpenCADStudio::new_for_test();
|
let mut app = OpenCADStudio::new_for_test();
|
||||||
app.automation_op(r#"{"op":"new"}"#);
|
app.automation_op(r#"{"op":"new"}"#);
|
||||||
let i = app.active_tab;
|
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.snap_enabled = true;
|
||||||
app.snapper.snap3d_enabled = false;
|
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.enabled3d.insert(SnapType::Vertex);
|
||||||
app.snapper.grid_snap_on = false;
|
app.snapper.grid_snap_on = false;
|
||||||
app.snapper.otrack_enabled = false;
|
app.snapper.otrack_enabled = false;
|
||||||
@@ -7004,11 +7012,23 @@ mod selection_preview_tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y));
|
let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y));
|
||||||
|
|
||||||
assert!(
|
let hit = app
|
||||||
app.tabs[i].snap_result.is_none(),
|
.tabs[i]
|
||||||
"3D master off must hide solid corners, got {:?}",
|
.snap_result
|
||||||
app.tabs[i].snap_result.map(|s| (s.snap_type, s.world))
|
.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
|
/// Face centres snap as their own 3D type at the loop average (exact
|
||||||
|
|||||||
@@ -2603,6 +2603,17 @@ impl OpenCADStudio {
|
|||||||
};
|
};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let control_capture = Subscription::none();
|
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.
|
// Blink the MText preview caret while the editor is open.
|
||||||
let caret_blink = if self.mtext_editor.is_some() {
|
let caret_blink = if self.mtext_editor.is_some() {
|
||||||
iced::time::every(std::time::Duration::from_millis(530))
|
iced::time::every(std::time::Duration::from_millis(530))
|
||||||
@@ -2823,6 +2834,7 @@ impl OpenCADStudio {
|
|||||||
gpu_probe,
|
gpu_probe,
|
||||||
thumbnail_capture,
|
thumbnail_capture,
|
||||||
control_capture,
|
control_capture,
|
||||||
|
print_snapshot,
|
||||||
caret_blink,
|
caret_blink,
|
||||||
web_fonts,
|
web_fonts,
|
||||||
autosave,
|
autosave,
|
||||||
|
|||||||
+112
-4
@@ -82,6 +82,20 @@ fn exts_match(a: &str, b: &str) -> bool {
|
|||||||
strip(a) == strip(b)
|
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"
|
/// `showSaveFilePicker` rejects with a `DOMException` named "AbortError"
|
||||||
/// when the user dismisses the dialog. That name check is the whole cancel
|
/// when the user dismisses the dialog. That name check is the whole cancel
|
||||||
/// classification — kept pure so it compiles and tests on every target.
|
/// 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
|
/// not an App field or a Message payload — owns the handles because
|
||||||
/// iced requires `Message: Send` even on wasm, and a JsValue-backed
|
/// iced requires `Message: Send` even on wasm, and a JsValue-backed
|
||||||
/// handle is not Send. (Same pattern as web_recent's OPEN_STORES.)
|
/// handle is not Send. (Same pattern as web_recent's OPEN_STORES.)
|
||||||
/// Closing a tab, and a failed write, drop its entry; Save As
|
/// Closing a tab, a failed write, a failed re-pick and a format
|
||||||
/// replaces it.
|
/// mismatch drop its entry; Save As replaces it.
|
||||||
static SAVE_HANDLES: RefCell<HashMap<u64, SaveHandle>> =
|
static SAVE_HANDLES: RefCell<HashMap<u64, SaveHandle>> =
|
||||||
RefCell::new(HashMap::new());
|
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 —
|
/// 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
|
/// Feature-detect `showSaveFilePicker` — never assume it: Firefox and
|
||||||
/// Safari keep the download flow precisely because this returns false.
|
/// Safari keep the download flow precisely because this returns false.
|
||||||
pub fn supported() -> bool {
|
pub fn supported() -> bool {
|
||||||
@@ -314,12 +386,13 @@ mod browser {
|
|||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub use browser::{
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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,
|
/// Firefox/Safari: no showSaveFilePicker — every save stays a download,
|
||||||
/// whatever handle state the tab happens to hold.
|
/// 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("TypeError"));
|
||||||
assert!(!is_cancel_error_name(""));
|
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
@@ -1180,12 +1180,25 @@ pub fn collect_entries_with_prev(
|
|||||||
/// The filesystem stat is desktop-only by `cfg` (not a runtime check): the
|
/// 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
|
/// 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.
|
/// session can never block on — or fail from — a host-filesystem probe.
|
||||||
/// Path-based web references resolve through the file-handle loader and
|
/// Path-based web references resolve through the file-handle loader: a
|
||||||
/// simply stay `NotFound` when unresolvable.
|
/// 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) {
|
fn stat_into(entry: &mut ReferenceEntry, raw: &str, base_dir: &Path) {
|
||||||
if raw.is_empty() {
|
if raw.is_empty() {
|
||||||
return;
|
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 {
|
let Some(found) = resolve_path(raw, base_dir) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1672,6 +1685,8 @@ fn bind_dwg(
|
|||||||
.find(|b| b.handle == br_handle)
|
.find(|b| b.handle == br_handle)
|
||||||
.map(|b| b.xref_path.clone())
|
.map(|b| b.xref_path.clone())
|
||||||
.unwrap_or_default();
|
.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 {
|
let Some(found) = resolve_path(&saved, base_dir) else {
|
||||||
return Err(bind_failed(name, "file not found"));
|
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 planned: HashMap<String, String> = HashMap::default();
|
||||||
let mut ops: Vec<(Handle, PathBuf, String)> = Vec::new();
|
let mut ops: Vec<(Handle, PathBuf, String)> = Vec::new();
|
||||||
for (handle, saved) in &defs {
|
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 {
|
let Some(resolved) = resolve_path(saved, source_dir) else {
|
||||||
return Err(bind_failed(ref_name, format!("image not found: {saved}")));
|
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]
|
#[test]
|
||||||
fn unresolved_xref_names_lists_not_found_entries_only() {
|
fn unresolved_xref_names_lists_not_found_entries_only() {
|
||||||
let infos = vec![
|
let infos = vec![
|
||||||
@@ -2876,4 +2914,346 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(entries[0].status, RefStatus::NotFound);
|
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
@@ -69,6 +69,40 @@ pub(crate) use convert::tess::{
|
|||||||
entity_aabb, entity_world_aabb_f64, is_unindexable_entity, tessellate_entity_dim_text,
|
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
|
/// Keep construction-history curves in the same per-entity tessellation memo
|
||||||
/// as their parent, so navigation never rebuilds them on every frame.
|
/// as their parent, so navigation never rebuilds them on every frame.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -145,17 +179,21 @@ pub(crate) fn tessellate_entity(
|
|||||||
// treat it exactly like any 2-D entity's geometry.
|
// treat it exactly like any 2-D entity's geometry.
|
||||||
//
|
//
|
||||||
// EXCEPT kernel B-rep solids: when the mesh cache carries real
|
// EXCEPT kernel B-rep solids: when the mesh cache carries real
|
||||||
// `geometry_edges`, the separate 3D object-snap system (F4 master +
|
// `geometry_edges` AND the separate 3D object-snap system (F4 master
|
||||||
// `enabled3d`, via `attach_solid_snaps`) already provides the
|
// + `enabled3d`, via `attach_solid_snaps`) is actually enabled, that
|
||||||
// corners and edge midpoints, and those must stay invisible to the
|
// system already provides the corners and edge midpoints, and those
|
||||||
// 2D modes. The 2D edge wire is the FALLBACK for solids the 3D
|
// must stay invisible to the 2D modes. The 2D edge wire is the
|
||||||
// system cannot serve (e.g. an OBJ import's triangle-only cache).
|
// FALLBACK whenever the 3D system cannot serve the solid — a
|
||||||
let three_d_covered = matches!(
|
// triangle-only cache (e.g. an OBJ import), or the F4 master off,
|
||||||
entity,
|
// which would otherwise leave NEITHER system serving the solid.
|
||||||
EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_)
|
let three_d_covered = snap3d_enabled()
|
||||||
) && volume_meshes
|
&& matches!(
|
||||||
.and_then(|meshes| meshes.get(&handle))
|
entity,
|
||||||
.is_some_and(|set| !set.geometry_edges().0.is_empty());
|
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 {
|
if !three_d_covered {
|
||||||
let stored = match entity {
|
let stored = match entity {
|
||||||
EntityType::Solid3D(solid) => solid.wires.as_slice(),
|
EntityType::Solid3D(solid) => solid.wires.as_slice(),
|
||||||
@@ -10358,6 +10396,10 @@ impl Scene {
|
|||||||
mix(channel.to_bits() as u64);
|
mix(channel.to_bits() as u64);
|
||||||
}
|
}
|
||||||
mix(avp.map(|h| h.value()).unwrap_or(0));
|
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
|
// 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
|
// a re-bake (which rescale / rewind every UV) makes memoized text
|
||||||
// address the wrong tile — garbage on screen, and a silent miss in
|
// address the wrong tile — garbage on screen, and a silent miss in
|
||||||
|
|||||||
@@ -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).
|
/// Every reference with bytes currently registered (picked or dropped).
|
||||||
/// Used after a web open settles to catch images registered while the load —
|
/// Used after a web open settles to catch images registered while the load —
|
||||||
/// which builds the raster models — was already running.
|
/// which builds the raster models — was already running.
|
||||||
|
|||||||
+107
-5
@@ -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());
|
let key = line_geom_key(a.as_vec3().to_array(), b.as_vec3().to_array());
|
||||||
map.entry(key).or_insert((a, b));
|
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
|
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]
|
#[test]
|
||||||
fn closest_point_on_triangle_2d_handles_inside_edge_and_vertex() {
|
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]);
|
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()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Centred on the closing edge x = ox, so both crossings sit on it.
|
// Centred ON the closing edge y = oy (the last→first segment, which
|
||||||
let pts = exact_curve_intersections(&square, &xy_circle(ox, oy + 5.0, 1.0))
|
// `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");
|
.expect("the circle crosses the closing edge");
|
||||||
assert_eq!(pts.len(), 2, "{pts:?}");
|
assert_eq!(pts.len(), 2, "{pts:?}");
|
||||||
for p in &pts {
|
pts.iter().for_each(|p| {
|
||||||
assert!((p.x - ox).abs() < 1e-9, "closing edge fell back to f32 endpoints: {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
@@ -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.
|
/// 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
|
/// 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
|
/// 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
|
/// Web: read an image from the system clipboard via the async Clipboard API
|
||||||
/// (`navigator.clipboard.read()`), used when a paste found no text. Returns
|
/// (`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.
|
/// 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
|
/// 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.
|
/// 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() {
|
if bytes.is_empty() {
|
||||||
return None;
|
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
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registry name for a pasted clipboard image: `pasted-YYYYMMDD-HHMMSS.ext`
|
/// Registry name for a pasted clipboard image:
|
||||||
/// stamped from the wall clock (local time, the same clock a screenshot file
|
/// `pasted-YYYYMMDD-HHMMSS-mmm.ext` stamped from the wall clock (local time,
|
||||||
/// name uses) with the extension derived from the MIME type.
|
/// 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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
fn pasted_image_name(mime_type: &str) -> String {
|
fn pasted_image_name(mime_type: &str) -> String {
|
||||||
let now = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(0.0));
|
let now = js_sys::Date::new_0();
|
||||||
format!(
|
unique_pasted_image_name(
|
||||||
"pasted-{:04}{:02}{:02}-{:02}{:02}{:02}.{}",
|
&format!(
|
||||||
now.get_full_year(),
|
"pasted-{:04}{:02}{:02}-{:02}{:02}{:02}-{:03}",
|
||||||
now.get_month() + 1,
|
now.get_full_year(),
|
||||||
now.get_date(),
|
now.get_month() + 1,
|
||||||
now.get_hours(),
|
now.get_date(),
|
||||||
now.get_minutes(),
|
now.get_hours(),
|
||||||
now.get_seconds(),
|
now.get_minutes(),
|
||||||
clipboard_image_extension(mime_type)
|
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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
#[wasm_bindgen::prelude::wasm_bindgen(module = "/web/clipboard.js")]
|
#[wasm_bindgen::prelude::wasm_bindgen(module = "/web/clipboard.js")]
|
||||||
extern "C" {
|
extern "C" {
|
||||||
|
|||||||
+12
-29
@@ -39,9 +39,13 @@
|
|||||||
}
|
}
|
||||||
@keyframes ocs-spin { to { transform: rotate(360deg); } }
|
@keyframes ocs-spin { to { transform: rotate(360deg); } }
|
||||||
/* Browser-menu print support: hidden on screen, the only thing shown on
|
/* 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
|
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; }
|
#print-snapshot { display: none; }
|
||||||
@media print {
|
@media print {
|
||||||
body > *:not(#print-snapshot) { display: none !important; }
|
body > *:not(#print-snapshot) { display: none !important; }
|
||||||
@@ -66,7 +70,12 @@
|
|||||||
<div class="title">Open <span>CAD</span> Studio</div>
|
<div class="title">Open <span>CAD</span> Studio</div>
|
||||||
<div class="sub">Loading…</div>
|
<div class="sub">Loading…</div>
|
||||||
</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="" />
|
<img id="print-snapshot" alt="" />
|
||||||
<script src="ocs-control.js" defer></script>
|
<script src="ocs-control.js" defer></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -84,32 +93,6 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</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>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const splash = document.getElementById("loading");
|
const splash = document.getElementById("loading");
|
||||||
|
|||||||
Reference in new issue
Block a user