forked from 1b-agent/opencadstudio-upstream
Merge pull request #1275 from CiberNin/fix/plot-bitmaps
# Conflicts: # src/app/update/file.rs # src/scene/mod.rs
This commit is contained in:
commit
487b635073
11 files changed
+8623
-649
No files matched your search
Generated
+1
@@ -4601,6 +4601,7 @@ dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"lopdf",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ meshopt = "0.6.2"
|
||||
i18n-embed = { version = "0.16", features = ["desktop-requester"] }
|
||||
rayon = "1"
|
||||
open = "5"
|
||||
printpdf = { version = "0.9.1", default-features = false }
|
||||
printpdf = { version = "0.9.1", default-features = false, features = ["images"] }
|
||||
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
|
||||
+164
-384
@@ -86,33 +86,7 @@ fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
type LayoutPlotParams = (
|
||||
std::sync::Arc<Vec<crate::io::pdf_export::PlotWire>>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
crate::io::pdf_export::PlotGroupSplits,
|
||||
f64,
|
||||
f64,
|
||||
f64,
|
||||
f64,
|
||||
i32,
|
||||
f32,
|
||||
Option<(f32, f32, f32, f32)>,
|
||||
);
|
||||
|
||||
type ClippedPlotParams = (
|
||||
Vec<crate::io::pdf_export::PlotWire>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
crate::io::pdf_export::PlotGroupSplits,
|
||||
f64,
|
||||
f64,
|
||||
f64,
|
||||
f64,
|
||||
i32,
|
||||
f32,
|
||||
Option<(f32, f32, f32, f32)>,
|
||||
);
|
||||
use crate::io::pdf_export::{PdfPageInput, PlotContent};
|
||||
|
||||
fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) {
|
||||
use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize};
|
||||
@@ -143,11 +117,8 @@ fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f
|
||||
}
|
||||
}
|
||||
|
||||
fn plot_content_extents(
|
||||
wires: &[crate::io::pdf_export::PlotWire],
|
||||
hatches: &[crate::scene::model::hatch_model::HatchModel],
|
||||
wipeouts: &[crate::scene::model::hatch_model::HatchModel],
|
||||
) -> Option<(f64, f64, f64, f64)> {
|
||||
fn plot_content_extents(content: &PlotContent) -> Option<(f64, f64, f64, f64)> {
|
||||
let PlotContent { wires, hatches, wipeouts, images, .. } = content;
|
||||
let mut bounds = (
|
||||
f64::INFINITY,
|
||||
f64::INFINITY,
|
||||
@@ -162,7 +133,7 @@ fn plot_content_extents(
|
||||
bounds.3 = bounds.3.max(y);
|
||||
}
|
||||
};
|
||||
for wire in wires {
|
||||
for wire in wires.iter().filter(|wire| wire.name != "paper_printable_area") {
|
||||
let [x0, y0, x1, y1] = wire.aabb;
|
||||
include(x0 as f64, y0 as f64);
|
||||
include(x1 as f64, y1 as f64);
|
||||
@@ -175,6 +146,29 @@ fn plot_content_extents(
|
||||
);
|
||||
}
|
||||
}
|
||||
for plot in images {
|
||||
let mut image_bounds = [f64::INFINITY, f64::INFINITY,
|
||||
f64::NEG_INFINITY, f64::NEG_INFINITY];
|
||||
for vertex in &plot.image.verts {
|
||||
for axis in 0..2 {
|
||||
let value = vertex.pos[axis] as f64 + vertex.pos_low[axis] as f64;
|
||||
image_bounds[axis] = image_bounds[axis].min(value);
|
||||
image_bounds[axis + 2] = image_bounds[axis + 2].max(value);
|
||||
}
|
||||
}
|
||||
for clip in &plot.clips {
|
||||
for axis in 0..2 {
|
||||
let min = clip.iter().map(|p| p[axis]).fold(f64::INFINITY, f64::min);
|
||||
let max = clip.iter().map(|p| p[axis]).fold(f64::NEG_INFINITY, f64::max);
|
||||
image_bounds[axis] = image_bounds[axis].max(min);
|
||||
image_bounds[axis + 2] = image_bounds[axis + 2].min(max);
|
||||
}
|
||||
}
|
||||
if image_bounds[0] < image_bounds[2] && image_bounds[1] < image_bounds[3] {
|
||||
include(image_bounds[0], image_bounds[1]);
|
||||
include(image_bounds[2], image_bounds[3]);
|
||||
}
|
||||
}
|
||||
(bounds.0.is_finite()
|
||||
&& bounds.1.is_finite()
|
||||
&& bounds.2.is_finite()
|
||||
@@ -188,12 +182,7 @@ fn plot_scene_content(
|
||||
scene: &crate::scene::Scene,
|
||||
paper_space_last: bool,
|
||||
render_mode_override: Option<acadrust::entities::ViewportRenderMode>,
|
||||
) -> (
|
||||
std::sync::Arc<Vec<crate::io::pdf_export::PlotWire>>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
crate::io::pdf_export::PlotGroupSplits,
|
||||
) {
|
||||
) -> PlotContent {
|
||||
let (mut paper_wires, mut model_wires) = scene.plot_wire_groups(render_mode_override);
|
||||
let plot_viewport_borders = scene
|
||||
.effective_plot_settings()
|
||||
@@ -227,56 +216,66 @@ fn plot_scene_content(
|
||||
let model_wires = with_depth(model_wires);
|
||||
let paper_hatches = scene.paper_plot_hatches().as_ref().clone();
|
||||
let paper_wipeouts = scene.paper_plot_wipeouts().as_ref().clone();
|
||||
let paper_images = scene.paper_plot_images();
|
||||
if scene.current_layout == "Model" {
|
||||
let splits = crate::io::pdf_export::PlotGroupSplits {
|
||||
wires: paper_wires.len(),
|
||||
hatches: paper_hatches.len(),
|
||||
wipeouts: paper_wipeouts.len(),
|
||||
images: paper_images.len(),
|
||||
};
|
||||
return PlotContent {
|
||||
wires: std::sync::Arc::new(paper_wires),
|
||||
hatches: paper_hatches,
|
||||
wipeouts: paper_wipeouts,
|
||||
images: paper_images,
|
||||
group_splits: splits,
|
||||
};
|
||||
return (
|
||||
std::sync::Arc::new(paper_wires),
|
||||
paper_hatches,
|
||||
paper_wipeouts,
|
||||
splits,
|
||||
);
|
||||
}
|
||||
let (mut model_pattern_wires, model_hatches, model_wipeouts) = scene.viewport_plot_fills();
|
||||
let (mut model_pattern_wires, model_hatches, model_wipeouts, model_images) =
|
||||
scene.viewport_plot_fills();
|
||||
model_pattern_wires.retain(|(wire, _)| wire.plot_visible);
|
||||
let model_pattern_wires = model_pattern_wires
|
||||
.into_iter()
|
||||
.map(|(wire, draw_depth)| crate::io::pdf_export::PlotWire { wire, draw_depth })
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let (wires, hatches, wipeouts, splits) = if paper_space_last {
|
||||
let (wires, hatches, wipeouts, images, splits) = if paper_space_last {
|
||||
let splits = crate::io::pdf_export::PlotGroupSplits {
|
||||
wires: model_wires.len() + model_pattern_wires.len(),
|
||||
hatches: model_hatches.len(),
|
||||
wipeouts: model_wipeouts.len(),
|
||||
images: model_images.len(),
|
||||
};
|
||||
let mut wires = model_wires;
|
||||
let mut hatches = model_hatches;
|
||||
let mut wipeouts = model_wipeouts;
|
||||
let mut images = model_images;
|
||||
wires.extend(model_pattern_wires);
|
||||
wires.extend(paper_wires);
|
||||
hatches.extend(paper_hatches);
|
||||
wipeouts.extend(paper_wipeouts);
|
||||
(wires, hatches, wipeouts, splits)
|
||||
images.extend(paper_images);
|
||||
(wires, hatches, wipeouts, images, splits)
|
||||
} else {
|
||||
let splits = crate::io::pdf_export::PlotGroupSplits {
|
||||
wires: paper_wires.len(),
|
||||
hatches: paper_hatches.len(),
|
||||
wipeouts: paper_wipeouts.len(),
|
||||
images: paper_images.len(),
|
||||
};
|
||||
let mut wires = paper_wires;
|
||||
let mut hatches = paper_hatches;
|
||||
let mut wipeouts = paper_wipeouts;
|
||||
let mut images = paper_images;
|
||||
wires.extend(model_wires);
|
||||
wires.extend(model_pattern_wires);
|
||||
hatches.extend(model_hatches);
|
||||
wipeouts.extend(model_wipeouts);
|
||||
(wires, hatches, wipeouts, splits)
|
||||
images.extend(model_images);
|
||||
(wires, hatches, wipeouts, images, splits)
|
||||
};
|
||||
(std::sync::Arc::new(wires), hatches, wipeouts, splits)
|
||||
PlotContent { wires: std::sync::Arc::new(wires), hatches, wipeouts, images, group_splits: splits }
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
@@ -2951,46 +2950,18 @@ impl OpenCADStudio {
|
||||
self.load_plotsettings_into_dialog(&settings);
|
||||
}
|
||||
}
|
||||
let Some((
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
group_splits,
|
||||
page_w,
|
||||
page_h,
|
||||
ox,
|
||||
oy,
|
||||
rotation,
|
||||
scale,
|
||||
clip,
|
||||
)) = self.direct_plot_params()
|
||||
let Some(page) = self.direct_plot_page()
|
||||
else {
|
||||
self.command_line.push_error(
|
||||
crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref(),
|
||||
);
|
||||
return Task::none();
|
||||
};
|
||||
let plot_style = self.dialog_plot_style(&self.plot_dialog);
|
||||
let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits);
|
||||
let worker_path = path.clone();
|
||||
let work = move || {
|
||||
crate::io::pdf_export::export_pdf(
|
||||
&wires,
|
||||
&hatches,
|
||||
&wipeouts,
|
||||
page_w,
|
||||
page_h,
|
||||
ox,
|
||||
oy,
|
||||
rotation,
|
||||
scale,
|
||||
clip,
|
||||
&worker_path,
|
||||
plot_style.as_ref(),
|
||||
render_options,
|
||||
)
|
||||
.map(|_| crate::tf!("Exported: {}", worker_path.display()).into_owned())
|
||||
.map_err(|e| crate::tf!("Export failed: {e}").into_owned())
|
||||
crate::io::pdf_export::export_pdf(&page, &worker_path)
|
||||
.map(|_| crate::tf!("Exported: {}", worker_path.display()).into_owned())
|
||||
.map_err(|e| crate::tf!("Export failed: {e}").into_owned())
|
||||
};
|
||||
self.run_plot_work(self.plot_dialog.background, false, work)
|
||||
}
|
||||
@@ -3006,55 +2977,27 @@ impl OpenCADStudio {
|
||||
"Extents" => self.extents_plot_job(),
|
||||
_ => self.window_plot_job(),
|
||||
};
|
||||
let Some((
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
group_splits,
|
||||
page_w,
|
||||
page_h,
|
||||
ox,
|
||||
oy,
|
||||
rotation,
|
||||
scale,
|
||||
clip,
|
||||
)) = job
|
||||
let Some(page) = job
|
||||
else {
|
||||
self.command_line
|
||||
.push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref());
|
||||
return Task::none();
|
||||
};
|
||||
let plot_style = self.dialog_plot_style(&self.plot_dialog);
|
||||
let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits);
|
||||
let worker_path = path.clone();
|
||||
self.close_active_modal();
|
||||
let work = move || {
|
||||
crate::io::pdf_export::export_pdf(
|
||||
&wires,
|
||||
&hatches,
|
||||
&wipeouts,
|
||||
page_w,
|
||||
page_h,
|
||||
ox,
|
||||
oy,
|
||||
rotation,
|
||||
scale,
|
||||
clip,
|
||||
&worker_path,
|
||||
plot_style.as_ref(),
|
||||
render_options,
|
||||
)
|
||||
.map(|_| {
|
||||
crate::tf!(
|
||||
"Plotted window to {}",
|
||||
worker_path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
)
|
||||
.into_owned()
|
||||
})
|
||||
.map_err(|e| crate::tf!("Plot failed: {e}").into_owned())
|
||||
crate::io::pdf_export::export_pdf(&page, &worker_path)
|
||||
.map(|_| {
|
||||
crate::tf!(
|
||||
"Plotted window to {}",
|
||||
worker_path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
)
|
||||
.into_owned()
|
||||
})
|
||||
.map_err(|e| crate::tf!("Plot failed: {e}").into_owned())
|
||||
};
|
||||
self.run_plot_work(self.plot_dialog.background, false, work)
|
||||
}
|
||||
@@ -3182,8 +3125,7 @@ impl OpenCADStudio {
|
||||
)
|
||||
.into_owned());
|
||||
}
|
||||
let plot_style = self.dialog_plot_style(&dialog);
|
||||
let params = match dialog.area.as_str() {
|
||||
let area_page = match dialog.area.as_str() {
|
||||
"Display" => self.display_plot_job(),
|
||||
"Extents" => self.extents_plot_job(),
|
||||
"Limits" => self.limits_plot_job(),
|
||||
@@ -3193,64 +3135,14 @@ impl OpenCADStudio {
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let (
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
group_splits,
|
||||
paper_w,
|
||||
paper_h,
|
||||
offset_x,
|
||||
offset_y,
|
||||
rotation_deg,
|
||||
scale,
|
||||
clip,
|
||||
) = if dialog.area == "Layout" {
|
||||
self.layout_plot_params_for("Layout")
|
||||
let page = if dialog.area == "Layout" {
|
||||
self.layout_plot_page_for("Layout")
|
||||
} else {
|
||||
let (
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
group_splits,
|
||||
paper_w,
|
||||
paper_h,
|
||||
offset_x,
|
||||
offset_y,
|
||||
rotation_deg,
|
||||
scale,
|
||||
clip,
|
||||
) = params.ok_or_else(|| {
|
||||
area_page.ok_or_else(|| {
|
||||
crate::tf!("Layout '{name}' plot area is empty.").into_owned()
|
||||
})?;
|
||||
(
|
||||
std::sync::Arc::new(wires),
|
||||
hatches,
|
||||
wipeouts,
|
||||
group_splits,
|
||||
paper_w,
|
||||
paper_h,
|
||||
offset_x,
|
||||
offset_y,
|
||||
rotation_deg,
|
||||
scale,
|
||||
clip,
|
||||
)
|
||||
})?
|
||||
};
|
||||
pages.push(crate::io::pdf_export::PdfPageInput {
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
paper_w,
|
||||
paper_h,
|
||||
offset_x,
|
||||
offset_y,
|
||||
rotation_deg,
|
||||
scale,
|
||||
clip,
|
||||
options: Self::pdf_plot_options(&dialog, group_splits),
|
||||
plot_style,
|
||||
});
|
||||
pages.push(page);
|
||||
}
|
||||
Ok(pages)
|
||||
})();
|
||||
@@ -3328,7 +3220,7 @@ impl OpenCADStudio {
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let options = self.plot_print_options(&dialog, Default::default());
|
||||
let options = self.plot_print_options(&dialog);
|
||||
let temp_path = crate::io::print_to_printer::temp_pdf_path("print_all");
|
||||
self.save_config();
|
||||
self.close_active_modal();
|
||||
@@ -3375,41 +3267,31 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the render inputs and page geometry for a full-layout plot: wires
|
||||
/// plus hatch / wipeout fills, the effective (rotation-swapped) sheet size,
|
||||
/// draw-origin offset, rotation, unit scale, and optional clip. Shared by
|
||||
/// PDF export, preview, and printer output so all three render identically.
|
||||
pub(super) fn layout_plot_params(&self) -> LayoutPlotParams {
|
||||
self.layout_plot_params_for(&self.plot_dialog.area)
|
||||
/// Assemble a layout page for PDF export, preview, or printing.
|
||||
pub(super) fn layout_plot_page(&self) -> PdfPageInput {
|
||||
self.layout_plot_page_for(&self.plot_dialog.area)
|
||||
}
|
||||
|
||||
fn layout_plot_params_for(&self, plot_area: &str) -> LayoutPlotParams {
|
||||
fn layout_plot_page_for(&self, plot_area: &str) -> PdfPageInput {
|
||||
let i = self.active_tab;
|
||||
let scene = &self.tabs[i].scene;
|
||||
let paper_space = scene.current_layout != "Model";
|
||||
let selected_sheet = plot_dialog_sheet_mm(&self.plot_dialog);
|
||||
let (source_wires, hatches, wipeouts, mut group_splits) = plot_scene_content(
|
||||
let mut content = plot_scene_content(
|
||||
scene,
|
||||
self.plot_dialog.paperspace_last,
|
||||
plot_render_mode_override(&self.plot_dialog),
|
||||
);
|
||||
// The printable-area rectangle is an on-screen guide, not drawing
|
||||
// content. It used to leak into every paper-space PDF/preview/print.
|
||||
let wires = if paper_space {
|
||||
group_splits.wires = source_wires[..group_splits.wires.min(source_wires.len())]
|
||||
if paper_space {
|
||||
content.group_splits.wires = content.wires[..content.group_splits.wires.min(content.wires.len())]
|
||||
.iter()
|
||||
.filter(|wire| wire.name != "paper_printable_area")
|
||||
.count();
|
||||
std::sync::Arc::new(
|
||||
source_wires
|
||||
.iter()
|
||||
.filter(|wire| wire.name != "paper_printable_area")
|
||||
.cloned()
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
source_wires
|
||||
};
|
||||
std::sync::Arc::make_mut(&mut content.wires)
|
||||
.retain(|wire| wire.name != "paper_printable_area");
|
||||
}
|
||||
if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() {
|
||||
// Paper geometry is stored in layout units; PDF pages use mm.
|
||||
// Scale both geometry and offsets by the same unit conversion.
|
||||
@@ -3434,7 +3316,7 @@ impl OpenCADStudio {
|
||||
|
||||
let (scale, offset_x, offset_y) = if plot_extents {
|
||||
let (min_x, min_y, max_x, max_y) =
|
||||
plot_content_extents(&wires, &hatches, &wipeouts).unwrap_or((x0, y0, x1, y1));
|
||||
plot_content_extents(&content).unwrap_or((x0, y0, x1, y1));
|
||||
let content_w = (max_x - min_x).max(1e-9);
|
||||
let content_h = (max_y - min_y).max(1e-9);
|
||||
let scale = if self.plot_dialog.fit_to_paper {
|
||||
@@ -3513,19 +3395,18 @@ impl OpenCADStudio {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return (
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
group_splits,
|
||||
page_w,
|
||||
page_h,
|
||||
return PdfPageInput {
|
||||
content,
|
||||
paper_w: page_w,
|
||||
paper_h: page_h,
|
||||
offset_x,
|
||||
offset_y,
|
||||
rotation,
|
||||
scale as f32,
|
||||
rotation_deg: rotation,
|
||||
scale: scale as f32,
|
||||
clip,
|
||||
);
|
||||
options: Self::pdf_plot_options(&self.plot_dialog),
|
||||
plot_style: self.dialog_plot_style(&self.plot_dialog),
|
||||
};
|
||||
}
|
||||
|
||||
// Model space keeps its established extents + 5% margin behaviour.
|
||||
@@ -3545,77 +3426,52 @@ impl OpenCADStudio {
|
||||
} else {
|
||||
(297.0, 210.0, 0.0, 0.0)
|
||||
};
|
||||
(
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
group_splits,
|
||||
page_w,
|
||||
page_h,
|
||||
PdfPageInput {
|
||||
content,
|
||||
paper_w: page_w,
|
||||
paper_h: page_h,
|
||||
offset_x,
|
||||
offset_y,
|
||||
0,
|
||||
1.0,
|
||||
None,
|
||||
)
|
||||
rotation_deg: 0,
|
||||
scale: 1.0,
|
||||
clip: None,
|
||||
options: Self::pdf_plot_options(&self.plot_dialog),
|
||||
plot_style: self.dialog_plot_style(&self.plot_dialog),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build direct PDF/printer output using the physical layout sheet in
|
||||
/// paper space and the selected ISO sheet/scale around Model extents.
|
||||
fn direct_plot_params(&self) -> Option<LayoutPlotParams> {
|
||||
fn direct_plot_page(&self) -> Option<PdfPageInput> {
|
||||
if self.tabs[self.active_tab].scene.current_layout != "Model" {
|
||||
return Some(self.layout_plot_params_for("Layout"));
|
||||
return Some(self.layout_plot_page_for("Layout"));
|
||||
}
|
||||
let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) =
|
||||
self.extents_plot_job()?;
|
||||
if wires.is_empty() && hatches.is_empty() && wipeouts.is_empty() {
|
||||
let page = self.extents_plot_job()?;
|
||||
let content = &page.content;
|
||||
if content.wires.is_empty() && content.hatches.is_empty()
|
||||
&& content.wipeouts.is_empty() && content.images.is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
std::sync::Arc::new(wires),
|
||||
hatches,
|
||||
wipeouts,
|
||||
group_splits,
|
||||
page_w,
|
||||
page_h,
|
||||
ox,
|
||||
oy,
|
||||
rotation,
|
||||
scale,
|
||||
clip,
|
||||
))
|
||||
Some(page)
|
||||
}
|
||||
|
||||
pub(super) fn on_print_to_printer(&mut self) -> Task<Message> {
|
||||
let Some((
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
group_splits,
|
||||
page_w,
|
||||
page_h,
|
||||
ox,
|
||||
oy,
|
||||
rotation,
|
||||
scale,
|
||||
clip,
|
||||
)) = self.direct_plot_params()
|
||||
let Some(page) = self.direct_plot_page()
|
||||
else {
|
||||
self.command_line.push_error(
|
||||
crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref(),
|
||||
);
|
||||
return Task::none();
|
||||
};
|
||||
let plot_style = self.dialog_plot_style(&self.plot_dialog);
|
||||
let options = self.plot_print_options(&self.plot_dialog, group_splits);
|
||||
let options = self.plot_print_options(&self.plot_dialog);
|
||||
self.command_line
|
||||
.push_info(crate::t!("Sending to system printer…").as_ref());
|
||||
background_task(
|
||||
move || {
|
||||
iced::futures::executor::block_on(crate::io::print_to_printer::print_wires_with(
|
||||
wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip,
|
||||
plot_style, options,
|
||||
))
|
||||
iced::futures::executor::block_on(
|
||||
crate::io::print_to_printer::print_wires_with(page, options),
|
||||
)
|
||||
},
|
||||
Message::PrintResult,
|
||||
)
|
||||
@@ -4499,7 +4355,6 @@ impl OpenCADStudio {
|
||||
self.active_modal = None;
|
||||
self.reset_modal_geometry();
|
||||
|
||||
let plot_style = self.dialog_plot_style(&d);
|
||||
// Extents, Window and Display use one plot path in both spaces. Only
|
||||
// Paper-space Layout is special: it uses the physical sheet bounds.
|
||||
if d.area != "Layout" {
|
||||
@@ -4513,19 +4368,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some((
|
||||
w_wires,
|
||||
w_hatches,
|
||||
w_wipeouts,
|
||||
wgroup_splits,
|
||||
sw,
|
||||
sh,
|
||||
wox,
|
||||
woy,
|
||||
wrotation,
|
||||
wscale,
|
||||
wclip,
|
||||
)) = job
|
||||
let Some(page) = job
|
||||
else {
|
||||
self.command_line
|
||||
.push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref());
|
||||
@@ -4536,26 +4379,11 @@ impl OpenCADStudio {
|
||||
// whatever the output target is.
|
||||
if preview {
|
||||
let tmp = crate::io::print_to_printer::temp_pdf_path("preview");
|
||||
let render_options = Self::pdf_plot_options(&d, wgroup_splits);
|
||||
let work = move || {
|
||||
crate::io::pdf_export::export_pdf(
|
||||
&w_wires,
|
||||
&w_hatches,
|
||||
&w_wipeouts,
|
||||
sw,
|
||||
sh,
|
||||
wox,
|
||||
woy,
|
||||
wrotation,
|
||||
wscale,
|
||||
wclip,
|
||||
&tmp,
|
||||
plot_style.as_ref(),
|
||||
render_options,
|
||||
)
|
||||
.and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp))
|
||||
.map(|_| crate::t!("Opened plot preview.").into_owned())
|
||||
.map_err(|e| crate::tf!("Preview failed: {e}").into_owned())
|
||||
crate::io::pdf_export::export_pdf(&page, &tmp)
|
||||
.and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp))
|
||||
.map(|_| crate::t!("Opened plot preview.").into_owned())
|
||||
.map_err(|e| crate::tf!("Preview failed: {e}").into_owned())
|
||||
};
|
||||
return self.run_plot_work(d.background, true, work);
|
||||
}
|
||||
@@ -4564,56 +4392,27 @@ impl OpenCADStudio {
|
||||
return Task::done(Message::PlotWindowExport);
|
||||
}
|
||||
let tmp = crate::io::print_to_printer::temp_pdf_path("print");
|
||||
let render_options = Self::pdf_plot_options(&d, wgroup_splits);
|
||||
let opts = self.plot_print_options(&d, wgroup_splits);
|
||||
let opts = self.plot_print_options(&d);
|
||||
let work = move || {
|
||||
crate::io::pdf_export::export_pdf(
|
||||
&w_wires,
|
||||
&w_hatches,
|
||||
&w_wipeouts,
|
||||
sw,
|
||||
sh,
|
||||
wox,
|
||||
woy,
|
||||
wrotation,
|
||||
wscale,
|
||||
wclip,
|
||||
&tmp,
|
||||
plot_style.as_ref(),
|
||||
render_options,
|
||||
)
|
||||
.and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts))
|
||||
.map(|printer| crate::tf!("Sent to printer: {printer}").into_owned())
|
||||
.map_err(|e| crate::tf!("Print failed: {e}").into_owned())
|
||||
crate::io::pdf_export::export_pdf(&page, &tmp)
|
||||
.and_then(|_| {
|
||||
crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)
|
||||
})
|
||||
.map(|printer| crate::tf!("Sent to printer: {printer}").into_owned())
|
||||
.map_err(|e| crate::tf!("Print failed: {e}").into_owned())
|
||||
};
|
||||
return self.run_plot_work(true, false, work);
|
||||
}
|
||||
|
||||
let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) =
|
||||
self.layout_plot_params();
|
||||
let page = self.layout_plot_page();
|
||||
|
||||
if preview {
|
||||
let tmp = crate::io::print_to_printer::temp_pdf_path("preview");
|
||||
let render_options = Self::pdf_plot_options(&d, group_splits);
|
||||
let work = move || {
|
||||
crate::io::pdf_export::export_pdf(
|
||||
&wires,
|
||||
&hatches,
|
||||
&wipeouts,
|
||||
page_w,
|
||||
page_h,
|
||||
ox,
|
||||
oy,
|
||||
rotation,
|
||||
scale,
|
||||
clip,
|
||||
&tmp,
|
||||
plot_style.as_ref(),
|
||||
render_options,
|
||||
)
|
||||
.and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp))
|
||||
.map(|_| crate::t!("Opened plot preview.").into_owned())
|
||||
.map_err(|e| crate::tf!("Preview failed: {e}").into_owned())
|
||||
crate::io::pdf_export::export_pdf(&page, &tmp)
|
||||
.and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp))
|
||||
.map(|_| crate::t!("Opened plot preview.").into_owned())
|
||||
.map_err(|e| crate::tf!("Preview failed: {e}").into_owned())
|
||||
};
|
||||
return self.run_plot_work(d.background, true, work);
|
||||
}
|
||||
@@ -4623,16 +4422,15 @@ impl OpenCADStudio {
|
||||
return Task::done(Message::PlotExport);
|
||||
}
|
||||
|
||||
let opts = self.plot_print_options(&d, group_splits);
|
||||
let opts = self.plot_print_options(&d);
|
||||
self.command_line
|
||||
.push_info(crate::t!("Sending to system printer…").as_ref());
|
||||
let work = move || {
|
||||
iced::futures::executor::block_on(crate::io::print_to_printer::print_wires_with(
|
||||
wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip,
|
||||
plot_style, opts,
|
||||
page, opts,
|
||||
))
|
||||
.map(|printer| crate::tf!("Sent to printer: {printer}").into_owned())
|
||||
.map_err(|error| crate::tf!("Print failed: {error}").into_owned())
|
||||
.map(|printer| crate::tf!("Sent to printer: {printer}").into_owned())
|
||||
.map_err(|error| crate::tf!("Print failed: {error}").into_owned())
|
||||
};
|
||||
self.run_plot_work(true, false, work)
|
||||
}
|
||||
@@ -4642,19 +4440,16 @@ impl OpenCADStudio {
|
||||
fn plot_print_options(
|
||||
&self,
|
||||
d: &crate::ui::window::plot::PlotDialogState,
|
||||
group_splits: crate::io::pdf_export::PlotGroupSplits,
|
||||
) -> crate::io::print_to_printer::PrintOptions {
|
||||
crate::io::print_to_printer::PrintOptions {
|
||||
printer: d.printer.clone(),
|
||||
copies: d.copies.trim().parse::<u32>().unwrap_or(1).max(1),
|
||||
quality: Some(d.quality.clone()),
|
||||
render: Self::pdf_plot_options(d, group_splits),
|
||||
}
|
||||
}
|
||||
|
||||
fn pdf_plot_options(
|
||||
d: &crate::ui::window::plot::PlotDialogState,
|
||||
group_splits: crate::io::pdf_export::PlotGroupSplits,
|
||||
) -> crate::io::pdf_export::PdfPlotOptions {
|
||||
crate::io::pdf_export::PdfPlotOptions {
|
||||
object_lineweights: d.lineweights,
|
||||
@@ -4662,7 +4457,6 @@ impl OpenCADStudio {
|
||||
transparency: d.transparency,
|
||||
stamp: d.stamp,
|
||||
merge_lines: d.merge_lines,
|
||||
group_splits,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4679,20 +4473,20 @@ impl OpenCADStudio {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn window_plot_job(&self) -> Option<ClippedPlotParams> {
|
||||
fn window_plot_job(&self) -> Option<PdfPageInput> {
|
||||
self.area_plot_job(self.plot_window?)
|
||||
}
|
||||
|
||||
fn display_plot_job(&self) -> Option<ClippedPlotParams> {
|
||||
fn display_plot_job(&self) -> Option<PdfPageInput> {
|
||||
self.area_plot_job(self.display_plot_window()?)
|
||||
}
|
||||
|
||||
fn limits_plot_job(&self) -> Option<ClippedPlotParams> {
|
||||
fn limits_plot_job(&self) -> Option<PdfPageInput> {
|
||||
let (min, max) = self.tabs[self.active_tab].scene.current_drawing_limits()?;
|
||||
self.area_plot_job((min.x, min.y, max.x, max.y))
|
||||
}
|
||||
|
||||
fn named_view_plot_job(&self, name: &str) -> Option<ClippedPlotParams> {
|
||||
fn named_view_plot_job(&self, name: &str) -> Option<PdfPageInput> {
|
||||
let view = self.tabs[self.active_tab]
|
||||
.scene
|
||||
.document
|
||||
@@ -4714,26 +4508,18 @@ impl OpenCADStudio {
|
||||
))
|
||||
}
|
||||
|
||||
fn extents_plot_job(&self) -> Option<ClippedPlotParams> {
|
||||
fn extents_plot_job(&self) -> Option<PdfPageInput> {
|
||||
let scene = &self.tabs[self.active_tab].scene;
|
||||
if scene.current_layout == "Model" {
|
||||
let (min, max) = scene.model_space_extents()?;
|
||||
return self.area_plot_job((min.x as f64, min.y as f64, max.x as f64, max.y as f64));
|
||||
}
|
||||
let (wires, hatches, wipeouts, _) = plot_scene_content(
|
||||
let content = plot_scene_content(
|
||||
scene,
|
||||
self.plot_dialog.paperspace_last,
|
||||
plot_render_mode_override(&self.plot_dialog),
|
||||
);
|
||||
let extents = plot_content_extents(
|
||||
&wires
|
||||
.iter()
|
||||
.filter(|wire| wire.name != "paper_printable_area")
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
&hatches,
|
||||
&wipeouts,
|
||||
)?;
|
||||
let extents = plot_content_extents(&content)?;
|
||||
self.area_plot_job(extents)
|
||||
}
|
||||
|
||||
@@ -4790,7 +4576,7 @@ impl OpenCADStudio {
|
||||
|
||||
/// Render a selected rectangle through one shared Model/Paper path. The
|
||||
/// window may lie partly or wholly outside a paper sheet.
|
||||
fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option<ClippedPlotParams> {
|
||||
fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option<PdfPageInput> {
|
||||
use crate::io::paper_sizes::{window_to_sheet, PlotScale};
|
||||
let i = self.active_tab;
|
||||
let (x0, y0, x1, y1) = window;
|
||||
@@ -4829,27 +4615,22 @@ impl OpenCADStudio {
|
||||
};
|
||||
let scene = &self.tabs[i].scene;
|
||||
let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32);
|
||||
let (all_wires, hatches, wipeouts, mut group_splits) = plot_scene_content(
|
||||
let mut content = plot_scene_content(
|
||||
scene,
|
||||
self.plot_dialog.paperspace_last,
|
||||
plot_render_mode_override(&self.plot_dialog),
|
||||
);
|
||||
let first_wire_count = all_wires[..group_splits.wires.min(all_wires.len())]
|
||||
let in_window = |wire: &crate::io::pdf_export::PlotWire| {
|
||||
wire.name != "paper_printable_area"
|
||||
&& wire.aabb[0] <= wx1 && wire.aabb[2] >= wx0
|
||||
&& wire.aabb[1] <= wy1 && wire.aabb[3] >= wy0
|
||||
};
|
||||
content.group_splits.wires = content.wires
|
||||
[..content.group_splits.wires.min(content.wires.len())]
|
||||
.iter()
|
||||
.filter(|w| w.name != "paper_printable_area")
|
||||
.filter(|w| {
|
||||
w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0
|
||||
})
|
||||
.filter(|wire| in_window(wire))
|
||||
.count();
|
||||
let wires: Vec<_> = all_wires
|
||||
.iter()
|
||||
.filter(|w| w.name != "paper_printable_area")
|
||||
.filter(|w| {
|
||||
w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
group_splits.wires = first_wire_count;
|
||||
std::sync::Arc::make_mut(&mut content.wires).retain(in_window);
|
||||
let offset_x = (target_x / scale) - x0;
|
||||
let offset_y = (target_y / scale) - y0;
|
||||
let clip = Some((
|
||||
@@ -4863,19 +4644,18 @@ impl OpenCADStudio {
|
||||
90 | 270 => (sheet_h, sheet_w),
|
||||
_ => (sheet_w, sheet_h),
|
||||
};
|
||||
Some((
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
group_splits,
|
||||
page_w,
|
||||
page_h,
|
||||
Some(PdfPageInput {
|
||||
content,
|
||||
paper_w: page_w,
|
||||
paper_h: page_h,
|
||||
offset_x,
|
||||
offset_y,
|
||||
rotation,
|
||||
scale as f32,
|
||||
rotation_deg: rotation,
|
||||
scale: scale as f32,
|
||||
clip,
|
||||
))
|
||||
options: Self::pdf_plot_options(&self.plot_dialog),
|
||||
plot_style: self.dialog_plot_style(&self.plot_dialog),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn on_plot_style_panel_apply(&mut self) -> Task<Message> {
|
||||
|
||||
+280
-175
@@ -13,6 +13,7 @@ use crate::scene::model::hatch_model::HatchModel;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::scene::model::hatch_model::HatchPattern;
|
||||
use crate::scene::WireModel;
|
||||
use crate::scene::model::image_model::ImageModel;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use printpdf::{
|
||||
BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, Line,
|
||||
@@ -37,25 +38,18 @@ impl std::ops::Deref for PlotWire {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoded image geometry plus inherited block/viewport clip boundaries.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlotImage {
|
||||
pub image: ImageModel,
|
||||
pub clips: Vec<Vec<[f64; 2]>>,
|
||||
}
|
||||
|
||||
// The web build has no `printpdf` (it pulls a wasm-incompatible `memchr` via
|
||||
// lopdf → nom_locate) and no filesystem, so PDF export is native-only; the web
|
||||
// build gets these stubs so the call sites still compile.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn export_pdf(
|
||||
_wires: &[PlotWire],
|
||||
_hatches: &[HatchModel],
|
||||
_wipeouts: &[HatchModel],
|
||||
_paper_w: f64,
|
||||
_paper_h: f64,
|
||||
_offset_x: f64,
|
||||
_offset_y: f64,
|
||||
_rotation_deg: i32,
|
||||
_scale: f32,
|
||||
_clip: Option<(f32, f32, f32, f32)>,
|
||||
_path: &Path,
|
||||
_plot_style: Option<&PlotStyleTable>,
|
||||
_options: PdfPlotOptions,
|
||||
) -> Result<(), String> {
|
||||
pub fn export_pdf(_page: &PdfPageInput, _path: &Path) -> Result<(), String> {
|
||||
Err("PDF export is not available in the web version.".into())
|
||||
}
|
||||
|
||||
@@ -94,7 +88,6 @@ pub struct PdfPlotOptions {
|
||||
pub transparency: bool,
|
||||
pub stamp: bool,
|
||||
pub merge_lines: bool,
|
||||
pub group_splits: PlotGroupSplits,
|
||||
}
|
||||
|
||||
/// End indexes of the first paper/model render group in each flat input list.
|
||||
@@ -103,16 +96,26 @@ pub struct PlotGroupSplits {
|
||||
pub wires: usize,
|
||||
pub hatches: usize,
|
||||
pub wipeouts: usize,
|
||||
pub images: usize,
|
||||
}
|
||||
|
||||
/// Owned render data for one page in a multi-page PDF.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub struct PdfPageInput {
|
||||
#[derive(Default)]
|
||||
pub struct PlotContent {
|
||||
pub wires: std::sync::Arc<Vec<PlotWire>>,
|
||||
pub hatches: Vec<HatchModel>,
|
||||
pub wipeouts: Vec<HatchModel>,
|
||||
pub images: Vec<PlotImage>,
|
||||
pub group_splits: PlotGroupSplits,
|
||||
}
|
||||
|
||||
/// Owned geometry and settings for one PDF page.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub struct PdfPageInput {
|
||||
pub content: PlotContent,
|
||||
/// Page dimensions in mm, after any 90/270-degree rotation.
|
||||
pub paper_w: f64,
|
||||
pub paper_h: f64,
|
||||
/// Absolute-world offsets stay f64 to preserve local detail at UTM coordinates.
|
||||
pub offset_x: f64,
|
||||
pub offset_y: f64,
|
||||
pub rotation_deg: i32,
|
||||
@@ -130,50 +133,16 @@ impl Default for PdfPlotOptions {
|
||||
transparency: false,
|
||||
stamp: false,
|
||||
merge_lines: false,
|
||||
group_splits: PlotGroupSplits::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public entry point ────────────────────────────────────────────────────
|
||||
|
||||
/// Export `wires` to a PDF file.
|
||||
///
|
||||
/// - `paper_w` / `paper_h`: page dimensions in mm (already swapped for 90°/270° by caller).
|
||||
/// - `offset_x` / `offset_y`: added to every wire coordinate so the drawing
|
||||
/// origin maps to the bottom-left corner of the page.
|
||||
/// - `rotation_deg`: 0 | 90 | 180 | 270 — rotates the entire drawing on the page.
|
||||
/// Export one page to a PDF file.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn export_pdf(
|
||||
wires: &[PlotWire],
|
||||
hatches: &[HatchModel],
|
||||
wipeouts: &[HatchModel],
|
||||
paper_w: f64,
|
||||
paper_h: f64,
|
||||
offset_x: f64,
|
||||
offset_y: f64,
|
||||
rotation_deg: i32,
|
||||
scale: f32,
|
||||
clip: Option<(f32, f32, f32, f32)>,
|
||||
path: &Path,
|
||||
plot_style: Option<&PlotStyleTable>,
|
||||
options: PdfPlotOptions,
|
||||
) -> Result<(), String> {
|
||||
let bytes = build_pdf(
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
paper_w as f32,
|
||||
paper_h as f32,
|
||||
offset_x,
|
||||
offset_y,
|
||||
rotation_deg,
|
||||
scale,
|
||||
clip,
|
||||
plot_style,
|
||||
options,
|
||||
);
|
||||
write_pdf_atomically(path, &bytes)
|
||||
pub fn export_pdf(page: &PdfPageInput, path: &Path) -> Result<(), String> {
|
||||
export_pdf_pages(std::slice::from_ref(page), path, None)
|
||||
}
|
||||
|
||||
/// Export several independently sized pages into one PDF file.
|
||||
@@ -186,7 +155,7 @@ pub fn export_pdf_pages(
|
||||
if pages.is_empty() {
|
||||
return Err("No pages were selected.".into());
|
||||
}
|
||||
let bytes = build_pdf_pages(pages, plot_style);
|
||||
let bytes = build_pdf_pages(pages, plot_style)?;
|
||||
write_pdf_atomically(path, &bytes)
|
||||
}
|
||||
|
||||
@@ -205,6 +174,202 @@ fn write_pdf_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn image_clip(ops: &mut Vec<Op>, rings: Vec<Vec<[f32; 2]>>) {
|
||||
ops.push(Op::DrawPolygon {
|
||||
polygon: Polygon {
|
||||
rings: rings
|
||||
.into_iter()
|
||||
.map(|ring| PolygonRing {
|
||||
points: ring
|
||||
.into_iter()
|
||||
.map(|[x, y]| LinePoint {
|
||||
p: Point { x: Pt(x), y: Pt(y) },
|
||||
bezier: false,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
mode: PaintMode::Clip,
|
||||
winding_order: WindingOrder::EvenOdd,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn emit_image(
|
||||
doc: &mut PdfDocument,
|
||||
resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
|
||||
ops: &mut Vec<Op>,
|
||||
plot: &PlotImage,
|
||||
ox: f64,
|
||||
oy: f64,
|
||||
options: PdfPlotOptions,
|
||||
) -> Result<(), String> {
|
||||
let image = &plot.image;
|
||||
let expected_byte_count = (image.width as usize)
|
||||
.checked_mul(image.height as usize)
|
||||
.and_then(|n| n.checked_mul(4));
|
||||
if image.width == 0 || image.height == 0 || expected_byte_count != Some(image.pixels.len()) {
|
||||
return Err("Cannot plot bitmap: dimensions do not match its RGBA pixels.".into());
|
||||
}
|
||||
if image.verts.len() < 3 || image.verts.len() % 3 != 0 {
|
||||
return Err("Cannot plot bitmap: incomplete triangle geometry.".into());
|
||||
}
|
||||
if options.transparency && image.opacity <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let to_page = |high: [f32; 3], low: [f32; 3]| {
|
||||
[
|
||||
((high[0] as f64 + low[0] as f64 + ox) * MM_TO_PT as f64) as f32,
|
||||
((high[1] as f64 + low[1] as f64 + oy) * MM_TO_PT as f64) as f32,
|
||||
]
|
||||
};
|
||||
let corners: [[f32; 2]; 4] =
|
||||
std::array::from_fn(|i| to_page(image.corners[i], image.corners_low[i]));
|
||||
if !corners.iter().flatten().all(|v| v.is_finite())
|
||||
|| !image.verts.iter().all(|v| {
|
||||
to_page(v.pos, v.pos_low)
|
||||
.iter()
|
||||
.chain(&v.uv)
|
||||
.all(|c| c.is_finite())
|
||||
})
|
||||
|| !plot
|
||||
.clips
|
||||
.iter()
|
||||
.all(|ring| ring.len() >= 3 && ring.iter().flatten().all(|v| v.is_finite()))
|
||||
|| !image.opacity.is_finite()
|
||||
{
|
||||
return Err("Cannot plot bitmap: invalid coordinates, clip boundary, or opacity.".into());
|
||||
}
|
||||
let key = (
|
||||
std::sync::Arc::as_ptr(&image.pixels) as usize,
|
||||
image.width,
|
||||
image.height,
|
||||
);
|
||||
let id = resources
|
||||
.entry(key)
|
||||
.or_insert_with(|| {
|
||||
let id = printpdf::XObjectId::new();
|
||||
// add_image clones its argument; the public map accepts owned pixels.
|
||||
doc.resources.xobjects.map.insert(
|
||||
id.clone(),
|
||||
printpdf::XObject::Image(printpdf::RawImage {
|
||||
pixels: printpdf::RawImageData::U8(image.pixels.as_ref().clone()),
|
||||
width: image.width as usize,
|
||||
height: image.height as usize,
|
||||
data_format: printpdf::RawImageFormat::RGBA8,
|
||||
tag: Vec::new(),
|
||||
}),
|
||||
);
|
||||
id
|
||||
})
|
||||
.clone();
|
||||
ops.push(Op::SaveGraphicsState);
|
||||
if options.transparency && image.opacity < 1.0 {
|
||||
// printpdf 0.9.1 swaps the serialized CA/ca keys. Set both inside
|
||||
// this image's saved state so the nonstroking alpha is correct.
|
||||
let alpha = image.opacity.clamp(0.0, 1.0);
|
||||
let gs = doc.add_graphics_state(
|
||||
ExtendedGraphicsState::default()
|
||||
.with_current_fill_alpha(alpha)
|
||||
.with_current_stroke_alpha(alpha),
|
||||
);
|
||||
ops.push(Op::LoadGraphicsState { gs });
|
||||
}
|
||||
for clip in &plot.clips {
|
||||
image_clip(
|
||||
ops,
|
||||
vec![clip
|
||||
.iter()
|
||||
.map(|p| {
|
||||
[
|
||||
((p[0] + ox) * MM_TO_PT as f64) as f32,
|
||||
((p[1] + oy) * MM_TO_PT as f64) as f32,
|
||||
]
|
||||
})
|
||||
.collect()],
|
||||
);
|
||||
}
|
||||
let draw = |ops: &mut Vec<Op>, matrix: [f32; 6]| {
|
||||
ops.push(Op::SetTransformationMatrix {
|
||||
matrix: printpdf::CurTransMat::Raw(matrix),
|
||||
});
|
||||
ops.push(Op::UseXobject {
|
||||
id: id.clone(),
|
||||
// Cancel printpdf's pixel-size transform: matrix maps the PDF
|
||||
// image's unit square directly onto the CAD quad in page points.
|
||||
transform: printpdf::XObjectTransform {
|
||||
dpi: Some(72.0),
|
||||
scale_x: Some(1.0 / image.width as f32),
|
||||
scale_y: Some(1.0 / image.height as f32),
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
};
|
||||
let u = [corners[1][0] - corners[0][0], corners[1][1] - corners[0][1]];
|
||||
let v = [corners[3][0] - corners[0][0], corners[3][1] - corners[0][1]];
|
||||
let affine = (0..2).all(|axis| {
|
||||
(corners[2][axis] - corners[0][axis] - u[axis] - v[axis]).abs()
|
||||
<= 1e-5 * (u[axis].abs() + v[axis].abs()).max(1.0)
|
||||
});
|
||||
if affine {
|
||||
// A single draw preserves intrinsic IMAGE clipping without overlapping image draws.
|
||||
image_clip(
|
||||
ops,
|
||||
image
|
||||
.verts
|
||||
.chunks_exact(3)
|
||||
.map(|tri| {
|
||||
tri.iter()
|
||||
.map(|vertex| to_page(vertex.pos, vertex.pos_low))
|
||||
.collect()
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
draw(ops, [u[0], u[1], v[0], v[1], corners[0][0], corners[0][1]]);
|
||||
} else {
|
||||
// PDF transforms are affine; perspective sampling is approximate.
|
||||
for triangle in image.verts.chunks_exact(3) {
|
||||
let page = std::array::from_fn(|i| to_page(triangle[i].pos, triangle[i].pos_low));
|
||||
let uv = std::array::from_fn(|i| [triangle[i].uv[0], 1.0 - triangle[i].uv[1]]);
|
||||
let matrix = image_triangle_matrix(page, uv)
|
||||
.filter(|matrix| matrix.iter().all(|v| v.is_finite()))
|
||||
.ok_or("Cannot plot bitmap: degenerate texture mapping.")?;
|
||||
ops.push(Op::SaveGraphicsState);
|
||||
image_clip(ops, vec![page.to_vec()]);
|
||||
draw(ops, matrix);
|
||||
ops.push(Op::RestoreGraphicsState);
|
||||
}
|
||||
}
|
||||
ops.push(Op::RestoreGraphicsState);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn image_triangle_matrix(p: [[f32; 2]; 3], uv: [[f32; 2]; 3]) -> Option<[f32; 6]> {
|
||||
let u = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
|
||||
let v = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
|
||||
let determinant = u[0] * v[1] - v[0] * u[1];
|
||||
if determinant.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let axes: [[f32; 2]; 2] = std::array::from_fn(|i| {
|
||||
[
|
||||
((p[1][i] - p[0][i]) * v[1] - (p[2][i] - p[0][i]) * u[1]) / determinant,
|
||||
(u[0] * (p[2][i] - p[0][i]) - v[0] * (p[1][i] - p[0][i])) / determinant,
|
||||
]
|
||||
});
|
||||
Some([
|
||||
axes[0][0],
|
||||
axes[1][0],
|
||||
axes[0][1],
|
||||
axes[1][1],
|
||||
p[0][0] - axes[0][0] * uv[0][0] - axes[0][1] * uv[0][1],
|
||||
p[0][1] - axes[1][0] * uv[0][0] - axes[1][1] * uv[0][1],
|
||||
])
|
||||
}
|
||||
|
||||
/// Show a parented PDF save-file dialog and return the chosen path.
|
||||
///
|
||||
/// The parent comes from `iced::window::run`, keeping the portal request tied
|
||||
@@ -230,83 +395,32 @@ pub fn pick_pdf_path_owned(
|
||||
// ── PDF builder ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_pdf(
|
||||
wires: &[PlotWire],
|
||||
hatches: &[HatchModel],
|
||||
wipeouts: &[HatchModel],
|
||||
paper_w: f32,
|
||||
paper_h: f32,
|
||||
// Absolute-world offsets, kept in f64: at UTM the drawing sits at ~5e5/4.5e6
|
||||
// where an f32 has ~0.03 m / ~0.5 m of resolution, so an f32 offset is itself
|
||||
// already quantised before it can cancel the coordinate it is meant to cancel.
|
||||
ox: f64,
|
||||
oy: f64,
|
||||
rotation_deg: i32,
|
||||
scale: f32,
|
||||
clip: Option<(f32, f32, f32, f32)>,
|
||||
plot_style: Option<&PlotStyleTable>,
|
||||
options: PdfPlotOptions,
|
||||
) -> Vec<u8> {
|
||||
fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Result<Vec<u8>, String> {
|
||||
let mut doc = PdfDocument::new("Open CAD Studio Export");
|
||||
append_pdf_page(
|
||||
&mut doc,
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
paper_w,
|
||||
paper_h,
|
||||
ox,
|
||||
oy,
|
||||
rotation_deg,
|
||||
scale,
|
||||
clip,
|
||||
plot_style,
|
||||
options,
|
||||
);
|
||||
let mut warnings = Vec::new();
|
||||
doc.save(&PdfSaveOptions::default(), &mut warnings)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Vec<u8> {
|
||||
let mut doc = PdfDocument::new("Open CAD Studio Export");
|
||||
for page in pages {
|
||||
append_pdf_page(
|
||||
&mut doc,
|
||||
&page.wires,
|
||||
&page.hatches,
|
||||
&page.wipeouts,
|
||||
page.paper_w as f32,
|
||||
page.paper_h as f32,
|
||||
page.offset_x,
|
||||
page.offset_y,
|
||||
page.rotation_deg,
|
||||
page.scale,
|
||||
page.clip,
|
||||
page.plot_style.as_ref().or(plot_style),
|
||||
page.options,
|
||||
);
|
||||
// Borrowing all pages keeps their pixel Arcs alive until this cache is dropped.
|
||||
// Allocation addresses cannot be reused by another source during this export.
|
||||
let mut image_resources = std::collections::HashMap::new();
|
||||
for (index, page) in pages.iter().enumerate() {
|
||||
append_pdf_page(&mut doc, &mut image_resources, page, plot_style)
|
||||
.map_err(|error| format!("Page {}: {error}", index + 1))?;
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
doc.save(&PdfSaveOptions::default(), &mut warnings)
|
||||
Ok(doc.save(&PdfSaveOptions::default(), &mut warnings))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn append_pdf_page(
|
||||
doc: &mut PdfDocument,
|
||||
wires: &[PlotWire],
|
||||
hatches: &[HatchModel],
|
||||
wipeouts: &[HatchModel],
|
||||
paper_w: f32,
|
||||
paper_h: f32,
|
||||
ox: f64,
|
||||
oy: f64,
|
||||
rotation_deg: i32,
|
||||
scale: f32,
|
||||
clip: Option<(f32, f32, f32, f32)>,
|
||||
plot_style: Option<&PlotStyleTable>,
|
||||
options: PdfPlotOptions,
|
||||
) {
|
||||
image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
|
||||
page: &PdfPageInput,
|
||||
fallback_plot_style: Option<&PlotStyleTable>,
|
||||
) -> Result<(), String> {
|
||||
let PlotContent { wires, hatches, wipeouts, images, group_splits } = &page.content;
|
||||
let (paper_w, paper_h) = (page.paper_w as f32, page.paper_h as f32);
|
||||
let (ox, oy) = (page.offset_x, page.offset_y);
|
||||
let (rotation_deg, scale, clip) = (page.rotation_deg, page.scale, page.clip);
|
||||
let plot_style = page.plot_style.as_ref().or(fallback_plot_style);
|
||||
let options = page.options;
|
||||
let mut ops: Vec<Op> = Vec::new();
|
||||
|
||||
// White page background.
|
||||
@@ -411,23 +525,26 @@ fn append_pdf_page(
|
||||
|
||||
|
||||
let (first_wires, second_wires) =
|
||||
wires.split_at(options.group_splits.wires.min(wires.len()));
|
||||
wires.split_at(group_splits.wires.min(wires.len()));
|
||||
let (first_hatches, second_hatches) =
|
||||
hatches.split_at(options.group_splits.hatches.min(hatches.len()));
|
||||
hatches.split_at(group_splits.hatches.min(hatches.len()));
|
||||
let (first_wipeouts, second_wipeouts) =
|
||||
wipeouts.split_at(options.group_splits.wipeouts.min(wipeouts.len()));
|
||||
for (wires, hatches, wipeouts) in [
|
||||
(first_wires, first_hatches, first_wipeouts),
|
||||
(second_wires, second_hatches, second_wipeouts),
|
||||
wipeouts.split_at(group_splits.wipeouts.min(wipeouts.len()));
|
||||
let (first_images, second_images) =
|
||||
images.split_at(group_splits.images.min(images.len()));
|
||||
for (wires, hatches, wipeouts, images) in [
|
||||
(first_wires, first_hatches, first_wipeouts, first_images),
|
||||
(second_wires, second_hatches, second_wipeouts, second_images),
|
||||
] {
|
||||
enum DrawItem<'a> {
|
||||
WireFill(&'a PlotWire),
|
||||
Hatch(&'a HatchModel),
|
||||
Image(&'a PlotImage),
|
||||
Wire(&'a PlotWire),
|
||||
Text(&'a PlotWire),
|
||||
}
|
||||
|
||||
let mut draw_items = Vec::with_capacity(wires.len() * 2 + hatches.len() + wipeouts.len());
|
||||
let mut draw_items = Vec::with_capacity(wires.len() * 2 + hatches.len() + wipeouts.len() + images.len());
|
||||
let mut sequence = 0usize;
|
||||
for wire in wires {
|
||||
if !wire.fill_tris.is_empty() {
|
||||
@@ -445,6 +562,10 @@ fn append_pdf_page(
|
||||
draw_items.push((hatch.draw_depth, 1u8, sequence, DrawItem::Hatch(hatch)));
|
||||
sequence += 1;
|
||||
}
|
||||
for image in images {
|
||||
draw_items.push((image.image.draw_depth, 1u8, sequence, DrawItem::Image(image)));
|
||||
sequence += 1;
|
||||
}
|
||||
draw_items.sort_by(|a, b| {
|
||||
a.0.total_cmp(&b.0)
|
||||
.then_with(|| a.1.cmp(&b.1))
|
||||
@@ -508,6 +629,10 @@ fn append_pdf_page(
|
||||
last_dash = None;
|
||||
continue;
|
||||
}
|
||||
DrawItem::Image(image) => {
|
||||
emit_image(doc, image_resources, &mut ops, image, ox, oy, options)?;
|
||||
continue;
|
||||
}
|
||||
DrawItem::Wire(wire) => wire,
|
||||
};
|
||||
let [mut r, mut g, mut b, a] = wire.color;
|
||||
@@ -722,6 +847,7 @@ fn append_pdf_page(
|
||||
|
||||
let page = PdfPage::new(Mm(paper_w), Mm(paper_h), ops);
|
||||
doc.pages.push(page);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a PDF dash array (in points) from a WireModel linetype pattern.
|
||||
@@ -1517,6 +1643,21 @@ fn emit_text(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_page(wires: Vec<PlotWire>) -> PdfPageInput {
|
||||
PdfPageInput {
|
||||
content: PlotContent { wires: std::sync::Arc::new(wires), ..Default::default() },
|
||||
paper_w: 210.0,
|
||||
paper_h: 297.0,
|
||||
offset_x: 0.0,
|
||||
offset_y: 0.0,
|
||||
rotation_deg: 0,
|
||||
scale: 1.0,
|
||||
clip: None,
|
||||
options: PdfPlotOptions::default(),
|
||||
plot_style: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_replaces_an_existing_pdf() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
@@ -1546,20 +1687,10 @@ mod tests {
|
||||
),
|
||||
draw_depth: 0.0,
|
||||
};
|
||||
let bytes = build_pdf(
|
||||
&[w],
|
||||
&[],
|
||||
&[],
|
||||
210.0,
|
||||
297.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0,
|
||||
2.0,
|
||||
Some((10.0, 10.0, 100.0, 100.0)),
|
||||
None,
|
||||
PdfPlotOptions::default(),
|
||||
);
|
||||
let mut page = test_page(vec![w]);
|
||||
page.scale = 2.0;
|
||||
page.clip = Some((10.0, 10.0, 100.0, 100.0));
|
||||
let bytes = build_pdf_pages(&[page], None).unwrap();
|
||||
// A valid PDF is produced (starts with the PDF header) and is non-trivial.
|
||||
assert!(bytes.starts_with(b"%PDF"), "not a PDF");
|
||||
assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len());
|
||||
@@ -1594,34 +1725,8 @@ mod tests {
|
||||
let mut blank = wire.clone();
|
||||
blank.wire.text_verts.clear();
|
||||
|
||||
let with_text = build_pdf(
|
||||
&[wire],
|
||||
&[],
|
||||
&[],
|
||||
210.0,
|
||||
297.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0,
|
||||
1.0,
|
||||
None,
|
||||
None,
|
||||
PdfPlotOptions::default(),
|
||||
);
|
||||
let no_text = build_pdf(
|
||||
&[blank],
|
||||
&[],
|
||||
&[],
|
||||
210.0,
|
||||
297.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0,
|
||||
1.0,
|
||||
None,
|
||||
None,
|
||||
PdfPlotOptions::default(),
|
||||
);
|
||||
let with_text = build_pdf_pages(&[test_page(vec![wire])], None).unwrap();
|
||||
let no_text = build_pdf_pages(&[test_page(vec![blank])], None).unwrap();
|
||||
assert!(with_text.starts_with(b"%PDF"));
|
||||
assert!(
|
||||
with_text.len() > no_text.len(),
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::io::pdf_export;
|
||||
use crate::io::plot_style::PlotStyleTable;
|
||||
use crate::scene::model::hatch_model::HatchModel;
|
||||
use crate::io::pdf_export::PlotWire;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn temp_pdf_path(kind: &str) -> std::path::PathBuf {
|
||||
@@ -50,8 +47,6 @@ pub struct PrintOptions {
|
||||
/// setting wins, so the field is legitimately unread there.
|
||||
#[cfg_attr(target_os = "windows", allow(dead_code))]
|
||||
pub quality: Option<String>,
|
||||
/// Controls applied while building the intermediate PDF.
|
||||
pub render: crate::io::pdf_export::PdfPlotOptions,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -60,19 +55,8 @@ pub fn list_printers() -> Vec<String> {
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn print_wires_with(
|
||||
_wires: std::sync::Arc<Vec<PlotWire>>,
|
||||
_hatches: Vec<HatchModel>,
|
||||
_wipeouts: Vec<HatchModel>,
|
||||
_paper_w: f64,
|
||||
_paper_h: f64,
|
||||
_offset_x: f64,
|
||||
_offset_y: f64,
|
||||
_rotation_deg: i32,
|
||||
_scale: f32,
|
||||
_clip: Option<(f32, f32, f32, f32)>,
|
||||
_plot_style: Option<PlotStyleTable>,
|
||||
_page: crate::io::pdf_export::PdfPageInput,
|
||||
_opts: PrintOptions,
|
||||
) -> Result<String, String> {
|
||||
Err("Printing is not available in the web version.".into())
|
||||
@@ -304,46 +288,18 @@ pub fn open_printer_properties(_printer: Option<&str>) -> Result<(), String> {
|
||||
Err("Printer properties are not available in the web version.".into())
|
||||
}
|
||||
|
||||
/// Like [`print_wires`] but honours a [`PrintOptions`] bundle (printer, copies,
|
||||
/// grayscale, quality, DPI).
|
||||
/// Render a page and send it to the selected printer.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn print_wires_with(
|
||||
wires: std::sync::Arc<Vec<PlotWire>>,
|
||||
hatches: Vec<HatchModel>,
|
||||
wipeouts: Vec<HatchModel>,
|
||||
paper_w: f64,
|
||||
paper_h: f64,
|
||||
offset_x: f64,
|
||||
offset_y: f64,
|
||||
rotation_deg: i32,
|
||||
scale: f32,
|
||||
clip: Option<(f32, f32, f32, f32)>,
|
||||
plot_style: Option<PlotStyleTable>,
|
||||
page: crate::io::pdf_export::PdfPageInput,
|
||||
opts: PrintOptions,
|
||||
) -> Result<String, String> {
|
||||
let tmp_path = temp_pdf_path("print");
|
||||
pdf_export::export_pdf(
|
||||
&wires,
|
||||
&hatches,
|
||||
&wipeouts,
|
||||
paper_w,
|
||||
paper_h,
|
||||
offset_x,
|
||||
offset_y,
|
||||
rotation_deg,
|
||||
scale,
|
||||
clip,
|
||||
&tmp_path,
|
||||
plot_style.as_ref(),
|
||||
opts.render,
|
||||
)?;
|
||||
pdf_export::export_pdf(&page, &tmp_path)?;
|
||||
dispatch_to_printer_opts(&tmp_path, &opts)
|
||||
}
|
||||
|
||||
/// Send an already-rendered PDF to a printer with [`PrintOptions`]. Used for
|
||||
/// clipped window plots, whose PDF is built with a scale + clip the plain
|
||||
/// `print_wires_with` path doesn't expose.
|
||||
/// Send an already-rendered PDF to the selected printer.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn print_existing_pdf(path: &std::path::Path, opts: &PrintOptions) -> Result<String, String> {
|
||||
dispatch_to_printer_opts(path, opts)
|
||||
|
||||
+48
-2
@@ -6655,6 +6655,49 @@ impl Scene {
|
||||
all_visible: bool,
|
||||
viewport: Option<Handle>,
|
||||
) -> Vec<ImageModel> {
|
||||
self.collect_images(target_block, frozen, annotation_scale_handle, all_visible,
|
||||
viewport, false, |image, _| image)
|
||||
}
|
||||
|
||||
pub fn paper_plot_images(&self) -> Vec<crate::io::pdf_export::PlotImage> {
|
||||
let scale = if self.current_layout == "Model" {
|
||||
self.displayed_annotation_scale_handle()
|
||||
} else {
|
||||
self.paper_annotation_scale_handle()
|
||||
};
|
||||
self.placed_images(self.current_layout_block_handle(), None,
|
||||
scale, self.annotation_all_visible(), None, true)
|
||||
}
|
||||
|
||||
fn placed_images(
|
||||
&self,
|
||||
target_block: Handle,
|
||||
frozen: Option<&HashSet<Handle>>,
|
||||
annotation_scale_handle: Option<Handle>,
|
||||
all_visible: bool,
|
||||
viewport: Option<Handle>,
|
||||
plotting: bool,
|
||||
) -> Vec<crate::io::pdf_export::PlotImage> {
|
||||
self.collect_images(target_block, frozen, annotation_scale_handle, all_visible,
|
||||
viewport, plotting, |image, context| crate::io::pdf_export::PlotImage {
|
||||
image,
|
||||
clips: context.clips.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_images<T>(
|
||||
&self,
|
||||
target_block: Handle,
|
||||
frozen: Option<&HashSet<Handle>>,
|
||||
annotation_scale_handle: Option<Handle>,
|
||||
all_visible: bool,
|
||||
viewport: Option<Handle>,
|
||||
plotting: bool,
|
||||
mut collect: impl FnMut(ImageModel, &render_graph::InstanceContext) -> T,
|
||||
) -> Vec<T> {
|
||||
if self.images.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let depth_map = self.draw_depth_map();
|
||||
let graph = render_graph::RenderSceneGraph::new(
|
||||
&self.document,
|
||||
@@ -6680,7 +6723,10 @@ impl Scene {
|
||||
graph.walk_root(
|
||||
root,
|
||||
|entity, context| {
|
||||
context.is_instanced() || !self.entity_temporarily_hidden(entity.common().handle)
|
||||
(!plotting || self.document.layers.get(&entity.common().layer)
|
||||
.is_none_or(|layer| layer.is_plottable))
|
||||
&& (context.is_instanced()
|
||||
|| !self.entity_temporarily_hidden(entity.common().handle))
|
||||
},
|
||||
|entity, context| {
|
||||
let handle = entity.common().handle;
|
||||
@@ -6739,7 +6785,7 @@ impl Scene {
|
||||
});
|
||||
}
|
||||
placed.draw_depth = context.draw_depth(handle, depth_map.as_ref());
|
||||
models.push(placed);
|
||||
models.push(collect(placed, context));
|
||||
},
|
||||
);
|
||||
models
|
||||
|
||||
+56
-3
@@ -386,12 +386,12 @@ impl Scene {
|
||||
/// needs the equivalent paper-space geometry explicitly.
|
||||
pub fn viewport_plot_fills(
|
||||
&self,
|
||||
) -> (Vec<(WireModel, f32)>, Vec<HatchModel>, Vec<HatchModel>) {
|
||||
) -> (Vec<(WireModel, f32)>, Vec<HatchModel>, Vec<HatchModel>, Vec<crate::io::pdf_export::PlotImage>) {
|
||||
use acadrust::entities::Viewport;
|
||||
use model::hatch_model::HatchPattern;
|
||||
|
||||
if self.current_layout == "Model" {
|
||||
return (Vec::new(), Vec::new(), Vec::new());
|
||||
return (Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||
}
|
||||
let paper_block = self.current_layout_block_handle();
|
||||
let model_block = self.model_space_block_handle();
|
||||
@@ -411,6 +411,7 @@ impl Scene {
|
||||
let mut pattern_wires = Vec::new();
|
||||
let mut projected_hatches = Vec::new();
|
||||
let mut projected_wipeouts = Vec::new();
|
||||
let mut projected_images = Vec::new();
|
||||
|
||||
for viewport in viewports {
|
||||
let Some(camera) = self.camera_for_viewport(viewport.common.handle) else {
|
||||
@@ -463,6 +464,58 @@ impl Scene {
|
||||
|
||||
let frozen: rustc_hash::FxHashSet<Handle> =
|
||||
viewport.frozen_layers.iter().copied().collect();
|
||||
let mut viewport_clips = vec![vec![
|
||||
[xmin as f64, ymin as f64], [xmax as f64, ymin as f64],
|
||||
[xmax as f64, ymax as f64], [xmin as f64, ymax as f64],
|
||||
]];
|
||||
if !self.images.is_empty() && !viewport.clip_boundary_handle.is_null() {
|
||||
let boundary = self.clip_boundary_polygon(
|
||||
viewport.clip_boundary_handle, viewport.center.z as f32);
|
||||
if boundary.len() >= 3 {
|
||||
viewport_clips.push(boundary.iter().map(|p| [p[0] as f64, p[1] as f64]).collect());
|
||||
}
|
||||
}
|
||||
for mut plot in self.placed_images(model_block, Some(&frozen),
|
||||
self.viewport_scale_handle(viewport.common.handle),
|
||||
self.annotation_all_visible(), Some(viewport.common.handle), true)
|
||||
{
|
||||
let mut valid = true;
|
||||
let mut project_point = |high: &mut [f32; 3], low: &mut [f32; 3]| {
|
||||
let point = std::array::from_fn(|i| high[i] as f64 + low[i] as f64);
|
||||
if let Some([x, y]) = project_3d(point) {
|
||||
*high = [x, y, 0.0];
|
||||
*low = [0.0; 3];
|
||||
} else {
|
||||
valid = false;
|
||||
}
|
||||
};
|
||||
for (high, low) in plot.image.corners.iter_mut().zip(&mut plot.image.corners_low) {
|
||||
project_point(high, low);
|
||||
}
|
||||
for vertex in &mut plot.image.verts {
|
||||
project_point(&mut vertex.pos, &mut vertex.pos_low);
|
||||
}
|
||||
for clip in &mut plot.clips {
|
||||
for point in clip {
|
||||
if let Some([x, y]) = project(point[0], point[1]) {
|
||||
*point = [x as f64, y as f64];
|
||||
} else {
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
continue;
|
||||
}
|
||||
if [(0, xmin, xmax), (1, ymin, ymax)].iter().any(|&(axis, min, max)| {
|
||||
plot.image.verts.iter().all(|v| v.pos[axis] < min)
|
||||
|| plot.image.verts.iter().all(|v| v.pos[axis] > max)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
plot.clips.extend(viewport_clips.iter().cloned());
|
||||
projected_images.push(plot);
|
||||
}
|
||||
let hatches = self.plot_hatches_for_block(
|
||||
model_block,
|
||||
Some(&frozen),
|
||||
@@ -536,7 +589,7 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
|
||||
(pattern_wires, projected_hatches, projected_wipeouts)
|
||||
(pattern_wires, projected_hatches, projected_wipeouts, projected_images)
|
||||
}
|
||||
|
||||
/// A content viewport's clip boundary, projected into that viewport's
|
||||
|
||||
+7522
File diff suppressed because it is too large.
Load diff
+8
@@ -0,0 +1,8 @@
|
||||
Synthetic regression fixture for #1266. Generated with ezdxf/Pillow from basic
|
||||
geometry; it contains no external references or customer drawing content.
|
||||
|
||||
Layout1 has an INSERT of SyntheticImage at (2, 4). That block contains one
|
||||
OLE2FRAME whose compound-file CONTENTS stream embeds a 64 x 32 BMP: red on
|
||||
the left, blue on the right, with a green diagonal from top-left to bottom-right.
|
||||
The frame occupies (0, 0) to (2, 1) in the block. Text and a triangle provide
|
||||
vector controls for manually opening the fixture and checking Plot Preview.
|
||||
@@ -0,0 +1,512 @@
|
||||
//! #1266: decoded IMAGE/OLE content must survive the shared PDF/preview/print path.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use acadrust::entities::{Insert, Viewport, Wipeout};
|
||||
use acadrust::types::{Vector2, Vector3};
|
||||
use acadrust::EntityType;
|
||||
use std::sync::Arc;
|
||||
use OpenCADStudio::io::pdf_export::{
|
||||
export_pdf, export_pdf_pages, PdfPageInput, PdfPlotOptions, PlotContent, PlotGroupSplits,
|
||||
PlotImage, PlotWire,
|
||||
};
|
||||
use OpenCADStudio::scene::model::image_model::{ImageModel, ImageQuadVertex};
|
||||
use OpenCADStudio::scene::{Scene, WireModel};
|
||||
|
||||
fn embedded_scene() -> Scene {
|
||||
let mut scene = Scene::new();
|
||||
scene.document = OpenCADStudio::io::load_bytes(
|
||||
"embedded-image-print.dxf",
|
||||
include_bytes!("fixtures/embedded-image-print.dxf").to_vec(),
|
||||
)
|
||||
.expect("synthetic embedded bitmap DXF");
|
||||
scene.images =
|
||||
OpenCADStudio::scene::build_derived_caches_with_progress(&scene.document, &|_| {}, None)
|
||||
.images;
|
||||
scene.set_current_layout("Layout1".into());
|
||||
scene
|
||||
}
|
||||
|
||||
fn page(images: Vec<PlotImage>) -> PdfPageInput {
|
||||
PdfPageInput {
|
||||
content: PlotContent {
|
||||
images,
|
||||
..Default::default()
|
||||
},
|
||||
paper_w: 100.0,
|
||||
paper_h: 100.0,
|
||||
offset_x: 0.0,
|
||||
offset_y: 0.0,
|
||||
rotation_deg: 0,
|
||||
scale: 1.0,
|
||||
clip: None,
|
||||
options: PdfPlotOptions::default(),
|
||||
plot_style: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn export(pages: &[PdfPageInput]) -> Vec<u8> {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ocs1266-{}-{}.pdf",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
));
|
||||
if let [page] = pages {
|
||||
export_pdf(page, &path).expect("export bitmap PDF");
|
||||
} else {
|
||||
export_pdf_pages(pages, &path, None).expect("export bitmap PDF pages");
|
||||
}
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
struct Raster {
|
||||
pixels: Vec<u8>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
scale: f32,
|
||||
}
|
||||
impl Raster {
|
||||
fn from_pdf(bytes: &[u8], page: usize) -> Self {
|
||||
Self::at_scale(bytes, page, 2.0)
|
||||
}
|
||||
|
||||
fn at_scale(bytes: &[u8], page: usize, scale: f32) -> Self {
|
||||
let pdf = hayro::hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap();
|
||||
let pixmap = hayro::render(
|
||||
&pdf.pages()[page],
|
||||
&hayro::RenderCache::new(),
|
||||
&hayro::hayro_interpret::InterpreterSettings::default(),
|
||||
&hayro::RenderSettings {
|
||||
x_scale: scale,
|
||||
y_scale: scale,
|
||||
bg_color: hayro::vello_cpu::color::palette::css::WHITE,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
Self {
|
||||
pixels: pixmap.data_as_u8_slice().to_vec(),
|
||||
width: u32::from(pixmap.width()) as usize,
|
||||
height: u32::from(pixmap.height()) as usize,
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_color(&self, x_mm: f32, y_mm: f32, expected: [u8; 3]) {
|
||||
let x = (x_mm * 72.0 / 25.4 * self.scale) as usize;
|
||||
let y = self.height - 1 - (y_mm * 72.0 / 25.4 * self.scale) as usize;
|
||||
let actual = &self.pixels[(y * self.width + x) * 4..][..3];
|
||||
assert!(
|
||||
actual
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(&a, b)| a.abs_diff(b) < 25),
|
||||
"at ({x_mm}, {y_mm}): expected {expected:?}, got {actual:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn image() -> PlotImage {
|
||||
let corners = [
|
||||
[10.0, 10.0, 0.0],
|
||||
[50.0, 10.0, 0.0],
|
||||
[50.0, 50.0, 0.0],
|
||||
[10.0, 50.0, 0.0],
|
||||
];
|
||||
let uv = [[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]];
|
||||
// Four distinct quadrants detect flips, reflections, and diagonal seams.
|
||||
let pixels = (0..32)
|
||||
.flat_map(|y| {
|
||||
(0..32).flat_map(move |x| match (x < 16, y < 16) {
|
||||
(true, true) => [255, 0, 0, 255],
|
||||
(false, true) => [0, 0, 255, 255],
|
||||
(true, false) => [0, 255, 0, 255],
|
||||
(false, false) => [255, 255, 0, 255],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
PlotImage {
|
||||
image: ImageModel {
|
||||
render_instance: None,
|
||||
file_path: "synthetic".into(),
|
||||
pixels: Arc::new(pixels),
|
||||
width: 32,
|
||||
height: 32,
|
||||
opacity: 1.0,
|
||||
corners,
|
||||
corners_low: [[0.0; 3]; 4],
|
||||
draw_depth: 0.5,
|
||||
verts: [0, 1, 2, 0, 2, 3]
|
||||
.into_iter()
|
||||
.map(|i| ImageQuadVertex {
|
||||
pos: corners[i],
|
||||
pos_low: [0.0; 3],
|
||||
uv: uv[i],
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
clips: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_ole_inside_insert_reaches_rendered_pdf() {
|
||||
let scene = embedded_scene();
|
||||
let images = scene.paper_plot_images();
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!((images[0].image.width, images[0].image.height), (64, 32));
|
||||
assert!(scene
|
||||
.images
|
||||
.values()
|
||||
.any(|source| Arc::ptr_eq(&source.pixels, &images[0].image.pixels)));
|
||||
let mut input = page(images);
|
||||
input.scale = 10.0;
|
||||
let raster = Raster::from_pdf(&export(&[input]), 0);
|
||||
raster.assert_color(23.0, 42.0, [255, 0, 0]);
|
||||
raster.assert_color(37.0, 48.0, [0, 0, 255]);
|
||||
raster.assert_color(25.0, 47.5, [0, 255, 0]);
|
||||
raster.assert_color(45.0, 45.0, [255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_nonuniform_insert_preserves_pixel_orientation() {
|
||||
let mut scene = embedded_scene();
|
||||
let handle = scene
|
||||
.document
|
||||
.entities()
|
||||
.find_map(|e| match e {
|
||||
EntityType::Insert(i) if i.block_name == "SyntheticImage" => Some(i.common.handle),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let Some(EntityType::Insert(insert)) = scene.document.get_entity_mut(handle) else {
|
||||
panic!()
|
||||
};
|
||||
insert.insert_point = Vector3::new(60.0, 20.0, 0.0);
|
||||
insert.rotation = std::f64::consts::FRAC_PI_2;
|
||||
insert.set_x_scale(20.0);
|
||||
insert.set_y_scale(10.0);
|
||||
let raster = Raster::from_pdf(&export(&[page(scene.paper_plot_images())]), 0);
|
||||
raster.assert_color(58.0, 26.0, [255, 0, 0]);
|
||||
raster.assert_color(52.0, 54.0, [0, 0, 255]);
|
||||
raster.assert_color(52.5, 30.0, [0, 255, 0]);
|
||||
raster.assert_color(65.0, 40.0, [255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_page_transform_and_shared_resources_survive_serialization() {
|
||||
let mut first = image();
|
||||
// A visible-region triangle (IMAGE clip), plus inherited and viewport clips.
|
||||
first.image.verts.truncate(3);
|
||||
first
|
||||
.clips
|
||||
.push(vec![[15.0, 5.0], [55.0, 5.0], [55.0, 45.0], [15.0, 45.0]]);
|
||||
first
|
||||
.clips
|
||||
.push(vec![[5.0, 15.0], [45.0, 15.0], [45.0, 55.0], [5.0, 55.0]]);
|
||||
let mut second = page(vec![first.clone()]);
|
||||
second.rotation_deg = 90;
|
||||
second.scale = 0.5;
|
||||
second.offset_x = 10.0;
|
||||
second.offset_y = 10.0;
|
||||
let bytes = export(&[page(vec![first]), second]);
|
||||
let parsed = printpdf::PdfDocument::parse(
|
||||
&bytes,
|
||||
&printpdf::PdfParseOptions::default(),
|
||||
&mut Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let uses: Vec<_> = parsed
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|p| p.ops.iter())
|
||||
.filter_map(|op| {
|
||||
if let printpdf::Op::UseXobject { id, .. } = op {
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
uses.len(),
|
||||
2,
|
||||
"one image draw per affine quad, without a diagonal seam"
|
||||
);
|
||||
assert_eq!(uses[0], uses[1], "pages should share one image resource");
|
||||
let raster = Raster::from_pdf(&bytes, 0);
|
||||
raster.assert_color(40.0, 20.0, [255, 255, 0]);
|
||||
for (x, y) in [(12.0, 20.0), (48.0, 20.0), (20.0, 40.0), (40.0, 12.0)] {
|
||||
raster.assert_color(x, y, [255, 255, 255]);
|
||||
}
|
||||
Raster::from_pdf(&bytes, 1).assert_color(85.0, 25.0, [255, 255, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_alpha_and_plot_transparency_are_preserved() {
|
||||
let mut plot = image();
|
||||
plot.image.opacity = 0.5;
|
||||
// Intrinsic transparent texels remain transparent with either plot setting.
|
||||
let pixels = Arc::make_mut(&mut plot.image.pixels);
|
||||
for y in 16..32 {
|
||||
for x in 0..16 {
|
||||
pixels[(y * 32 + x) * 4 + 3] = 0;
|
||||
}
|
||||
}
|
||||
let opaque = page(vec![plot.clone()]);
|
||||
let mut transparent = page(vec![plot]);
|
||||
transparent.options.transparency = true;
|
||||
let bytes = export(&[opaque, transparent]);
|
||||
let off = Raster::from_pdf(&bytes, 0);
|
||||
off.assert_color(20.0, 40.0, [255, 0, 0]);
|
||||
off.assert_color(20.0, 20.0, [255, 255, 255]);
|
||||
let on = Raster::from_pdf(&bytes, 1);
|
||||
on.assert_color(20.0, 40.0, [255, 128, 128]);
|
||||
on.assert_color(20.0, 20.0, [255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_and_paper_model_groups_order_images_with_wipeouts_and_wires() {
|
||||
let mut scene = Scene::new();
|
||||
let wipeout = Wipeout::polygonal(
|
||||
&[
|
||||
Vector2::new(10.0, 30.0),
|
||||
Vector2::new(30.0, 30.0),
|
||||
Vector2::new(30.0, 50.0),
|
||||
Vector2::new(10.0, 50.0),
|
||||
],
|
||||
0.0,
|
||||
);
|
||||
scene.add_entity(EntityType::Wipeout(wipeout));
|
||||
let mut input = page(vec![image()]);
|
||||
input.content.wipeouts = scene.paper_plot_wipeouts().as_ref().clone();
|
||||
assert_eq!(input.content.wipeouts.len(), 1);
|
||||
input.content.wipeouts[0].draw_depth = 0.8;
|
||||
let mut wire = WireModel::solid(
|
||||
"over image".into(),
|
||||
vec![[35.0, 15.0, 0.0], [45.0, 15.0, 0.0]],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
false,
|
||||
);
|
||||
wire.line_weight_px = 8.0;
|
||||
input.content.wires = Arc::new(vec![PlotWire {
|
||||
wire,
|
||||
draw_depth: 0.9,
|
||||
}]);
|
||||
let raster = Raster::from_pdf(&export(&[input]), 0);
|
||||
raster.assert_color(20.0, 40.0, [255, 255, 255]);
|
||||
raster.assert_color(40.0, 40.0, [0, 0, 255]);
|
||||
raster.assert_color(40.0, 15.0, [0, 0, 0]);
|
||||
|
||||
// First group wins by depth internally, second group still paints last.
|
||||
let mut input = page(vec![image()]);
|
||||
input.content.wipeouts = scene.paper_plot_wipeouts().as_ref().clone();
|
||||
input.content.wipeouts[0].draw_depth = 0.9;
|
||||
input.content.group_splits = PlotGroupSplits {
|
||||
wipeouts: 1,
|
||||
..Default::default()
|
||||
};
|
||||
Raster::from_pdf(&export(&[input]), 0).assert_color(20.0, 40.0, [255, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_projection_clips_images_and_respects_frozen_and_nonplot_layers() {
|
||||
let mut scene = embedded_scene();
|
||||
scene.set_current_layout("Model".into());
|
||||
scene.ensure_layer("Bitmap");
|
||||
let mut insert = Insert::new("SyntheticImage", Vector3::new(0.0, 0.0, 0.0));
|
||||
insert.common.layer = "Bitmap".into();
|
||||
insert.set_x_scale(20.0);
|
||||
insert.set_y_scale(20.0);
|
||||
scene.add_entity(EntityType::Insert(insert));
|
||||
scene.set_current_layout("Layout1".into());
|
||||
let mut viewport = Viewport::new();
|
||||
viewport.center = Vector3::new(50.0, 50.0, 0.0);
|
||||
viewport.width = 20.0;
|
||||
viewport.height = 20.0;
|
||||
viewport.view_height = 20.0;
|
||||
viewport.view_center = Vector3::new(20.0, 10.0, 0.0);
|
||||
viewport.id = 2;
|
||||
viewport.status.is_on = true;
|
||||
let handle = scene.add_entity(EntityType::Viewport(viewport));
|
||||
let (_, _, _, images) = scene.viewport_plot_fills();
|
||||
assert_eq!(images.len(), 1);
|
||||
let raster = Raster::from_pdf(&export(&[page(images)]), 0);
|
||||
raster.assert_color(42.0, 43.0, [255, 0, 0]);
|
||||
raster.assert_color(58.0, 57.0, [0, 0, 255]);
|
||||
raster.assert_color(35.0, 45.0, [255, 255, 255]);
|
||||
let layer = scene.document.layers.get("Bitmap").unwrap().handle;
|
||||
let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(handle) else {
|
||||
panic!()
|
||||
};
|
||||
vp.frozen_layers.push(layer);
|
||||
assert!(scene.viewport_plot_fills().3.is_empty());
|
||||
let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(handle) else {
|
||||
panic!()
|
||||
};
|
||||
vp.frozen_layers.clear();
|
||||
scene
|
||||
.document
|
||||
.layers
|
||||
.get_mut("Bitmap")
|
||||
.unwrap()
|
||||
.is_plottable = false;
|
||||
assert!(scene.viewport_plot_fills().3.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_image_uses_decoded_pixels_and_its_image_clip_without_reopening() {
|
||||
use acadrust::entities::{ClipBoundary, RasterImage};
|
||||
let path = std::env::temp_dir().join(format!("ocs1266-linked-{}.png", std::process::id()));
|
||||
let source = image();
|
||||
image::save_buffer(&path, &source.image.pixels, 32, 32, image::ColorType::Rgba8).unwrap();
|
||||
let mut raster = RasterImage::new(
|
||||
path.to_str().unwrap(),
|
||||
Vector3::new(10.0, 10.0, 0.0),
|
||||
32.0,
|
||||
32.0,
|
||||
);
|
||||
raster.u_vector = Vector3::new(1.25, 0.0, 0.0);
|
||||
raster.v_vector = Vector3::new(0.0, 1.25, 0.0);
|
||||
raster.clipping_enabled = true;
|
||||
raster.clip_boundary =
|
||||
ClipBoundary::rectangular(Vector2::new(0.0, 0.0), Vector2::new(16.0, 32.0));
|
||||
let mut scene = Scene::new();
|
||||
let handle = scene.add_entity(EntityType::RasterImage(raster));
|
||||
scene.populate_images_from_document();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
let images = scene.paper_plot_images();
|
||||
assert_eq!(images.len(), 1);
|
||||
assert!(Arc::ptr_eq(
|
||||
&images[0].image.pixels,
|
||||
&scene.images[&handle].pixels
|
||||
));
|
||||
let rendered = Raster::from_pdf(&export(&[page(images)]), 0);
|
||||
rendered.assert_color(20.0, 40.0, [255, 0, 0]);
|
||||
rendered.assert_color(20.0, 20.0, [0, 255, 0]);
|
||||
rendered.assert_color(40.0, 40.0, [255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_sized_sources_remain_distinct_across_pages() {
|
||||
let pages = {
|
||||
let red = image();
|
||||
let mut cyan = image();
|
||||
Arc::make_mut(&mut cyan.image.pixels)
|
||||
.chunks_exact_mut(4)
|
||||
.for_each(|pixel| pixel.copy_from_slice(&[0, 255, 255, 255]));
|
||||
assert!(!Arc::ptr_eq(&red.image.pixels, &cyan.image.pixels));
|
||||
vec![
|
||||
page(vec![red.clone()]),
|
||||
page(vec![cyan.clone()]),
|
||||
page(vec![red]),
|
||||
page(vec![cyan]),
|
||||
]
|
||||
};
|
||||
let bytes = export(&pages);
|
||||
let parsed = printpdf::PdfDocument::parse(
|
||||
&bytes,
|
||||
&printpdf::PdfParseOptions::default(),
|
||||
&mut Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let uses: Vec<_> = parsed
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|page| &page.ops)
|
||||
.filter_map(|op| {
|
||||
if let printpdf::Op::UseXobject { id, .. } = op {
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(uses.len(), 4);
|
||||
assert_ne!(uses[0], uses[1]);
|
||||
assert_eq!(uses[0], uses[2]);
|
||||
assert_eq!(uses[1], uses[3]);
|
||||
for (index, expected) in [[255, 0, 0], [0, 255, 255], [255, 0, 0], [0, 255, 255]]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
Raster::from_pdf(&bytes, index).assert_color(20.0, 40.0, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_triangle_clip_has_no_shared_edge_seam() {
|
||||
let mut plot = image();
|
||||
Arc::make_mut(&mut plot.image.pixels)
|
||||
.chunks_exact_mut(4)
|
||||
.for_each(|pixel| pixel.copy_from_slice(&[0, 0, 0, 128]));
|
||||
let mut sheared = plot.clone();
|
||||
let transform = |[x, y, z]: [f32; 3]| [x + 0.27 * y + 5.0, 0.19 * x + y + 3.0, z];
|
||||
sheared
|
||||
.image
|
||||
.corners
|
||||
.iter_mut()
|
||||
.for_each(|p| *p = transform(*p));
|
||||
sheared
|
||||
.image
|
||||
.verts
|
||||
.iter_mut()
|
||||
.for_each(|v| v.pos = transform(v.pos));
|
||||
let pages = [page(vec![plot]), page(vec![sheared])];
|
||||
let bytes = export(&pages);
|
||||
for (index, page) in pages.iter().enumerate() {
|
||||
let raster = Raster::at_scale(&bytes, index, 8.0);
|
||||
let corners = page.content.images[0].image.corners;
|
||||
let u = [corners[1][0] - corners[0][0], corners[1][1] - corners[0][1]];
|
||||
let v = [corners[3][0] - corners[0][0], corners[3][1] - corners[0][1]];
|
||||
let determinant = u[0] * v[1] - u[1] * v[0];
|
||||
let mut checked = 0;
|
||||
for y in 0..raster.height {
|
||||
for x in 0..raster.width {
|
||||
let dx = (x as f32 + 0.5) / raster.scale * 25.4 / 72.0 - corners[0][0];
|
||||
let dy = (raster.height as f32 - y as f32 - 0.5) / raster.scale * 25.4 / 72.0
|
||||
- corners[0][1];
|
||||
let a = (dx * v[1] - dy * v[0]) / determinant;
|
||||
let b = (u[0] * dy - u[1] * dx) / determinant;
|
||||
if (0.15..0.85).contains(&a) && (0.15..0.85).contains(&b) {
|
||||
let rgb = &raster.pixels[(y * raster.width + x) * 4..][..3];
|
||||
assert!(rgb.iter().all(|&c| c.abs_diff(127) <= 2),
|
||||
"page {index}, pixel ({x}, {y}): shared-edge gap or double coverage: {rgb:?}");
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 100_000);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_bitmap_reports_an_error_without_replacing_the_pdf() {
|
||||
let path = std::env::temp_dir().join(format!("ocs1266-invalid-{}.pdf", std::process::id()));
|
||||
std::fs::write(&path, b"existing PDF").unwrap();
|
||||
let mut pixels = image();
|
||||
pixels.image.width += 1;
|
||||
let mut coordinates = image();
|
||||
coordinates.image.corners[0][0] = f32::NAN;
|
||||
let mut mapping = image();
|
||||
mapping.image.corners[2][0] += 10.0;
|
||||
mapping
|
||||
.image
|
||||
.verts
|
||||
.iter_mut()
|
||||
.for_each(|vertex| vertex.uv = [0.0, 0.0]);
|
||||
for (plot, reason) in [
|
||||
(pixels, "dimensions"),
|
||||
(coordinates, "coordinates"),
|
||||
(mapping, "texture mapping"),
|
||||
] {
|
||||
let error = export_pdf(&page(vec![plot]), &path).unwrap_err();
|
||||
assert!(
|
||||
error.contains("Page 1") && error.contains(reason),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"existing PDF");
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
@@ -11,7 +11,9 @@
|
||||
use acadrust::entities::{Dimension, DimensionLinear, Text};
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::EntityType;
|
||||
use OpenCADStudio::io::pdf_export::{export_pdf, PdfPlotOptions, PlotWire};
|
||||
use OpenCADStudio::io::pdf_export::{
|
||||
export_pdf, PdfPageInput, PdfPlotOptions, PlotContent, PlotWire,
|
||||
};
|
||||
use OpenCADStudio::scene::Scene;
|
||||
|
||||
#[test]
|
||||
@@ -60,49 +62,38 @@ fn text_and_dim_reach_pdf_export() {
|
||||
.collect();
|
||||
|
||||
let p_text = dir.join("with_text.pdf");
|
||||
export_pdf(
|
||||
&plot_wires,
|
||||
&[],
|
||||
&[],
|
||||
210.0,
|
||||
297.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0,
|
||||
1.0,
|
||||
None,
|
||||
&p_text,
|
||||
None,
|
||||
PdfPlotOptions::default(),
|
||||
)
|
||||
.expect("export with text");
|
||||
let mut page = PdfPageInput {
|
||||
content: PlotContent {
|
||||
wires: std::sync::Arc::new(plot_wires),
|
||||
..Default::default()
|
||||
},
|
||||
paper_w: 210.0,
|
||||
paper_h: 297.0,
|
||||
offset_x: 0.0,
|
||||
offset_y: 0.0,
|
||||
rotation_deg: 0,
|
||||
scale: 1.0,
|
||||
clip: None,
|
||||
options: PdfPlotOptions::default(),
|
||||
plot_style: None,
|
||||
};
|
||||
export_pdf(&page, &p_text).expect("export with text");
|
||||
let with_text = std::fs::read(&p_text).expect("read pdf");
|
||||
assert!(with_text.starts_with(b"%PDF"), "not a PDF");
|
||||
|
||||
let stripped: Vec<_> = plot_wires
|
||||
.into_iter()
|
||||
let stripped: Vec<_> = page
|
||||
.content
|
||||
.wires
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut w| {
|
||||
w.wire.text_verts.clear();
|
||||
w
|
||||
})
|
||||
.collect();
|
||||
let p_bare = dir.join("no_text.pdf");
|
||||
export_pdf(
|
||||
&stripped,
|
||||
&[],
|
||||
&[],
|
||||
210.0,
|
||||
297.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0,
|
||||
1.0,
|
||||
None,
|
||||
&p_bare,
|
||||
None,
|
||||
PdfPlotOptions::default(),
|
||||
)
|
||||
.expect("export without text");
|
||||
page.content.wires = std::sync::Arc::new(stripped);
|
||||
export_pdf(&page, &p_bare).expect("export without text");
|
||||
let no_text = std::fs::read(&p_bare).expect("read pdf");
|
||||
|
||||
assert!(
|
||||
|
||||
Reference in new issue
Block a user