Merge pull request #1472 from ramox81:codex/pdf-attach-parity

This commit is contained in:
Hakan Seven committed 2026-09-24 23:24:43 +03:00
commit 2e60c4da1b
63 files changed
+7470 -405

No files matched your search

+1 -1
View File
@@ -1066,7 +1066,7 @@ impl OpenCADStudio {
}
}
if let Some(bounds) = bounds {
let (min, max) = crate::scene::convert::tess::entity_bounds(e);
let (min, max) = crate::scene::convert::tess::entity_bounds_in(&tab.scene.document, e);
if max[0] < bounds[0]
|| max[1] < bounds[1]
|| min[0] > bounds[2]
+27
View File
@@ -3892,6 +3892,33 @@ impl OpenCADStudio {
}
self.refresh_properties();
}
CmdResult::OpenPdfImportSettings => self.open_pdf_import_settings(),
CmdResult::PdfImportFile(import) => {
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.run_pdf_import(
i,
crate::app::commands::pdf_import::PdfImportSource::File(import),
);
}
CmdResult::AttachPdfPages { path, pages } => {
let label = self.history_label_from_active_cmd(i, "PDFATTACH");
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.attach_pdf_pages(i, label, &path, pages);
self.restore_pre_cmd_tangent();
}
CmdResult::PdfImport(request) => {
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.run_pdf_import(
i,
crate::app::commands::pdf_import::PdfImportSource::Underlay(request),
);
}
CmdResult::UpdateEntityAndFinish { handle, entity } => {
if self.reject_locked_edit(i, handle) {
self.tabs[i].active_cmd = None;
+53 -2
View File
@@ -695,8 +695,59 @@ impl OpenCADStudio {
self.open_attedit_dialog();
}
"PDFATTACH" => {
return Some(Task::done(Message::PdfAttachPick));
"PDFIMPORT" => {
use crate::command::CadCommand;
let command = crate::modules::insert::pdf_import::PdfImportCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"_PDFIMPORTFILE" => {
return Some(Task::done(Message::PdfImportPick));
}
// A file named after it skips the picker.
cmd if cmd.starts_with("_PDFIMPORTFILE ") => {
let path = cmd["_PDFIMPORTFILE ".len()..].trim().trim_matches('"').to_string();
match std::fs::read(&path) {
Ok(bytes) => {
crate::scene::model::pdf_raster::register_source(&path, std::sync::Arc::new(bytes));
self.open_pdf_import_file(&path);
}
Err(_) => self.command_line.push_error(&format!("{path} not found.")),
}
}
"PDFCLIP" | "CLIP" => {
use crate::command::CadCommand;
let command = crate::modules::insert::pdf_clip::PdfClipCommand::new(cmd == "CLIP");
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
// PDFATTACH picks the file; -PDFATTACH asks for it on the command
// line. A path after either names the file at once.
cmd if matches!(
cmd.split_whitespace().next().map(|v| v.to_ascii_uppercase()).as_deref(),
Some("PDFATTACH") | Some("-PDFATTACH")
) =>
{
use crate::command::CadCommand;
let (verb, rest) = cmd
.split_once(char::is_whitespace)
.map(|(verb, rest)| (verb.to_ascii_uppercase(), rest.trim()))
.unwrap_or_else(|| (cmd.to_ascii_uppercase(), ""));
if verb == "PDFATTACH" && rest.is_empty() {
return Some(Task::done(Message::PdfAttachPick));
}
let insunits = self.tabs[i].scene.document.header.insertion_units;
let mut command =
crate::modules::insert::pdf_attach::PdfAttachCommand::new(insunits);
if !rest.is_empty() {
if let Some(crate::command::CmdResult::ReportError(message)) =
command.on_text_input(rest)
{
self.command_line.push_error(&message);
}
}
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"XATTACH" => {
// Launch the file picker; XAttachPickResult will start the command.
+2 -2
View File
@@ -1861,8 +1861,8 @@ impl OpenCADStudio {
}
}
"POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" | "UNDERLAYLAYERS"
| "UOSNAP" => {
"UNDERLAYLAYERS" | "ULAYERS" => self.open_underlay_layers_dialog(i),
"POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" => {
self.command_line
.push_info(crate::tf!("{cmd}: not yet implemented.").as_ref());
}
+11
View File
@@ -13,6 +13,9 @@ mod fileops;
mod inquiry;
mod layerprops;
mod layers;
pub(crate) mod pdf_import;
mod pdf_underlay;
mod pdf_dialogs;
mod plotvars;
mod styleprops;
mod view;
@@ -253,6 +256,9 @@ impl OpenCADStudio {
if let Some(t) = self.dispatch_blocks(cmd, i) {
return Some(t);
}
if let Some(t) = self.dispatch_pdf_underlay(cmd, i) {
return Some(t);
}
if let Some(t) = self.dispatch_draw(cmd, i) {
return Some(t);
}
@@ -610,6 +616,11 @@ inventory::submit!(crate::command::CommandRegistration {
"FRAME",
"IMAGEFRAME",
"PDFFRAME",
"PDFOSNAP",
"UOSNAP",
"PDFIMPORTMODE",
"PDFIMPORTFILTER",
"PDFIMPORTLAYERS",
"POINTCLOUDCLIPFRAME",
"XCLIPFRAME",
"WIPEOUTFRAME",
+507
View File
@@ -0,0 +1,507 @@
//! The PDF dialogs' state changes: Attach PDF Underlay, Underlay Layers,
//! PDF Import Settings and Import PDF.
use super::*;
use crate::io::xref_model::Pathtype;
use crate::modules::insert::pdf_import::{self, PdfFileImport};
use crate::ui::window::pdf_dialogs::{
click_page, page_thumbs, LayerTarget, PdfAttachState, PdfDialogMsg, PdfImportFileState,
RotationChoice, UnderlayLayersState,
};
/// "7.8740 × 3.9369" (inches) for a page.
fn page_size_text(path: &str, page: &str) -> String {
crate::scene::model::pdf_raster::page_size_inches(path, page)
.map(|(w, h)| format!("{w:.4} × {h:.4}"))
.unwrap_or_default()
}
fn file_stem(path: &str) -> String {
std::path::Path::new(&path.replace('\\', "/"))
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
}
impl OpenCADStudio {
/// The path an attached file is stored with: full, relative to the saved
/// drawing (the full path until it is saved), or the file name alone.
fn pdf_stored_path(&self, path: &str, path_type: Pathtype) -> String {
match path_type {
Pathtype::Full => path.to_string(),
Pathtype::Relative => self.tabs[self.active_tab]
.current_path
.as_deref()
.and_then(|host| {
crate::io::xref_model::to_pathtype_result(path, host, Pathtype::Relative).ok()
})
.unwrap_or_else(|| path.to_string()),
Pathtype::None => std::path::Path::new(&path.replace('\\', "/"))
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string()),
}
}
/// PDFATTACH after the file is chosen.
pub(in crate::app) fn open_pdf_attach_dialog(&mut self, path: &str) {
let i = self.active_tab;
let mut existing: Vec<(String, String)> = Vec::new();
for object in self.tabs[i].scene.document.objects.values() {
if let acadrust::objects::ObjectType::UnderlayDefinition(def) = object {
let name = file_stem(&def.file_path);
if !existing.iter().any(|(n, _)| *n == name) {
existing.push((name, def.file_path.clone()));
}
}
}
let mut state = PdfAttachState::new(existing);
self.load_pdf_attach_file(&mut state, path);
self.pdf_attach = Some(state);
self.active_modal = Some(crate::app::ModalKind::PdfAttach);
}
fn load_pdf_attach_file(&self, state: &mut PdfAttachState, path: &str) {
state.path = path.to_string();
state.name = file_stem(path);
state.pages = page_thumbs(path);
state.selected = vec![0];
state.anchor = 0;
state.found_in = crate::entities::underlay::display_path(path);
state.page_size = page_size_text(path, "1");
state.saved_path =
crate::entities::underlay::display_path(&self.pdf_stored_path(path, state.path_type.0));
}
/// ULAYERS: every PDF underlay of the drawing, the selected one first
/// shown.
pub(in crate::app) fn open_underlay_layers_dialog(&mut self, i: usize) {
let document = &self.tabs[i].scene.document;
let selected: Vec<acadrust::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.collect();
let mut targets: Vec<LayerTarget> = Vec::new();
for entity in document.entities() {
let acadrust::EntityType::Underlay(u) = entity else {
continue;
};
if u.underlay_type != acadrust::entities::UnderlayType::Pdf {
continue;
}
let Some(def) = crate::entities::underlay::definition(u, document) else {
continue;
};
let mut name = crate::entities::underlay::definition_display_name(def);
let same = targets.iter().filter(|t| t.name.starts_with(&name)).count();
if same > 0 {
name = format!("{name} ({})", same + 1);
}
let mut layers: Vec<String> = crate::scene::model::pdf_layers::layers(&def.file_path)
.into_iter()
.map(|l| l.name)
.collect();
layers.sort_by_key(|n| n.to_lowercase());
layers.dedup();
targets.push(LayerTarget {
name,
handle: u.common.handle,
layers,
hidden: crate::scene::model::pdf_layers::hidden_layers(u),
});
}
if targets.is_empty() {
self.command_line.push_error("No PDF underlays found.");
return;
}
let current = targets
.iter()
.position(|t| selected.contains(&t.handle))
.unwrap_or(0);
self.underlay_layers = Some(UnderlayLayersState {
targets,
current,
search: String::new(),
});
self.active_modal = Some(crate::app::ModalKind::UnderlayLayers);
}
/// PDFIMPORT's Settings option: the dialog over the running command.
pub(in crate::app) fn open_pdf_import_settings(&mut self) {
self.pdf_import_settings = Some(pdf_import::import_settings());
self.active_modal = Some(crate::app::ModalKind::PdfImportSettings);
}
/// PDFIMPORT's File option after the file is chosen.
pub(in crate::app) fn open_pdf_import_file(&mut self, path: &str) {
self.pdf_import_file = Some(PdfImportFileState {
path: path.to_string(),
pages: page_thumbs(path),
selected: 0,
page_text: "1".into(),
insert_on_screen: false,
scale: "1".into(),
rotation: RotationChoice(0),
page_size: page_size_mm(path, "1"),
settings: pdf_import::import_settings(),
});
self.active_modal = Some(crate::app::ModalKind::PdfImportFile);
}
/// Writes the layers each underlay turns off, as one undo step.
fn apply_underlay_layers(&mut self, i: usize, targets: Vec<LayerTarget>) {
use crate::scene::model::pdf_layers::{hidden_layers, LAYER_OVERRIDE_APP};
let changed: Vec<LayerTarget> = targets
.into_iter()
.filter(|t| match self.tabs[i].scene.document.get_entity(t.handle) {
Some(acadrust::EntityType::Underlay(u)) => hidden_layers(u) != t.hidden,
_ => false,
})
.collect();
if changed.is_empty() {
return;
}
self.push_undo_snapshot(i, "ULAYERS");
self.tabs[i].scene.ensure_app_id(LAYER_OVERRIDE_APP);
let app_handle = self.tabs[i]
.scene
.document
.app_ids
.get(LAYER_OVERRIDE_APP)
.map(|a| a.handle.value());
for target in &changed {
if let Some(acadrust::EntityType::Underlay(u)) =
self.tabs[i].scene.document.get_entity_mut(target.handle)
{
let data = &mut u.common.extended_data;
if let Some(app) = app_handle {
data.raw_dwg_eed.retain(|(a, _)| *a != app);
}
data.remove_record(LAYER_OVERRIDE_APP);
if !target.hidden.is_empty() {
let mut record = acadrust::xdata::ExtendedDataRecord::new(LAYER_OVERRIDE_APP);
for name in &target.hidden {
record.add_value(acadrust::xdata::XDataValue::String(name.clone()));
}
data.add_record(record);
}
}
self.tabs[i].scene.reseed_derived_caches(target.handle);
}
let changes: Vec<_> = changed
.iter()
.map(|t| (t.handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
self.refresh_properties();
}
pub(in crate::app) fn update_pdf_dialog(&mut self, message: PdfDialogMsg) -> Task<Message> {
let i = self.active_tab;
match message {
PdfDialogMsg::Help(which) => {
let text = match which {
"attach" => crate::t!("Attaches a PDF file as an underlay."),
"layers" => crate::t!("Turns the layers of a PDF underlay on or off."),
_ => crate::t!("Imports the geometry, fills, raster images and text of a PDF file as drawing objects."),
};
self.command_line.push_info(text.as_ref());
}
PdfDialogMsg::Options => {
self.options_parent = self.active_modal;
self.options_open();
}
// ── Attach ────────────────────────────────────────────────────
PdfDialogMsg::AttachBrowse => {
if let Some(state) = &self.pdf_attach {
state.remember();
}
return Task::done(Message::PdfAttachPick);
}
PdfDialogMsg::AttachName(name) => {
let Some(mut state) = self.pdf_attach.take() else {
return Task::none();
};
if let Some((_, path)) = state.existing.iter().find(|(n, _)| *n == name).cloned() {
self.load_pdf_attach_file(&mut state, &path);
}
self.pdf_attach = Some(state);
}
PdfDialogMsg::AttachPage(page) => {
let (ctrl, shift) = (self.ctrl_down, self.shift_down);
if let Some(state) = self.pdf_attach.as_mut() {
click_page(&mut state.selected, &mut state.anchor, page, ctrl, shift);
let first = state.page_labels().into_iter().next().unwrap_or_default();
state.page_size = page_size_text(&state.path, &first);
}
}
PdfDialogMsg::AttachPathType(choice) => {
let Some(mut state) = self.pdf_attach.take() else {
return Task::none();
};
state.path_type = choice;
state.saved_path = crate::entities::underlay::display_path(
&self.pdf_stored_path(&state.path, choice.0),
);
self.pdf_attach = Some(state);
}
PdfDialogMsg::AttachInsertOnScreen(on) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.insert_on_screen = on;
}
}
PdfDialogMsg::AttachInsert(axis, value) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.insert[axis.min(2)] = value;
}
}
PdfDialogMsg::AttachScaleOnScreen(on) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.scale_on_screen = on;
}
}
PdfDialogMsg::AttachScale(value) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.scale = value;
}
}
PdfDialogMsg::AttachRotationOnScreen(on) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.rotation_on_screen = on;
}
}
PdfDialogMsg::AttachRotation(value) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.rotation = value;
}
}
PdfDialogMsg::AttachDetails(on) => {
if let Some(s) = self.pdf_attach.as_mut() {
s.details = on;
}
}
PdfDialogMsg::AttachOk => return self.pdf_attach_ok(i),
// ── Underlay Layers ───────────────────────────────────────────
PdfDialogMsg::LayersUnderlay(name) => {
if let Some(s) = self.underlay_layers.as_mut() {
if let Some(at) = s.targets.iter().position(|t| t.name == name) {
s.current = at;
}
}
}
PdfDialogMsg::LayersSearch(text) => {
if let Some(s) = self.underlay_layers.as_mut() {
s.search = text;
}
}
PdfDialogMsg::LayersToggle(layer) => {
if let Some(s) = self.underlay_layers.as_mut() {
if let Some(t) = s.targets.get_mut(s.current) {
match t.hidden.iter().position(|h| *h == layer) {
Some(at) => {
t.hidden.remove(at);
}
None => t.hidden.push(layer),
}
}
}
}
PdfDialogMsg::LayersOk => {
if let Some(state) = self.underlay_layers.take() {
self.apply_underlay_layers(i, state.targets);
}
self.close_active_modal();
}
// ── Import settings ───────────────────────────────────────────
PdfDialogMsg::Vector(_)
| PdfDialogMsg::Fills(_)
| PdfDialogMsg::Text(_)
| PdfDialogMsg::Raster(_)
| PdfDialogMsg::Layers(_)
| PdfDialogMsg::AsBlock(_)
| PdfDialogMsg::Join(_)
| PdfDialogMsg::Hatches(_)
| PdfDialogMsg::Lineweights(_)
| PdfDialogMsg::Linetypes(_) => {
let settings = if self.active_modal == Some(crate::app::ModalKind::PdfImportFile) {
self.pdf_import_file.as_mut().map(|s| &mut s.settings)
} else {
self.pdf_import_settings.as_mut()
};
if let Some(s) = settings {
match message {
PdfDialogMsg::Vector(v) => s.vector = v,
PdfDialogMsg::Fills(v) => s.fills = v,
PdfDialogMsg::Text(v) => s.text = v,
PdfDialogMsg::Raster(v) => s.raster = v,
PdfDialogMsg::Layers(v) => s.layers = v,
PdfDialogMsg::AsBlock(v) => s.as_block = v,
PdfDialogMsg::Join(v) => s.join = v,
PdfDialogMsg::Hatches(v) => s.hatches = v,
PdfDialogMsg::Lineweights(v) => s.lineweights = v,
PdfDialogMsg::Linetypes(v) => s.linetypes = v,
_ => {}
}
}
}
PdfDialogMsg::SettingsOk => {
if let Some(s) = self.pdf_import_settings.take() {
pdf_import::set_import_settings(s);
}
self.close_active_modal();
}
// ── Import PDF ────────────────────────────────────────────────
PdfDialogMsg::ImportBrowse => return Task::done(Message::PdfImportPick),
PdfDialogMsg::ImportPage(page) => {
if let Some(s) = self.pdf_import_file.as_mut() {
s.selected = page;
s.page_text = (page + 1).to_string();
s.page_size = page_size_mm(&s.path, &s.page_text);
}
}
PdfDialogMsg::ImportPageText(text) => {
if let Some(s) = self.pdf_import_file.as_mut() {
if let Some(n) = text.trim().parse::<usize>().ok().filter(|n| (1..=s.pages.len()).contains(n)) {
s.selected = n - 1;
s.page_size = page_size_mm(&s.path, &n.to_string());
}
s.page_text = text;
}
}
PdfDialogMsg::ImportInsertOnScreen(on) => {
if let Some(s) = self.pdf_import_file.as_mut() {
s.insert_on_screen = on;
}
}
PdfDialogMsg::ImportScale(value) => {
if let Some(s) = self.pdf_import_file.as_mut() {
s.scale = value;
}
}
PdfDialogMsg::ImportRotation(r) => {
if let Some(s) = self.pdf_import_file.as_mut() {
s.rotation = r;
}
}
PdfDialogMsg::ImportOk => {
let Some(state) = self.pdf_import_file.as_ref() else {
return Task::none();
};
let Some(scale) = state.scale.trim().parse::<f64>().ok().filter(|v| *v > 0.0) else {
self.command_line.push_error("Value must be positive and nonzero.");
return Task::none();
};
let state = self.pdf_import_file.take().expect("checked above");
pdf_import::set_import_settings(state.settings);
self.close_active_modal();
let import = PdfFileImport {
path: state.path.clone(),
page: (state.selected + 1).to_string(),
scale,
rotation: (state.rotation.0 as f64).to_radians(),
insertion: glam::DVec3::ZERO,
};
if state.insert_on_screen {
use crate::command::CadCommand;
let command = pdf_import::PdfImportPointCommand::new(import);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
return self.focus_cmd_input();
}
self.run_pdf_import(i, crate::app::commands::pdf_import::PdfImportSource::File(import));
}
}
Task::none()
}
fn pdf_attach_ok(&mut self, i: usize) -> Task<Message> {
use crate::command::CadCommand;
let Some(state) = self.pdf_attach.as_ref() else {
return Task::none();
};
let number = |s: &str| s.trim().parse::<f64>().ok();
let insertion = if state.insert_on_screen {
None
} else {
match (number(&state.insert[0]), number(&state.insert[1]), number(&state.insert[2])) {
(Some(x), Some(y), Some(z)) => Some(glam::DVec3::new(x, y, z)),
_ => {
self.command_line.push_error("Requires numeric value.");
return Task::none();
}
}
};
let scale = if state.scale_on_screen {
None
} else {
match number(&state.scale).filter(|v| *v > 0.0) {
Some(v) => Some(v),
None => {
self.command_line.push_error("Value must be positive and nonzero.");
return Task::none();
}
}
};
let rotation = if state.rotation_on_screen {
None
} else {
match number(&state.rotation) {
Some(v) => Some(v),
None => {
self.command_line.push_error("Requires numeric value.");
return Task::none();
}
}
};
let state = self.pdf_attach.take().expect("checked above");
state.remember();
let stored = self.pdf_stored_path(&state.path, state.path_type.0);
if stored != state.path {
if let Some(bytes) = crate::scene::model::pdf_raster::source_bytes(&state.path) {
crate::scene::model::pdf_raster::register_source(&stored, bytes);
}
}
self.close_active_modal();
let insunits = self.tabs[i].scene.document.header.insertion_units;
let mut command = crate::modules::insert::pdf_attach::PdfAttachCommand::from_dialog(
&state.path,
&stored,
&state.page_labels(),
scale,
rotation,
insunits,
);
match insertion {
Some(point) => {
let result = command.on_point(point);
self.tabs[i].active_cmd = Some(Box::new(command));
if matches!(result, crate::command::CmdResult::NeedPoint) {
self.command_line.push_info(
&self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()).unwrap_or_default(),
);
return self.focus_cmd_input();
}
self.apply_cmd_result(result)
}
None => {
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
self.focus_cmd_input()
}
}
}
}
/// "200 × 100 mm" for a page.
fn page_size_mm(path: &str, page: &str) -> String {
crate::scene::model::pdf_raster::page_size_inches(path, page)
.map(|(w, h)| format!("{:.0} × {:.0} mm", w * 25.4, h * 25.4))
.unwrap_or_default()
}
+269
View File
@@ -0,0 +1,269 @@
//! PDFIMPORT: adding the converted PDF content to the drawing as one undo
//! step, with the layers and text styles it uses, and what then happens to
//! the underlay (Keep / Detach / Unload).
use super::*;
use crate::modules::insert::pdf_import::{self, ImportArea, PdfImportRequest, UnderlayMode};
/// What an import reads: a placed underlay, or a file imported at the
/// origin at full size.
pub(crate) enum PdfImportSource {
Underlay(PdfImportRequest),
File(pdf_import::PdfFileImport),
}
impl OpenCADStudio {
pub(crate) fn run_pdf_import(&mut self, i: usize, source: PdfImportSource) {
use acadrust::objects::ObjectType;
let document = &self.tabs[i].scene.document;
let (underlay, path, page, area, mode, handle) = match &source {
PdfImportSource::Underlay(request) => {
let Some(acadrust::EntityType::Underlay(underlay)) =
document.get_entity(request.underlay)
else {
return;
};
let Some(def) = crate::entities::underlay::definition(underlay, document) else {
return;
};
if def.unloaded {
self.command_line
.push_error("Cannot bind a PDF underlay that is unloaded.");
return;
}
(
underlay.clone(),
def.file_path.clone(),
crate::entities::underlay::page_of(def).to_string(),
request.area.clone(),
Some(request.mode),
Some(request.underlay),
)
}
PdfImportSource::File(import) => (
import.placement(),
import.path.clone(),
import.page.clone(),
ImportArea::All,
None,
None,
),
};
let shown = crate::entities::underlay::display_path(&path);
match &source {
PdfImportSource::Underlay(_) => self
.command_line
.push_output(&format!("Binding PDF file {shown}, page {page} ...")),
PdfImportSource::File(_) => self
.command_line
.push_output(&format!("Importing page {page} of PDF file: {shown}...")),
}
// The underlay's own layer overrides leave those layers out.
let hidden = crate::scene::model::pdf_layers::hidden_layers(&underlay);
let Some(content) =
crate::scene::model::pdf_vector::page_content_by_layer(&path, &page, &hidden)
else {
self.command_line.push_error(&format!("{shown} not found."));
return;
};
let settings = pdf_import::import_settings();
let current_layer = {
let header = &self.tabs[i].scene.document.header;
if header.current_layer_name.is_empty() {
self.tabs[i].active_layer.clone()
} else {
header.current_layer_name.clone()
}
};
let naming = pdf_import::LayerNaming {
settings,
prefix: import_prefix(&path),
current: current_layer.clone(),
};
let mut result = pdf_import::convert(&content, &underlay, &area, &naming);
let images = std::mem::take(&mut result.images);
let image_dir = self.tabs[i]
.current_path
.as_deref()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
.or_else(|| std::path::Path::new(&path).parent().map(|p| p.to_path_buf()))
.map(|dir| dir.join("PDF Images"));
self.push_undo_snapshot(i, "PDFIMPORT");
let scene = &mut self.tabs[i].scene;
if result.dashed && !scene.document.line_types.contains(pdf_import::DASH_LINETYPE) {
let mut lt = acadrust::tables::LineType::new(pdf_import::DASH_LINETYPE);
lt.handle = scene.document.allocate_handle();
lt.add_element(acadrust::tables::LineTypeElement::dash(0.8));
lt.add_element(acadrust::tables::LineTypeElement::space(0.2));
lt.pattern_length = 1.0;
let _ = scene.document.line_types.add(lt);
}
// Raster images: each written as a PNG under "PDF Images" next to the
// drawing (or the PDF while the drawing is unsaved), then referenced.
let stem = std::path::Path::new(&path.replace('\\', "/"))
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "PDF".to_string());
for (n, image) in images.iter().enumerate() {
let Some(dir) = &image_dir else { break };
if std::fs::create_dir_all(dir).is_err() {
break;
}
let file = dir.join(format!("{stem}{:08x}.png", image_file_id(&path, &page, n)));
if image::save_buffer(&file, &image.rgba, image.width, image.height, image::ColorType::Rgba8)
.is_err()
{
continue;
}
let mut raster = acadrust::entities::RasterImage::with_size(
file.to_string_lossy().as_ref(),
image.insertion,
image.width as f64,
image.height as f64,
1.0,
1.0,
);
raster.u_vector = image.u;
raster.v_vector = image.v;
raster.flags = acadrust::entities::ImageDisplayFlags::SHOW_IMAGE
| acadrust::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY;
raster.common.layer = image.layer.clone();
result.entities.push(acadrust::EntityType::RasterImage(raster));
}
for (name, color) in &result.layers {
if !scene.document.layers.contains(name) {
let mut layer = acadrust::tables::Layer::new(name.as_str());
layer.handle = scene.document.allocate_handle();
layer.color = *color;
let _ = scene.document.layers.add(layer);
}
}
for (name, font) in &result.text_styles {
if !scene.document.text_styles.contains(name) {
let mut style = acadrust::tables::TextStyle::new(name.as_str());
style.handle = scene.document.allocate_handle();
style.font_file = font.clone();
let _ = scene.document.text_styles.add(style);
}
}
if settings.as_block && !result.entities.is_empty() {
// One block named after the file, inserted at the origin on the
// current layer.
let mut name = stem.clone();
let mut n = 1;
while scene.document.block_records.get(&name).is_some() {
n += 1;
name = format!("{stem}({n})");
}
if scene
.define_block_from_owned_entities(result.entities, &name, glam::DVec3::ZERO)
.is_ok()
{
let mut insert = acadrust::entities::Insert::new(
name,
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
);
insert.common.layer = current_layer;
scene.add_entity(acadrust::EntityType::Insert(insert));
}
} else {
for entity in result.entities {
scene.add_entity(entity);
}
}
if let (Some(mode), Some(handle)) = (mode, handle) {
match mode {
UnderlayMode::Keep => {}
UnderlayMode::Unload => {
if let Some(ObjectType::UnderlayDefinition(definition)) =
scene.document.objects.get_mut(&underlay.definition_handle)
{
definition.unloaded = true;
}
scene.reseed_underlays();
}
UnderlayMode::Detach => {
scene.erase_entities(&[handle]);
// Drop the definition once nothing references it.
let definition = underlay.definition_handle;
let referenced = scene.document.entities().any(|entity| {
matches!(entity, acadrust::EntityType::Underlay(other)
if other.definition_handle == definition)
});
if !referenced {
scene.document.objects.remove(&definition);
for object in scene.document.objects.values_mut() {
if let ObjectType::Dictionary(dictionary) = object {
dictionary.entries.retain(|(_, target)| *target != definition);
}
}
}
}
}
}
self.tabs[i].dirty = true;
self.refresh_layer_panel();
self.refresh_properties();
}
}
impl OpenCADStudio {
/// PDFATTACH's end: every page's definition (created or reused, with the
/// definitions dictionary) and underlay in one undo step.
pub(crate) fn attach_pdf_pages(
&mut self,
i: usize,
label: String,
path: &str,
pages: Vec<(String, acadrust::EntityType)>,
) {
let pending = self.begin_undo(i, label, pages.len(), false);
for (page, mut entity) in pages {
let definition = crate::modules::insert::pdf_attach::ensure_pdf_definition(
&mut self.tabs[i].scene.document,
path,
&page,
);
if let acadrust::EntityType::Underlay(underlay) = &mut entity {
underlay.definition_handle = definition;
}
self.commit_entity_handle(entity);
}
self.tabs[i].dirty = true;
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
}
/// The layer prefix of an imported file: "PDF_" for the first file of the
/// session, "PDF2_", "PDF3_" … for each other file.
// ponytail: remembered for the session only; a reopened drawing starts from
// "PDF_" again.
fn import_prefix(path: &str) -> String {
static FILES: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
let mut files = FILES.lock().unwrap_or_else(|e| e.into_inner());
let index = match files.iter().position(|f| f.eq_ignore_ascii_case(path)) {
Some(at) => at,
None => {
files.push(path.to_string());
files.len() - 1
}
};
if index == 0 {
"PDF_".to_string()
} else {
format!("PDF{}_", index + 1)
}
}
/// A file name suffix for an imported image, different per import.
fn image_file_id(path: &str, page: &str, n: usize) -> u32 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(path, page, n, std::time::SystemTime::now()).hash(&mut h);
h.finish() as u32
}
+166
View File
@@ -0,0 +1,166 @@
//! The contextual PDF Underlay ribbon tab: its values come from the selected
//! underlays, its buttons edit them.
use super::*;
use acadrust::entities::{Underlay, UnderlayDisplayFlags, UnderlayType};
use crate::ui::ribbon::{UnderlayContext, UnderlayTabMsg};
impl OpenCADStudio {
/// The selected PDF underlays, or nothing when anything else is selected.
fn selected_pdf_underlays(&self, i: usize) -> Vec<(acadrust::Handle, Underlay)> {
let mut out = Vec::new();
for (handle, entity) in self.tabs[i].scene.selected_entities() {
match entity {
acadrust::EntityType::Underlay(u) if u.underlay_type == UnderlayType::Pdf => {
out.push((handle, u.clone()));
}
_ => return Vec::new(),
}
}
out
}
/// Show the tab while only PDF underlays are selected.
pub(in crate::app) fn sync_underlay_tab(&mut self) {
let i = self.active_tab;
let context = if self.tabs[i].is_start {
None
} else {
self.selected_pdf_underlays(i).first().map(|(_, u)| {
UnderlayContext::new(
u.contrast,
u.fade,
u.flags.contains(UnderlayDisplayFlags::MONOCHROME),
u.flags.contains(UnderlayDisplayFlags::ON),
crate::scene::model::pdf_vector::pdf_osnap(),
)
})
};
self.ribbon.set_underlay_context(context);
}
/// One undo step that edits every selected PDF underlay.
fn edit_selected_underlays(&mut self, i: usize, label: &str, edit: impl Fn(&mut Underlay)) {
let handles: Vec<_> = self
.selected_pdf_underlays(i)
.into_iter()
.map(|(h, _)| h)
.filter(|h| !self.tabs[i].scene.is_layer_locked(*h))
.collect();
if handles.is_empty() {
return;
}
self.push_undo_snapshot(i, label);
for handle in &handles {
if let Some(acadrust::EntityType::Underlay(u)) =
self.tabs[i].scene.document.get_entity_mut(*handle)
{
edit(u);
}
self.tabs[i].scene.reseed_derived_caches(*handle);
}
let changes: Vec<_> = handles
.iter()
.map(|h| (*h, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
self.refresh_properties();
}
pub(in crate::app) fn update_underlay_tab(&mut self, message: UnderlayTabMsg) -> Task<Message> {
let i = self.active_tab;
let Some(ctx) = self.ribbon.underlay_context_mut() else {
return Task::none();
};
match message {
UnderlayTabMsg::Contrast(v) => {
ctx.contrast = v;
ctx.contrast_text = v.to_string();
}
UnderlayTabMsg::Fade(v) => {
ctx.fade = v;
ctx.fade_text = v.to_string();
}
UnderlayTabMsg::ContrastText(s) => ctx.contrast_text = s,
UnderlayTabMsg::FadeText(s) => ctx.fade_text = s,
UnderlayTabMsg::Commit => {
// A typed value outside 0–100 or not a number keeps the value.
let parse = |s: &str, old: u8| {
s.trim().parse::<u8>().ok().filter(|v| *v <= 100).unwrap_or(old)
};
let (contrast, fade) = (
parse(&ctx.contrast_text, ctx.contrast),
parse(&ctx.fade_text, ctx.fade),
);
ctx.contrast_text = contrast.to_string();
ctx.fade_text = fade.to_string();
self.edit_selected_underlays(i, "PDFADJUST", |u| {
u.set_contrast(contrast);
u.set_fade(fade);
});
}
}
Task::none()
}
/// The tab's buttons (host-only command names).
pub(super) fn dispatch_pdf_underlay(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
match cmd {
"_PDFULMONO" => {
let on = !self
.selected_pdf_underlays(i)
.first()
.is_some_and(|(_, u)| u.flags.contains(UnderlayDisplayFlags::MONOCHROME));
self.edit_selected_underlays(i, "PDFADJUST", |u| u.set_monochrome(on));
}
"_PDFULSHOW" => {
let on = !self
.selected_pdf_underlays(i)
.first()
.is_some_and(|(_, u)| u.flags.contains(UnderlayDisplayFlags::ON));
self.edit_selected_underlays(i, "PDFUNDERLAY", |u| u.set_on(on));
}
"_PDFULSNAP" => {
let on = !crate::scene::model::pdf_vector::pdf_osnap();
crate::scene::model::pdf_vector::set_pdf_osnap(on);
self.tabs[i].scene.reseed_underlays();
self.sync_underlay_tab();
}
"_PDFULUNCLIP" => {
self.edit_selected_underlays(i, "PDFCLIP", |u| {
u.clip_boundary_vertices.clear();
u.clip_inverted = false;
u.flags -= UnderlayDisplayFlags::CLIPPING;
});
}
"_PDFULCLIP" => {
use crate::command::CadCommand;
let Some((handle, underlay)) = self.selected_pdf_underlays(i).into_iter().next()
else {
return Some(Task::none());
};
let (command, first) =
crate::modules::insert::pdf_clip::PdfClipCommand::new_boundary(handle, underlay);
if let crate::command::CmdResult::ReportMeasurement(text) = first {
for line in text.lines() {
self.command_line.push_output(line);
}
}
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"_PDFULIMPORT" => {
use crate::command::CadCommand;
let Some((handle, _)) = self.selected_pdf_underlays(i).into_iter().next() else {
return Some(Task::none());
};
let command = crate::modules::insert::pdf_import::PdfImportCommand::for_underlay(handle);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
_ => return None,
}
Some(self.finish_dispatch(cmd))
}
}
+54
View File
@@ -1015,6 +1015,11 @@ impl OpenCADStudio {
| "FRAME"
| "IMAGEFRAME"
| "PDFFRAME"
| "PDFOSNAP"
| "UOSNAP"
| "PDFIMPORTMODE"
| "PDFIMPORTFILTER"
| "PDFIMPORTLAYERS"
| "POINTCLOUDCLIPFRAME"
| "XCLIPFRAME"
| "WIPEOUTFRAME"
@@ -1106,6 +1111,55 @@ impl OpenCADStudio {
}
return Some(self.finish_dispatch(cmd));
}
// Snapping to the geometry inside underlays (one switch for
// PDF underlays and underlays in general).
// The PDF Import Settings' options as bits.
if matches!(name.as_str(), "PDFIMPORTMODE" | "PDFIMPORTFILTER" | "PDFIMPORTLAYERS") {
use crate::modules::insert::pdf_import::{import_settings, set_import_settings, ImportLayers};
let mut settings = import_settings();
let (current, max) = match name.as_str() {
"PDFIMPORTMODE" => (settings.mode(), 31),
"PDFIMPORTFILTER" => (settings.filter(), 15),
_ => (settings.layers_value(), 2),
};
if let Some(value) = &value {
match value.parse::<i16>().ok().filter(|v| (0..=max).contains(v)) {
Some(v) => {
match name.as_str() {
"PDFIMPORTMODE" => settings.set_mode(v),
"PDFIMPORTFILTER" => settings.set_filter(v),
_ => {
settings.layers = [ImportLayers::Pdf, ImportLayers::Object, ImportLayers::Current][v as usize]
}
}
set_import_settings(settings);
}
None => self.command_line.push_error(&format!("Requires an integer between 0 and {max}.")),
}
} else {
self.command_line.push_output(&format!("Enter new value for {name} <{current}>:"));
self.pending_setvar = Some(name.clone());
}
return Some(self.finish_dispatch(cmd));
}
if matches!(name.as_str(), "PDFOSNAP" | "UOSNAP") {
let current = i16::from(crate::scene::model::pdf_vector::pdf_osnap());
if let Some(value) = &value {
match value.parse::<i16>().ok().filter(|value| (0..=1).contains(value)) {
Some(mode) => {
if current != mode {
crate::scene::model::pdf_vector::set_pdf_osnap(mode == 1);
self.tabs[i].scene.reseed_underlays();
}
}
None => self.command_line.push_error("Requires 0 or 1 only"),
}
} else {
self.command_line.push_output(&format!("Enter new value for {name} <{current}>:"));
self.pending_setvar = Some(name.clone());
}
return Some(self.finish_dispatch(cmd));
}
if matches!(name.as_str(), "SHOWHIST" | "SOLIDHIST") {
let current = if name == "SHOWHIST" {
self.tabs[i].scene.document.header.show_solid_history.clamp(0, 2)
+42
View File
@@ -47,6 +47,11 @@ fn color_value(color: acadrust::types::Color) -> Value {
}
pub(super) const NAMES: &[&str] = &[
"close_modal",
"pdf_dialog_ok",
"pdf_layer_toggle",
"pdf_page_select",
"ribbon_tab",
"ribbon_dropdown",
"dialog_ok",
"close_document",
"toggle_properties",
@@ -664,6 +669,43 @@ impl OpenCADStudio {
let name = string(req, "name")?;
let msg = match name {
"close_modal" => Message::CloseModal,
// The open PDF dialog's OK button.
// Underlay Layers: switch a layer of the shown underlay.
"pdf_layer_toggle" => Message::PdfDialog(
crate::ui::window::pdf_dialogs::PdfDialogMsg::LayersToggle(string(req, "value")?.into()),
),
// Attach dialog: choose pages by index ("0,2").
"pdf_page_select" => {
let pages: Vec<usize> = string(req, "value")?
.split(',')
.filter_map(|p| p.trim().parse().ok())
.collect();
let Some(state) = self.pdf_attach.as_mut() else {
return Err(failure("no_dialog", "The Attach dialog is not open"));
};
state.selected = pages;
return Ok(Task::none());
}
// Bring a ribbon tab forward by module id ("pdf_underlay" for the
// contextual underlay tab).
"ribbon_tab" => {
let id = string(req, "value")?;
if !self.ribbon.select_by_id(id) {
return Err(failure("no_tab", "No such ribbon tab"));
}
return Ok(Task::none());
}
"ribbon_dropdown" => Message::ToggleRibbonDropdown(string(req, "value")?.into()),
"pdf_dialog_ok" => {
use crate::ui::window::pdf_dialogs::PdfDialogMsg;
Message::PdfDialog(match self.active_modal {
Some(crate::app::ModalKind::PdfAttach) => PdfDialogMsg::AttachOk,
Some(crate::app::ModalKind::UnderlayLayers) => PdfDialogMsg::LayersOk,
Some(crate::app::ModalKind::PdfImportSettings) => PdfDialogMsg::SettingsOk,
Some(crate::app::ModalKind::PdfImportFile) => PdfDialogMsg::ImportOk,
_ => return Err(failure("no_dialog", "No PDF dialog is open")),
})
}
// The open dialog's OK button.
"dialog_ok" => match self.active_modal {
Some(crate::app::ModalKind::XrefAttach) => Message::XrefAttach(
+1 -1
View File
@@ -1199,7 +1199,7 @@ impl OpenCADStudio {
let Some(entity) = tab.scene.document.get_entity(handle) else {
continue;
};
let (min, max) = crate::scene::convert::tess::entity_bounds(entity);
let (min, max) = crate::scene::convert::tess::entity_bounds_in(&tab.scene.document, entity);
let metrics = tab
.scene
.meshes
+24
View File
@@ -493,6 +493,13 @@ pub(super) struct OpenCADStudio {
drawing_units: Option<crate::ui::window::drawing_units::State>,
/// Working copy of the Block Definition dialog; `None` while it is closed.
block_definition: Option<crate::ui::window::block_definition::BlockDefinitionState>,
/// PDF dialogs' working copies; `None` while closed.
pdf_attach: Option<crate::ui::window::pdf_dialogs::PdfAttachState>,
underlay_layers: Option<crate::ui::window::pdf_dialogs::UnderlayLayersState>,
pdf_import_settings: Option<crate::modules::insert::pdf_import::PdfImportSettings>,
pdf_import_file: Option<crate::ui::window::pdf_dialogs::PdfImportFileState>,
/// The dialog Options was opened from; it comes back when Options closes.
options_parent: Option<ModalKind>,
/// Working copy of the Attach External Reference dialog; None while closed.
xref_attach: Option<crate::ui::window::xref_attach::XrefAttachState>,
/// Working copy of the Write Block (WBLOCK) dialog; `None` while it is closed.
@@ -1884,6 +1891,10 @@ pub enum ModalKind {
LayerTranslator,
DrawingUnits,
BlockDefinition,
PdfAttach,
UnderlayLayers,
PdfImportSettings,
PdfImportFile,
XrefAttach,
WriteBlock,
GeometricTolerance,
@@ -3804,6 +3815,14 @@ pub enum Message {
// ── PDF Underlay ──────────────────────────────────────────────────────
/// Open file-picker dialog for PDFATTACH command (async).
PdfAttachPick,
/// PDFIMPORT File: pick the PDF to import.
PdfImportPick,
/// The contextual PDF Underlay tab.
RibbonSelectUnderlayTab,
/// An edit in one of the PDF dialogs.
PdfDialog(crate::ui::window::pdf_dialogs::PdfDialogMsg),
UnderlayTab(crate::ui::ribbon::UnderlayTabMsg),
PdfImportPickResult(Result<(std::path::PathBuf, std::sync::Arc<Vec<u8>>), String>),
/// Result of the PDFATTACH file picker.
PdfAttachPickResult(Result<(std::path::PathBuf, std::sync::Arc<Vec<u8>>), String>),
@@ -3982,6 +4001,11 @@ impl OpenCADStudio {
layer_translator: None,
drawing_units: None,
block_definition: None,
pdf_attach: None,
underlay_layers: None,
pdf_import_settings: None,
pdf_import_file: None,
options_parent: None,
xref_attach: None,
wblock: None,
geometric_tolerance: None,
+29 -18
View File
@@ -1498,25 +1498,29 @@ impl OpenCADStudio {
xref_rows(&mut sections, ins, &br.xref_path, factor, overrides);
}
}
// Underlay: name + path from the referenced definition.
// Underlay: name, page, path and size from the definition.
acadrust::EntityType::Underlay(ul) => {
if let Some((name, path)) =
doc.objects.iter().find_map(|(h, o)| match o {
acadrust::objects::ObjectType::UnderlayDefinition(def)
if *h == ul.definition_handle =>
{
let nm = if !def.name.is_empty() {
def.name.clone()
} else {
def.page_name.clone()
};
Some((nm, def.file_path.clone()))
}
_ => None,
})
{
set_row(&mut sections, "ul_name", name);
set_row(&mut sections, "ul_path", path);
use crate::entities::underlay as und;
if let Some(def) = und::definition(ul, doc) {
set_row(&mut sections, "ul_name", und::definition_display_name(def));
set_row(&mut sections, "ul_page", und::page_of(def).to_string());
set_row(
&mut sections,
"ul_path",
und::display_path(&def.file_path),
);
}
if let Some((w, h)) = und::shown_size(ul, doc) {
set_row_value(
&mut sections,
"ul_width",
crate::scene::model::object::PropValue::EditText(und::plain_number(w)),
);
set_row_value(
&mut sections,
"ul_height",
crate::scene::model::object::PropValue::EditText(und::plain_number(h)),
);
}
}
// Leader: text style / vertical text placement / overall
@@ -2355,6 +2359,12 @@ impl OpenCADStudio {
acadrust::EntityType::Dimension(
acadrust::entities::Dimension::Angular3Pt(_),
) => t!("3 Point Angular Dimension").into_owned(),
acadrust::EntityType::Underlay(underlay) => match underlay.underlay_type {
acadrust::entities::UnderlayType::Pdf => t!("PDF Underlay"),
acadrust::entities::UnderlayType::Dwf => t!("DWF Underlay"),
acadrust::entities::UnderlayType::Dgn => t!("DGN Underlay"),
}
.into_owned(),
_ => entity_type_label(entity),
};
// A dynamic dimension shows only its constraint and text
@@ -2619,6 +2629,7 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}",
let m_panel = t_all.map(|t| t.elapsed().as_secs_f64() * 1000.0);
let t_ribbon = crate::perf::enabled().then(iced::time::Instant::now);
self.sync_ribbon_from_selection();
self.sync_underlay_tab();
if let Some(t) = t_all {
let ribbon_ms = t_ribbon.map_or(0.0, |r| r.elapsed().as_secs_f64() * 1000.0);
let total_ms = t.elapsed().as_secs_f64() * 1000.0;
+32
View File
@@ -3455,6 +3455,38 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
} else {
raw_val
};
// Underlay rows: reject what the reference rejects, and
// write a Width / Height as the scale that produces it.
if field.starts_with("ul_") {
if let Err(message) =
crate::entities::underlay::validate_property(field, &val)
{
self.command_line.push_error(message);
return Task::none();
}
}
let (field, val) = match field {
"ul_width" | "ul_height" => {
let scale = handles.iter().find_map(|handle| {
match self.tabs[i].scene.document.get_entity(*handle) {
Some(acadrust::EntityType::Underlay(underlay)) => {
crate::entities::underlay::size_to_scale(
underlay,
&self.tabs[i].scene.document,
field,
&val,
)
}
_ => None,
}
});
match scale {
Some(scale) => ("ul_scale", scale),
None => return Task::none(),
}
}
_ => (field, val),
};
if matches!(
field,
"current_fit_point" | "current_control_point" | "pm_current_vertex"
+16
View File
@@ -990,6 +990,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
Err(msg) => self.command_line.push_error(msg.as_str()),
}
}
self.tabs[i].scene.reseed_underlays();
}
XrefPaletteOp::Reload => {
let base_dir: std::path::PathBuf = host
@@ -1016,6 +1017,21 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
"XREF: cannot reload nested reference '{}'. Reload it in its host drawing.",
name
).as_ref());
} else if *kind == crate::io::xref_model::RefKind::Pdf {
// A PDF reloads by clearing its definition's unloaded state.
for (key, row_name, row_kind, _) in &picked {
if row_kind != kind || row_name != name {
continue;
}
let handle = acadrust::types::Handle::new(*key);
if let Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) =
self.tabs[i].scene.document.objects.get_mut(&handle)
{
def.unloaded = false;
}
self.tabs[i].xref_unloaded.remove(key);
}
self.tabs[i].scene.reseed_underlays();
} else if *kind != crate::io::xref_model::RefKind::DwgXref {
self.command_line.push_error(crate::tf!(
"{}: reload applies to drawing references only.",
+49 -21
View File
@@ -128,12 +128,19 @@ impl OpenCADStudio {
/// Close the active in-canvas modal (Plan B), mirroring what closing the
/// old OS window did: a style editor discards its staged (un-applied)
/// changes, and the ribbon tool that launched the dialog is de-highlighted.
fn close_active_modal(&mut self) {
pub(in crate::app) fn close_active_modal(&mut self) {
self.mark_startup_modal_shown();
use super::ModalKind::*;
// Plot Style opened from PLOT behaves as a child modal.
// Closing it restores the parent Plot dialog instead of returning
// to the drawing.
// Options opened from a PDF dialog returns to it.
if self.active_modal == Some(Options) {
if let Some(parent) = self.options_parent.take() {
self.active_modal = Some(parent);
return;
}
}
if self.active_modal == Some(Plotstyle) {
if let Some((plot_offset, plot_resize)) = self.plotstyle_parent_plot_geometry.take() {
self.active_modal = Some(Plot);
@@ -208,6 +215,10 @@ impl OpenCADStudio {
}
Some(GeometricTolerance) => self.geometric_tolerance = None,
Some(BlockDefinition) => self.block_definition = None,
Some(PdfAttach) => self.pdf_attach = None,
Some(UnderlayLayers) => self.underlay_layers = None,
Some(PdfImportSettings) => self.pdf_import_settings = None,
Some(PdfImportFile) => self.pdf_import_file = None,
Some(XrefAttach) => self.xref_attach = None,
Some(WriteBlock) => self.wblock = None,
Some(Hyperlink) => {
@@ -1315,32 +1326,43 @@ impl OpenCADStudio {
),
Message::PdfAttachPickResult(Ok((path, bytes))) => {
use crate::command::CadCommand;
use crate::modules::insert::pdf_attach::PdfAttachCommand;
use acadrust::objects::{ObjectType, UnderlayDefinition};
let i = self.active_tab;
let path_str = path.to_string_lossy().into_owned();
let _ = i;
crate::scene::model::pdf_raster::register_source(&path_str, bytes);
let definition_handle = self.tabs[i].scene.document.allocate_handle();
let mut definition = UnderlayDefinition::pdf(&path_str, "1");
definition.handle = definition_handle;
self.tabs[i].scene.document.objects.insert(
definition_handle,
ObjectType::UnderlayDefinition(definition),
);
let cmd = PdfAttachCommand::new(definition_handle);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
// Pages, placement and path type are chosen in the Attach
// dialog; the definition is created with the underlay.
self.open_pdf_attach_dialog(&path_str);
Task::none()
}
Message::PdfImportPick => Task::perform(
async {
let handle = crate::sys::file_dialog()
.set_title(crate::t!("Select PDF File").as_ref())
.add_filter(crate::t!("PDF Files").as_ref(), &["pdf", "PDF"])
.pick_file()
.await;
match handle {
Some(h) => {
let path = crate::sys::handle_path(&h);
let bytes = std::sync::Arc::new(h.read().await);
Ok((path, bytes))
}
None => Err("Cancelled".to_string()),
}
},
Message::PdfImportPickResult,
),
Message::PdfImportPickResult(Ok((path, bytes))) => {
let i = self.active_tab;
let path_str = path.to_string_lossy().into_owned();
let _ = i;
crate::scene::model::pdf_raster::register_source(&path_str, bytes);
self.open_pdf_import_file(&path_str);
Task::none()
}
Message::PdfImportPickResult(Err(_)) => Task::none(),
Message::PdfAttachPickResult(Err(e)) => {
if e != "Cancelled" {
self.command_line
@@ -1750,6 +1772,12 @@ impl OpenCADStudio {
Task::none()
}
Message::RibbonSelectUnderlayTab => {
self.ribbon.select_underlay_tab();
Task::none()
}
Message::UnderlayTab(message) => self.update_underlay_tab(message),
Message::PdfDialog(message) => self.update_pdf_dialog(message),
Message::RibbonSelectTab(idx) => {
self.ribbon.select(idx);
Task::none()
+29
View File
@@ -24,6 +24,10 @@ impl OpenCADStudio {
Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(),
Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(),
Some(K::BlockDefinition) => crate::t!("Block Definition").into_owned(),
Some(K::PdfAttach) => crate::t!("Attach PDF Underlay").into_owned(),
Some(K::UnderlayLayers) => crate::t!("Underlay Layers").into_owned(),
Some(K::PdfImportSettings) => crate::t!("PDF Import Settings").into_owned(),
Some(K::PdfImportFile) => crate::t!("Import PDF").into_owned(),
Some(K::XrefAttach) => crate::t!("Attach External Reference").into_owned(),
Some(K::WriteBlock) => crate::t!("Write Block").into_owned(),
Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(),
@@ -508,6 +512,31 @@ impl OpenCADStudio {
crate::ui::window::block_definition::view_window(state, flow)
})
}
super::super::ModalKind::PdfAttach => {
let state = self.pdf_attach.as_ref()?;
let height = if state.details { 520 } else { 440 };
sized_flow(ex, 820, height, |flow| {
crate::ui::window::pdf_dialogs::view_attach(state, flow)
})
}
super::super::ModalKind::UnderlayLayers => {
let state = self.underlay_layers.as_ref()?;
sized_flow(ex, 500, 500, |flow| {
crate::ui::window::pdf_dialogs::view_layers(state, flow)
})
}
super::super::ModalKind::PdfImportSettings => {
let settings = self.pdf_import_settings.as_ref()?;
sized_flow(ex, 600, 360, |flow| {
crate::ui::window::pdf_dialogs::view_import_settings(settings, flow)
})
}
super::super::ModalKind::PdfImportFile => {
let state = self.pdf_import_file.as_ref()?;
sized_flow(ex, 900, 560, |flow| {
crate::ui::window::pdf_dialogs::view_import_file(state, flow)
})
}
super::super::ModalKind::XrefAttach => {
let state = self.xref_attach.as_ref()?;
let height = if state.details { 520 } else { 450 };
+13
View File
@@ -1556,6 +1556,19 @@ pub enum CmdResult {
BatchCopy(Vec<Handle>, Vec<EntityTransform>),
/// Erase `handle` and replace with new entities; command stays active.
ReplaceEntity(Handle, Vec<EntityType>),
/// Attach PDF pages: each page's underlay with its file (as stored) and
/// page; the host creates or reuses the definitions and commits them all
/// in one undo step, then ends the command.
/// Open the PDF Import Settings dialog; the command keeps its prompt.
OpenPdfImportSettings,
/// Import a page of a file (PDFIMPORT File); ends the command.
PdfImportFile(crate::modules::insert::pdf_import::PdfFileImport),
AttachPdfPages {
path: String,
pages: Vec<(String, EntityType)>,
},
/// Import the vector content of a PDF underlay (PDFIMPORT); ends the command.
PdfImport(crate::modules::insert::pdf_import::PdfImportRequest),
/// Update one entity in place, preserve its handle, and end the command.
UpdateEntityAndFinish {
handle: Handle,
+312 -205
View File
@@ -1,15 +1,18 @@
// Underlay entity — PDF/DWF/DGN reference.
//
// Render: clip boundary polygon (or cross at insertion if no boundary).
// Render: the visible outline (clip polygon, or the page frame) with a pick
// surface over it; a cross at the insertion when the page is unknown.
// Grips: insertion point + clip boundary vertices.
// Props: position, scales, rotation, contrast, fade, flags.
// Props: General → Underlay Adjust → Geometry → Misc, as the reference lists
// a PDF underlay. Rows that need the definition (name, page, path,
// width, height) are filled in by the Properties panel.
use acadrust::entities::{Underlay, UnderlayDisplayFlags};
use crate::t;
use glam::DVec3;
use crate::command::EntityTransform;
use crate::entities::common::{center_grip, edit_angle_prop as edit_angle, edit_prop as edit, ro_prop as ro, square_grip};
use crate::entities::common::{center_grip, ro_prop as ro, square_grip};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible};
use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
@@ -21,11 +24,7 @@ fn v3(v: &acadrust::types::Vector3) -> [f64; 3] {
[v.x, v.y, v.z]
}
fn v3f32(v: &acadrust::types::Vector3) -> [f32; 3] {
[v.x as f32, v.y as f32, v.z as f32]
}
/// Small cross marker at the insertion point (used when no clip boundary).
/// Small cross marker at the insertion point (used when the page is unknown).
fn cross_wire(origin: [f64; 3], size: f64) -> Vec<[f64; 3]> {
let [ox, oy, oz] = origin;
vec![
@@ -37,92 +36,275 @@ fn cross_wire(origin: [f64; 3], size: f64) -> Vec<[f64; 3]> {
]
}
// ── RenderConvertible ──────────────────────────────────────────────────────────
/// The underlay's page rectangle in world space (CCW from the insertion),
/// when its definition resolves and the page rasterises: page inches × the
/// entity scale, rotated about the insertion. `None` keeps the cross
/// placeholder.
fn page_quad(
/// The underlay's definition, when the handle resolves to one.
pub(crate) fn definition<'a>(
u: &Underlay,
document: &acadrust::CadDocument,
) -> Option<[[f64; 3]; 4]> {
let def = match document.objects.get(&u.definition_handle) {
Some(acadrust::objects::ObjectType::UnderlayDefinition(d)) => d,
_ => return None,
};
let page = if def.page_name.trim().is_empty() {
document: &'a acadrust::CadDocument,
) -> Option<&'a acadrust::entities::UnderlayDefinition> {
match document.objects.get(&u.definition_handle) {
Some(acadrust::objects::ObjectType::UnderlayDefinition(d)) => Some(d),
_ => None,
}
}
/// Page number of a definition ("1" when unset).
pub(crate) fn page_of(def: &acadrust::entities::UnderlayDefinition) -> &str {
if def.page_name.trim().is_empty() {
"1"
} else {
def.page_name.trim()
};
let raster = crate::scene::model::pdf_raster::rasterize_page(&def.file_path, page)?;
let w = raster.width as f64 / raster.dpi as f64 * u.x_scale;
let h = raster.height as f64 / raster.dpi as f64 * u.y_scale;
}
}
/// Size of the referenced page in underlay units (page inches), when the
/// definition resolves and its PDF page can be read.
pub(crate) fn page_size(u: &Underlay, document: &acadrust::CadDocument) -> Option<(f64, f64)> {
let def = definition(u, document)?;
if !matches!(def.underlay_type, acadrust::entities::UnderlayType::Pdf) {
return None;
}
crate::scene::model::pdf_raster::page_size_inches(&def.file_path, page_of(def))
}
/// The name a definition shows: its own name, or "<file> - <page>" as the
/// reference names a PDF definition.
pub(crate) fn definition_display_name(def: &acadrust::entities::UnderlayDefinition) -> String {
if !def.name.trim().is_empty() {
return def.name.clone();
}
let stem = std::path::Path::new(&def.file_path.replace('\\', "/"))
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
format!("{stem} - {}", page_of(def))
}
/// A stored path as the platform writes it (backslashes on Windows).
pub(crate) fn display_path(path: &str) -> String {
if cfg!(windows) {
path.replace('/', "\\")
} else {
path.to_string()
}
}
/// Clip polygon in underlay units (page inches). Two stored vertices are the
/// opposite corners of a rectangle.
pub(crate) fn clip_polygon_local(u: &Underlay) -> Vec<[f64; 2]> {
let v = &u.clip_boundary_vertices;
if v.len() == 2 {
let (a, b) = (&v[0], &v[1]);
return vec![[a.x, a.y], [b.x, a.y], [b.x, b.y], [a.x, b.y]];
}
v.iter().map(|p| [p.x, p.y]).collect()
}
/// Underlay units → world, through scale, rotation and insertion.
pub(crate) fn local_to_world(u: &Underlay, p: [f64; 2]) -> [f64; 3] {
let (c, s) = (u.rotation.cos(), u.rotation.sin());
let x = p[0] * u.x_scale;
let y = p[1] * u.y_scale;
let o = &u.insertion_point;
let ux = (c * w, s * w);
let vx = (-s * h, c * h);
[o.x + x * c - y * s, o.y + x * s + y * c, o.z]
}
/// Whether a clip boundary limits what is shown.
pub(crate) fn is_clipped(u: &Underlay) -> bool {
u.flags.contains(UnderlayDisplayFlags::CLIPPING) && clip_polygon_local(u).len() >= 3
}
/// Page frame in world space (CCW from the insertion).
fn page_quad(u: &Underlay, document: &acadrust::CadDocument) -> Option<[[f64; 3]; 4]> {
let (w, h) = page_size(u, document)?;
Some([
[o.x, o.y, o.z],
[o.x + ux.0, o.y + ux.1, o.z],
[o.x + ux.0 + vx.0, o.y + ux.1 + vx.1, o.z],
[o.x + vx.0, o.y + vx.1, o.z],
local_to_world(u, [0.0, 0.0]),
local_to_world(u, [w, 0.0]),
local_to_world(u, [w, h]),
local_to_world(u, [0.0, h]),
])
}
impl RenderConvertible for Underlay {
fn to_render(&self, _document: &acadrust::CadDocument) -> Option<RenderEntity> {
let origin = v3(&self.insertion_point);
let _origin_f32 = v3f32(&self.insertion_point);
/// Local-space extent of what is shown: the clip polygon's box when clipped
/// to its inside, else the page.
fn shown_local_extent(u: &Underlay, document: &acadrust::CadDocument) -> Option<(f64, f64)> {
if is_clipped(u) && !u.clip_inverted {
let clip = clip_polygon_local(u);
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
for p in &clip {
lo = [lo[0].min(p[0]), lo[1].min(p[1])];
hi = [hi[0].max(p[0]), hi[1].max(p[1])];
}
return Some((hi[0] - lo[0], hi[1] - lo[1]));
}
page_size(u, document)
}
if !self.clip_boundary_vertices.is_empty() {
// Draw clip boundary polygon + close it.
let world_verts = self.world_clip_boundary();
let mut pts: Vec<[f64; 3]> = world_verts.iter().map(|v| [v.x, v.y, v.z]).collect();
// Close polygon.
if let Some(&first) = pts.first() {
pts.push(first);
}
// Insertion grip.
let key: Vec<[f64; 3]> = pts.clone();
// Interior pick surface over the clip polygon so the underlay
// selects on a click anywhere inside, not just on its outline.
let ring: Vec<[f64; 3]> = world_verts.iter().map(|v| [v.x, v.y, v.z]).collect();
let pick_tris = crate::entities::mesh::triangulate_planar(&ring);
Some(RenderEntity {
pick_tris,
object: RenderObject::Lines(pts),
snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Node)],
tangent_geoms: vec![],
key_vertices: key,
fill_tris: vec![],
})
} else if let Some(q) = page_quad(self, _document) {
// Rendered page: draw its frame so selection/pick cover the
// visible extent instead of a lone cross under the raster.
let pts = vec![q[0], q[1], q[2], q[3], q[0]];
let pick_tris = crate::entities::mesh::triangulate_planar(&q.to_vec());
Some(RenderEntity {
pick_tris,
object: RenderObject::Lines(pts),
snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Node)],
tangent_geoms: vec![],
key_vertices: q.to_vec(),
fill_tris: vec![],
})
/// Width and height the Properties panel shows: the shown extent times the
/// scale (the clip's size once clipped, as the reference reports it).
pub(crate) fn shown_size(u: &Underlay, document: &acadrust::CadDocument) -> Option<(f64, f64)> {
shown_local_extent(u, document).map(|(w, h)| (w * u.x_scale.abs(), h * u.y_scale.abs()))
}
/// World bounds of the underlay: the clip polygon when clipped to its
/// inside, else the page frame. `None` when the page is unknown.
pub(crate) fn world_bounds(
u: &Underlay,
document: &acadrust::CadDocument,
) -> Option<([f64; 3], [f64; 3])> {
let outline: Vec<[f64; 3]> = if is_clipped(u) && !u.clip_inverted {
clip_polygon_local(u).into_iter().map(|p| local_to_world(u, p)).collect()
} else {
page_quad(u, document)?.to_vec()
};
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for p in outline {
for k in 0..3 {
min[k] = min[k].min(p[k]);
max[k] = max[k].max(p[k]);
}
}
Some((min, max))
}
/// A number as the reference Properties palette shows underlay values: up to
/// four decimals, trailing zeros dropped.
pub(crate) fn plain_number(value: f64) -> String {
let text = format!("{value:.4}");
let text = text.trim_end_matches('0').trim_end_matches('.').to_string();
if text == "-0" { "0".to_string() } else { text }
}
fn number_row(label: &str, field: &'static str, value: f64) -> Property {
Property {
label: label.into(),
field,
value: PropValue::EditText(plain_number(value)),
}
}
fn yes_no(label: &str, field: &'static str, flag: bool) -> Property {
Property {
label: label.into(),
field,
value: PropValue::Choice {
selected: if flag { t!("Yes") } else { t!("No") }.into_owned(),
options: vec![t!("Yes").into_owned(), t!("No").into_owned()],
},
}
}
/// A Yes/No list value (localized label, the English word, or a toggle).
fn parse_flag(value: &str, current: bool) -> bool {
let v = value.trim();
if v == "toggle" {
return !current;
}
v == t!("Yes").as_ref() || v.eq_ignore_ascii_case("yes") || v.eq_ignore_ascii_case("true")
}
/// Checks a value typed into an underlay row before it is written. The
/// messages are the reference's.
pub(crate) fn validate_property(field: &str, value: &str) -> Result<(), &'static str> {
let number = || value.trim().parse::<f64>().ok();
match field {
"ul_contrast" | "ul_fade" => match number() {
Some(v) if (0.0..=100.0).contains(&v) && v.fract() == 0.0 => Ok(()),
_ => Err("Value must be an integer between 0 and 100"),
},
"ul_scale" | "ul_width" | "ul_height" => match number() {
Some(v) if v > 0.0 => Ok(()),
_ => Err("Value must be positive and nonzero."),
},
_ => Ok(()),
}
}
/// Turns a Width / Height write into the scale that produces it, since the
/// size itself is the page (or clip) size times the scale.
pub(crate) fn size_to_scale(
u: &Underlay,
document: &acadrust::CadDocument,
field: &str,
value: &str,
) -> Option<String> {
let v = value.trim().parse::<f64>().ok()?;
let (w, h) = shown_local_extent(u, document)?;
let base = match field {
"ul_width" => w,
"ul_height" => h,
_ => return None,
};
(base > 0.0).then(|| (v / base).to_string())
}
// ── RenderConvertible ──────────────────────────────────────────────────────────
impl RenderConvertible for Underlay {
fn to_render(&self, document: &acadrust::CadDocument) -> Option<RenderEntity> {
let origin = v3(&self.insertion_point);
let insertion_snap = (
DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z),
SnapHint::Insertion,
);
let quad = page_quad(self, document);
let clip: Vec<[f64; 3]> = if is_clipped(self) {
clip_polygon_local(self)
.into_iter()
.map(|p| local_to_world(self, p))
.collect()
} else {
// No clip boundary: draw a cross at insertion point.
let pts = cross_wire(origin, 1.0);
Some(RenderEntity {
Vec::new()
};
// The outline is the clip boundary when clipped (plus the page frame
// for an outside clip), else the page frame.
let mut pts: Vec<[f64; 3]> = Vec::new();
let ring = |poly: &[[f64; 3]], pts: &mut Vec<[f64; 3]>| {
if !pts.is_empty() {
pts.push([f64::NAN; 3]);
}
pts.extend_from_slice(poly);
pts.push(poly[0]);
};
if !clip.is_empty() {
ring(&clip, &mut pts);
if self.clip_inverted {
if let Some(q) = quad {
ring(&q, &mut pts);
}
}
} else if let Some(q) = quad {
ring(&q, &mut pts);
}
if pts.is_empty() {
return Some(RenderEntity {
pick_tris: Vec::new(),
object: RenderObject::Lines(pts),
snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Node)],
object: RenderObject::Lines(cross_wire(origin, 1.0)),
snap_pts: vec![insertion_snap],
tangent_geoms: vec![],
key_vertices: vec![origin],
fill_tris: vec![],
})
});
}
// Pick surface over the shown area so a click inside selects it.
let pick_ring: Vec<[f64; 3]> = if !clip.is_empty() && !self.clip_inverted {
clip.clone()
} else {
quad.map(|q| q.to_vec()).unwrap_or_default()
};
let pick_tris = crate::entities::mesh::triangulate_planar(&pick_ring);
let mut snap_pts = vec![insertion_snap];
snap_pts.extend(crate::scene::model::pdf_vector::underlay_snap_points(self, document));
Some(RenderEntity {
pick_tris,
object: RenderObject::Lines(pts),
snap_pts,
tangent_geoms: vec![],
key_vertices: pick_ring,
fill_tris: vec![],
})
}
}
@@ -200,25 +382,6 @@ impl Grippable for Underlay {
impl PropertyEditable for Underlay {
fn geometry_properties(&self, _text_style_names: &[String]) -> Vec<PropSection> {
// Width / Height derived from the world-space clip boundary bounds
// (the only in-entity source of the placed footprint size).
let (width, height) = if self.clip_boundary_vertices.is_empty() {
(0.0_f64, 0.0_f64)
} else {
let verts = self.world_clip_boundary();
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for v in &verts {
min_x = min_x.min(v.x);
min_y = min_y.min(v.y);
max_x = max_x.max(v.x);
max_y = max_y.max(v.y);
}
(max_x - min_x, max_y - min_y)
};
let show = self.flags.contains(UnderlayDisplayFlags::ON);
let clipping = self.flags.contains(UnderlayDisplayFlags::CLIPPING);
let monochrome = self.flags.contains(UnderlayDisplayFlags::MONOCHROME);
@@ -226,74 +389,46 @@ impl PropertyEditable for Underlay {
vec![
PropSection {
title: t!("Geometry").into_owned(),
title: t!("Underlay Adjust").into_owned(),
props: vec![
edit(t!("Position X").as_ref(), "ul_ix", self.insertion_point.x),
edit(t!("Position Y").as_ref(), "ul_iy", self.insertion_point.y),
edit(t!("Position Z").as_ref(), "ul_iz", self.insertion_point.z),
edit(t!("Scale X").as_ref(), "ul_sx", self.x_scale),
edit(t!("Scale Y").as_ref(), "ul_sy", self.y_scale),
edit(t!("Scale Z").as_ref(), "ul_sz", self.z_scale),
ro(t!("Width").as_ref(), "ul_width", crate::entities::common::format_length(width)),
ro(t!("Height").as_ref(), "ul_height", crate::entities::common::format_length(height)),
edit_angle(t!("Rotation").as_ref(), "ul_rot", self.rotation.to_degrees()),
number_row(t!("Contrast").as_ref(), "ul_contrast", self.contrast as f64),
number_row(t!("Fade").as_ref(), "ul_fade", self.fade as f64),
yes_no(t!("Monochrome").as_ref(), "ul_mono", monochrome),
yes_no(t!("Adjust colors for background").as_ref(), "ul_adjust_bg", adjust_bg),
],
},
PropSection {
title: t!("Underlay Adjust").into_owned(),
title: t!("Geometry").into_owned(),
props: vec![
edit(t!("Contrast").as_ref(), "ul_contrast", self.contrast as f64),
edit(t!("Fade").as_ref(), "ul_fade", self.fade as f64),
Property {
label: t!("Monochrome").into_owned(),
field: "ul_mono",
value: PropValue::BoolToggle {
field: "ul_mono",
value: monochrome,
},
},
Property {
label: t!("Adjust Colors for Background").into_owned(),
field: "ul_adjust_bg",
value: PropValue::BoolToggle {
field: "ul_adjust_bg",
value: adjust_bg,
},
},
number_row(t!("Position X").as_ref(), "ul_ix", self.insertion_point.x),
number_row(t!("Position Y").as_ref(), "ul_iy", self.insertion_point.y),
number_row(t!("Position Z").as_ref(), "ul_iz", self.insertion_point.z),
number_row(t!("Rotation").as_ref(), "ul_rot", self.rotation.to_degrees()),
// Filled from the page size by the Properties panel.
number_row(t!("Width").as_ref(), "ul_width", 0.0),
number_row(t!("Height").as_ref(), "ul_height", 0.0),
number_row(t!("Scale").as_ref(), "ul_scale", self.x_scale),
],
},
PropSection {
title: t!("Misc").into_owned(),
props: vec![
// Underlay name/path/layers live on the separate
// UnderlayDefinition object, not reachable from the entity.
ro(t!("Underlay name").as_ref(), "ul_name", String::new()),
ro(t!("Underlay path").as_ref(), "ul_path", String::new()),
Property {
label: t!("Show underlay").into_owned(),
field: "ul_on",
value: PropValue::BoolToggle {
field: "ul_on",
value: show,
// Name, page and path live on the UnderlayDefinition and
// are filled in by the Properties panel.
ro(t!("Name").as_ref(), "ul_name", String::new()),
ro(t!("Page number").as_ref(), "ul_page", String::new()),
ro(t!("Saved Path").as_ref(), "ul_path", String::new()),
yes_no(t!("Show underlay").as_ref(), "ul_on", show),
yes_no(t!("Show clipped").as_ref(), "ul_clip", clipping),
ro(
t!("Layer display overrides").as_ref(),
"ul_layers",
if crate::scene::model::pdf_layers::hidden_layers(self).is_empty() {
t!("None").into_owned()
} else {
t!("Applied").into_owned()
},
},
Property {
label: t!("Clipping").into_owned(),
field: "ul_clip",
value: PropValue::BoolToggle {
field: "ul_clip",
value: clipping,
},
},
Property {
label: t!("Show clipped").into_owned(),
field: "ul_clip_inverted",
value: PropValue::BoolToggle {
field: "ul_clip_inverted",
value: self.clip_inverted,
},
},
ro(t!("Underlay layers").as_ref(), "ul_layers", String::new()),
),
],
},
]
@@ -302,73 +437,45 @@ impl PropertyEditable for Underlay {
fn apply_geom_prop(&mut self, field: &str, value: &str) {
match field {
"ul_mono" => {
let on = if value == "toggle" {
!self.flags.contains(UnderlayDisplayFlags::MONOCHROME)
} else {
value == "true"
};
let on = parse_flag(value, self.flags.contains(UnderlayDisplayFlags::MONOCHROME));
self.set_monochrome(on);
return;
}
"ul_adjust_bg" => {
let on = if value == "toggle" {
!self.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND)
} else {
value == "true"
};
if on {
self.flags |= UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND;
} else {
self.flags -= UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND;
}
let on = parse_flag(
value,
self.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND),
);
self.flags.set(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND, on);
return;
}
"ul_on" => {
let on = if value == "toggle" {
!self.flags.contains(UnderlayDisplayFlags::ON)
} else {
value == "true"
};
let on = parse_flag(value, self.flags.contains(UnderlayDisplayFlags::ON));
self.set_on(on);
return;
}
"ul_clip" => {
let on = if value == "toggle" {
!self.flags.contains(UnderlayDisplayFlags::CLIPPING)
} else {
value == "true"
};
if on {
self.flags |= UnderlayDisplayFlags::CLIPPING;
} else {
self.flags -= UnderlayDisplayFlags::CLIPPING;
}
return;
}
"ul_clip_inverted" => {
let on = if value == "toggle" {
!self.clip_inverted
} else {
value == "true"
};
self.clip_inverted = on;
let on = parse_flag(value, self.flags.contains(UnderlayDisplayFlags::CLIPPING));
self.flags.set(UnderlayDisplayFlags::CLIPPING, on);
return;
}
_ => {}
}
if let Ok(v) = value.trim().parse::<f64>() {
match field {
"ul_ix" => self.insertion_point.x = v,
"ul_iy" => self.insertion_point.y = v,
"ul_iz" => self.insertion_point.z = v,
"ul_sx" => self.x_scale = v,
"ul_sy" => self.y_scale = v,
"ul_sz" => self.z_scale = v,
"ul_rot" => self.rotation = v.to_radians(),
"ul_contrast" => self.set_contrast(v.clamp(0.0, 100.0) as u8),
"ul_fade" => self.set_fade(v.clamp(0.0, 80.0) as u8),
_ => {}
}
if validate_property(field, value).is_err() {
return;
}
let Some(v) = crate::entities::common::parse_f64(value) else {
return;
};
match field {
"ul_ix" => self.insertion_point.x = v,
"ul_iy" => self.insertion_point.y = v,
"ul_iz" => self.insertion_point.z = v,
"ul_scale" => self.set_scale(v),
"ul_rot" => self.rotation = v.to_radians(),
"ul_contrast" => self.set_contrast(v as u8),
"ul_fade" => self.set_fade(v as u8),
_ => {}
}
}
}
+1
View File
@@ -372,6 +372,7 @@ async fn open_path_with_phase_attempt(
});
callback
};
crate::io::xref::register_underlay_sources(&doc, base_dir);
crate::io::xref::resolve_xrefs_with_progress(
&mut doc,
base_dir,
+30 -2
View File
@@ -305,6 +305,25 @@ fn resolve_xrefs_with_filter(
(result, dropped)
}
/// Underlay files named by a relative (or moved) path: read them from where
/// the drawing is and register the bytes under the stored path, which is
/// what the underlay's definition names.
#[cfg(not(target_arch = "wasm32"))]
pub fn register_underlay_sources(doc: &CadDocument, base_dir: &Path) {
for object in doc.objects.values() {
let acadrust::objects::ObjectType::UnderlayDefinition(def) = object else {
continue;
};
let stored = def.file_path.as_str();
if stored.is_empty() || Path::new(stored).is_absolute() && Path::new(stored).exists() {
continue;
}
if let Some(bytes) = resolve_path(stored, base_dir).and_then(|p| std::fs::read(p).ok()) {
crate::scene::model::pdf_raster::register_source(stored, std::sync::Arc::new(bytes));
}
}
}
/// Layer properties an override can change, as compared for the
/// "Layer property overrides" row.
#[derive(Clone, PartialEq)]
@@ -1244,7 +1263,7 @@ pub fn collect_entries_with_prev(
};
let mut entry = ReferenceEntry::new(key, name, RefKind::Pdf);
entry.saved_path = def.file_path.clone();
if unloaded.contains(&UnloadKey::Direct(key)) {
if def.unloaded || unloaded.contains(&UnloadKey::Direct(key)) {
entry.status = RefStatus::Unloaded;
resolve_only(&mut entry, &def.file_path, base_dir);
} else {
@@ -1437,7 +1456,16 @@ pub fn unload_reference(doc: &mut CadDocument, key: u64) -> Result<String, Strin
}
Ok(name)
}
RefTarget::Image { name, .. } | RefTarget::Pdf { name, .. } => Ok(name),
RefTarget::Image { name, .. } => Ok(name),
// A PDF keeps its unloaded state on the definition (saved with it).
RefTarget::Pdf { handle, name } => {
if let Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) =
doc.objects.get_mut(&handle)
{
def.unloaded = true;
}
Ok(name)
}
}
}
+54
View File
@@ -2942,7 +2942,61 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Unable to save: file is in use." => Some(("command-line", "unable-to-save-file-is-in-use")),
"Unable to verify the server certificate" => Some(("plugins", "unable-to-verify-the-server-certificate")),
"Unavailable" => Some(("plugins", "unavailable")),
"PDF scale: {scale}:1" => Some(("insert", "pdfui-pdf-scale")),
"Applied" => Some(("properties", "applied")),
"No PDF underlays found." => Some(("insert", "pdfui-no-pdf-underlays")),
"Specify insertion point:" => Some(("insert", "pdfui-specify-insertion-point")),
"Underlay" => Some(("properties", "underlay")),
"PDF Underlay" => Some(("properties", "pdf-underlay")),
"DWF Underlay" => Some(("properties", "dwf-underlay")),
"DGN Underlay" => Some(("properties", "dgn-underlay")),
"Adjust colors for background" => Some(("properties", "adjust-colors-for-background")),
"Page number" => Some(("properties", "page-number")),
"Layer display overrides" => Some(("properties", "layer-display-overrides")),
"Display in\nMonochrome" => Some(("insert", "pdfui-display-in-monochrome")),
"Create Clipping\nBoundary" => Some(("insert", "pdfui-create-clipping-boundary")),
"Remove\nClipping" => Some(("insert", "pdfui-remove-clipping")),
"Show\nUnderlay" => Some(("insert", "pdfui-show-underlay")),
"Enable\nSnap" => Some(("insert", "pdfui-enable-snap")),
"External\nReferences" => Some(("insert", "pdfui-external-references")),
"Edit\nLayers" => Some(("insert", "pdfui-edit-layers")),
"Import As\nObjects" => Some(("insert", "pdfui-import-as-objects")),
"PDF Layers" => Some(("insert", "pdfui-pdf-layers")),
"PDF Import" => Some(("insert", "pdfui-pdf-import")),
"Attach PDF Underlay" => Some(("insert", "pdfui-attach-pdf-underlay")),
"Underlay Layers" => Some(("insert", "pdfui-underlay-layers")),
"PDF Import Settings" => Some(("insert", "pdfui-pdf-import-settings")),
"Import PDF" => Some(("insert", "pdfui-import-pdf")),
"Select one or more pages from the PDF file:" => Some(("insert", "pdfui-select-one-or-more-pages-from-the-pdf-file")),
"Page size:" => Some(("insert", "pdfui-page-size")),
"Reference name:" => Some(("insert", "pdfui-reference-name")),
"Search for layer" => Some(("insert", "pdfui-search-for-layer")),
"This file does not contain any layers." => Some(("insert", "pdfui-this-file-does-not-contain-any-layers")),
"Select an underlay to view its layers." => Some(("insert", "pdfui-select-an-underlay-to-view-its-layers")),
"PDF data to import" => Some(("insert", "pdfui-pdf-data-to-import")),
"Vector geometry" => Some(("insert", "pdfui-vector-geometry")),
"Solid fills" => Some(("insert", "pdfui-solid-fills")),
"TrueType text" => Some(("insert", "pdfui-truetype-text")),
"Raster images" => Some(("insert", "pdfui-raster-images")),
"Use PDF layers" => Some(("insert", "pdfui-use-pdf-layers")),
"Create object layers" => Some(("insert", "pdfui-create-object-layers")),
"Import options" => Some(("insert", "pdfui-import-options")),
"Import as block" => Some(("insert", "pdfui-import-as-block")),
"Join line and arc segments" => Some(("insert", "pdfui-join-line-and-arc-segments")),
"Convert solid fills to hatches" => Some(("insert", "pdfui-convert-solid-fills-to-hatches")),
"Apply lineweight properties" => Some(("insert", "pdfui-apply-lineweight-properties")),
"Infer linetypes from collinear dashes" => Some(("insert", "pdfui-infer-linetypes-from-collinear-dashes")),
"Page to import" => Some(("insert", "pdfui-page-to-import")),
"Page:" => Some(("insert", "pdfui-page")),
"Total: {count}" => Some(("insert", "pdfui-total-count")),
"Page size: {size}" => Some(("insert", "pdfui-page-size-size")),
"Specify insertion point on-screen" => Some(("insert", "pdfui-specify-insertion-point-on-screen")),
"Scale:" => Some(("insert", "pdfui-scale")),
"Rotation:" => Some(("insert", "pdfui-rotation")),
"Attaches a PDF file as an underlay." => Some(("insert", "pdfui-attaches-a-pdf-file-as-an-underlay")),
"Turns the layers of a PDF underlay on or off." => Some(("insert", "pdfui-turns-the-layers-of-a-pdf-underlay-on-or-off")),
"Imports the geometry, fills, raster images and text of a PDF file as drawing objects." => Some(("insert", "pdfui-imports-the-geometry-fills-raster-images-and-text-of-a-pdf-f")),
"Select PDF File" => Some(("insert", "select-pdf-file")),
"Underlay\nLayers" => Some(("insert", "underlay-layers")),
"Underlay Adjust" => Some(("properties", "underlay-adjust")),
"Underlay layers" => Some(("properties", "underlay-layers")),
+2
View File
@@ -17,6 +17,8 @@ mod mview_block;
mod open_obj;
mod pc_attach;
pub(crate) mod pdf_attach;
pub(crate) mod pdf_clip;
pub(crate) mod pdf_import;
mod snap_underlays;
pub(crate) mod solid3d_cmds;
mod underlay_layers;
+497 -19
View File
@@ -1,11 +1,27 @@
use acadrust::entities::Underlay;
// PDFATTACH / -PDFATTACH — attach a PDF page as an underlay.
//
// Path to PDF file to attach: (-PDFATTACH only)
// Enter page number or [?] <1>:
// Specify insertion point:
// Base image size: Width: 7.8740, Height: 3.9369, Meters
// Specify scale factor or [Unit] <1>:
// Specify rotation <0>:
//
// The definition (one per file and page, named "<file> - <page>" in the
// ACAD_PDFDEFINITIONS dictionary) is created with the underlay when it is
// placed, so a cancelled attach leaves nothing and undo removes both.
use std::sync::Mutex;
use acadrust::entities::{Underlay, UnderlayDisplayFlags};
use acadrust::objects::{Dictionary, ObjectType, UnderlayDefinition};
use acadrust::types::{Handle, Vector3};
use acadrust::EntityType;
use acadrust::{CadDocument, EntityType};
use glam::DVec3;
use crate::command::{CadCommand, CmdResult, WorkingPlane};
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, WorkingPlane};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::t;
use crate::scene::model::wire_model::WireModel;
pub const ICON: IconKind =
IconKind::Svg(include_bytes!("../../../assets/icons/underlay_layers.svg"));
@@ -19,18 +35,310 @@ pub fn tool() -> ToolDef {
}
}
/// Units the Unit option offers: keyword, the name the prompt shows.
const UNITS: [(&str, &str); 9] = [
("MM", "Millimeters"),
("Centimeter", "Centimeters"),
("Meter", "Meters"),
("Kilometer", "Kilometers"),
("Inch", "Inches"),
("Foot", "Feet"),
("Yard", "Yards"),
("MILe", "Miles"),
("Unitless", "Unitless"),
];
/// The unit last chosen with the Unit option; it stays the default.
static LAST_UNIT: Mutex<Option<usize>> = Mutex::new(None);
/// The Unit option's index for the drawing's INSUNITS.
fn unit_for_insunits(insunits: i16) -> usize {
match insunits {
4 => 0,
5 => 1,
6 => 2,
7 => 3,
1 => 4,
2 => 5,
10 => 6,
3 => 7,
_ => 8,
}
}
fn unit_from_keyword(text: &str) -> Option<usize> {
let t = text.trim().to_ascii_uppercase();
let short = match t.as_str() {
"MM" => Some(0),
"C" => Some(1),
"M" => Some(2),
"K" => Some(3),
"I" => Some(4),
"F" => Some(5),
"Y" => Some(6),
"MIL" => Some(7),
"U" => Some(8),
_ => None,
};
short.or_else(|| {
UNITS.iter().position(|(keyword, _)| {
let k = keyword.to_ascii_uppercase();
t.len() >= 2 && k.starts_with(&t)
})
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Step {
Path,
Page,
ListPages,
Insertion,
Scale,
Unit,
Rotation,
}
pub struct PdfAttachCommand {
definition_handle: Handle,
step: Step,
/// The file read for the page (absolute or registered source).
path: String,
/// The path the definition stores (full, relative or file name only).
stored_path: Option<String>,
/// Pages after the first, attached beside it (dialog selection).
extra_pages: Vec<String>,
/// Scale and rotation fixed in the dialog: their prompts are skipped.
preset_scale: Option<f64>,
preset_rotation: Option<f64>,
page: String,
page_count: usize,
page_size: (f64, f64),
insertion: DVec3,
scale: f64,
unit: usize,
plane: WorkingPlane,
}
impl PdfAttachCommand {
pub fn new(definition_handle: Handle) -> Self {
/// `-PDFATTACH`: starts by asking for the file.
pub fn new(insunits: i16) -> Self {
let unit = LAST_UNIT
.lock()
.ok()
.and_then(|last| *last)
.unwrap_or_else(|| unit_for_insunits(insunits));
Self {
definition_handle,
step: Step::Path,
path: String::new(),
stored_path: None,
extra_pages: Vec::new(),
preset_scale: None,
preset_rotation: None,
page: "1".to_string(),
page_count: 0,
page_size: (0.0, 0.0),
insertion: DVec3::ZERO,
scale: 1.0,
unit,
plane: WorkingPlane::default(),
}
}
/// PDFATTACH with a file already chosen: starts at the page prompt.
pub fn with_file(path: &str, insunits: i16) -> Self {
let mut command = Self::new(insunits);
if let Some(count) = crate::scene::model::pdf_raster::page_count(path) {
command.path = path.to_string();
command.page_count = count;
command.step = Step::Page;
}
command
}
/// The dialog's attach with the insertion point asked on screen: pages
/// chosen, the path to store, and any scale / rotation already given.
pub fn from_dialog(
read_path: &str,
stored_path: &str,
pages: &[String],
scale: Option<f64>,
rotation_deg: Option<f64>,
insunits: i16,
) -> Self {
let mut command = Self::with_file(read_path, insunits);
command.stored_path = Some(stored_path.to_string());
if let Some((first, rest)) = pages.split_first() {
command.page = first.clone();
command.extra_pages = rest.to_vec();
}
command.page_size =
crate::scene::model::pdf_raster::page_size_inches(read_path, &command.page)
.unwrap_or((0.0, 0.0));
command.preset_scale = scale;
if let Some(s) = scale {
command.scale = s;
}
command.preset_rotation = rotation_deg;
command.step = Step::Insertion;
command
}
/// After the insertion point or the scale: the next prompt, or the
/// attach itself when the dialog fixed what is left.
fn after_scale(&mut self) -> CmdResult {
match self.preset_rotation {
Some(deg) => self.commit(deg.to_radians()),
None => {
self.step = Step::Rotation;
CmdResult::NeedPoint
}
}
}
fn base_image_size(&self) -> String {
format!(
"Base image size: Width: {:.4}, Height: {:.4}, {}",
self.page_size.0, self.page_size.1, UNITS[self.unit].1
)
}
fn accept_path(&mut self, text: &str) -> CmdResult {
let mut path = text.trim().trim_matches('"').to_string();
if path.is_empty() {
return CmdResult::NeedPoint;
}
if std::path::Path::new(&path).extension().is_none() {
path.push_str(".pdf");
}
match crate::scene::model::pdf_raster::page_count(&path) {
Some(count) if count > 0 => {
self.path = path;
self.page_count = count;
self.step = Step::Page;
CmdResult::NeedPoint
}
_ => CmdResult::ReportError(format!(
"{} not found.",
crate::entities::underlay::display_path(&path)
)),
}
}
fn accept_page(&mut self, text: &str) -> CmdResult {
let text = text.trim();
if text == "?" {
self.step = Step::ListPages;
return CmdResult::NeedPoint;
}
let page = if text.is_empty() { "1" } else { text };
match page.parse::<usize>() {
Ok(n) if n >= 1 && n <= self.page_count => {
self.page = n.to_string();
self.page_size = crate::scene::model::pdf_raster::page_size_inches(&self.path, &self.page)
.unwrap_or((0.0, 0.0));
self.step = Step::Insertion;
CmdResult::NeedPoint
}
_ => CmdResult::ReportError(format!(
"There is no {page} in {}.",
crate::entities::underlay::display_path(&self.path)
)),
}
}
fn list_pages(&mut self, pattern: &str) -> CmdResult {
let pattern = if pattern.trim().is_empty() { "*" } else { pattern.trim() };
let lines: Vec<String> = (1..=self.page_count)
.map(|n| n.to_string())
.filter(|n| crate::io::xref_model::wildcard_match(n, pattern))
.collect();
self.step = Step::Page;
CmdResult::ReportMeasurement(lines.join("\n"))
}
fn accept_scale(&mut self, text: &str) -> CmdResult {
let text = text.trim();
if text.is_empty() {
return self.after_scale();
}
if text.eq_ignore_ascii_case("U") || text.eq_ignore_ascii_case("UNIT") {
self.step = Step::Unit;
return CmdResult::NeedPoint;
}
match crate::entities::common::parse_f64(text) {
Some(v) if v > 0.0 => {
self.scale = v;
self.after_scale()
}
Some(_) => CmdResult::ReportError("Value must be positive and nonzero.".to_string()),
None => CmdResult::ReportError("Requires numeric value or option keyword.".to_string()),
}
}
fn accept_unit(&mut self, text: &str) -> CmdResult {
let unit = if text.trim().is_empty() {
Some(self.unit)
} else {
unit_from_keyword(text)
};
let Some(unit) = unit else {
return CmdResult::ReportError("Invalid option keyword.".to_string());
};
self.unit = unit;
if let Ok(mut last) = LAST_UNIT.lock() {
*last = Some(unit);
}
self.step = Step::Scale;
CmdResult::ReportMeasurement(self.base_image_size())
}
fn accept_rotation(&mut self, text: &str) -> CmdResult {
let text = text.trim();
let degrees = if text.is_empty() {
0.0
} else {
match crate::entities::common::parse_f64(text) {
Some(v) => v,
None => {
return CmdResult::ReportError(
"Requires numeric angle or second point.".to_string(),
)
}
}
};
self.commit(degrees.to_radians())
}
fn commit(&mut self, rotation: f64) -> CmdResult {
let pages: Vec<String> = std::iter::once(self.page.clone())
.chain(self.extra_pages.iter().cloned())
.collect();
let placed = underlays_for_pages(
&self.path,
&pages,
self.plane.to_local(self.insertion),
self.scale,
rotation,
)
.into_iter()
.map(|(page, underlay)| (page, self.plane.place_entity(underlay)))
.collect();
CmdResult::AttachPdfPages {
path: self.stored_path.clone().unwrap_or_else(|| self.path.clone()),
pages: placed,
}
}
fn frame_at(&self, pt: DVec3) -> Vec<[f32; 3]> {
let (w, h) = (self.page_size.0 * self.scale, self.page_size.1 * self.scale);
let x = self.plane.vector_to_world(DVec3::new(w, 0.0, 0.0));
let y = self.plane.vector_to_world(DVec3::new(0.0, h, 0.0));
let corners = [pt, pt + x, pt + x + y, pt + y, pt];
corners
.iter()
.map(|p| [p.x as f32, p.y as f32, p.z as f32])
.collect()
}
}
impl CadCommand for PdfAttachCommand {
@@ -43,27 +351,197 @@ impl CadCommand for PdfAttachCommand {
}
fn prompt(&self) -> String {
t!("PDFATTACH Specify insertion point:").into_owned()
match self.step {
Step::Path => "Path to PDF file to attach:".to_string(),
Step::Page => "Enter page number or [?] <1>:".to_string(),
Step::ListPages => "Enter page(s) to list <*>:".to_string(),
Step::Insertion => "Specify insertion point:".to_string(),
Step::Scale => "Specify scale factor or [Unit] <1>:".to_string(),
Step::Unit => format!(
"Enter unit [MM/Centimeter/Meter/Kilometer/Inch/Foot/Yard/MILe/Unitless] <{}>:",
UNITS[self.unit].0
),
Step::Rotation => "Specify rotation <0>:".to_string(),
}
}
fn options(&self) -> Vec<CmdOption> {
match self.step {
Step::Page => vec![CmdOption::new("?", "?")],
Step::Scale => vec![CmdOption::new("Unit", "U")],
Step::Unit => vec![
CmdOption::new("MM", "MM"),
CmdOption::new("Centimeter", "C"),
CmdOption::new("Meter", "M"),
CmdOption::new("Kilometer", "K"),
CmdOption::new("Inch", "I"),
CmdOption::new("Foot", "F"),
CmdOption::new("Yard", "Y"),
CmdOption::new("MILe", "MIL"),
CmdOption::new("Unitless", "U"),
],
_ => Vec::new(),
}
}
fn input_kind(&self) -> InputKind {
match self.step {
Step::Path => InputKind::FreeText,
Step::Insertion => InputKind::Point,
_ => InputKind::SingleToken,
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
let point = self.plane.to_local(pt);
if self.step != Step::Insertion {
return CmdResult::NeedPoint;
}
self.insertion = pt;
if self.preset_scale.is_some() {
return self.after_scale();
}
self.step = Step::Scale;
CmdResult::ReportMeasurement(self.base_image_size())
}
let mut underlay = Underlay::pdf();
underlay.definition_handle = self.definition_handle;
underlay.insertion_point = Vector3::new(point.x, point.y, point.z);
CmdResult::CommitAndExit(
self.plane
.place_entity(EntityType::Underlay(underlay)),
)
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
Some(match self.step {
Step::Path => self.accept_path(text),
Step::Page => self.accept_page(text),
Step::ListPages => self.list_pages(text),
Step::Insertion => return None,
Step::Scale => self.accept_scale(text),
Step::Unit => self.accept_unit(text),
Step::Rotation => self.accept_rotation(text),
})
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
match self.step {
Step::Path => CmdResult::Cancel,
Step::Page => self.accept_page(""),
Step::ListPages => self.list_pages("*"),
Step::Insertion => {
CmdResult::ReportError("Point or option keyword required.".to_string())
}
Step::Scale => self.accept_scale(""),
Step::Unit => self.accept_unit(""),
Step::Rotation => self.accept_rotation(""),
}
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
if self.step != Step::Insertion || self.page_size.0 <= 0.0 {
return None;
}
Some(WireModel::solid(
"pdf_attach_frame".into(),
self.frame_at(pt),
WireModel::CYAN,
false,
))
}
}
/// Underlays for `pages` of a file: the first at `insertion`, each next one
/// beside the previous along the rotated X axis (page width times scale).
// ponytail: side-by-side placement of the pages after the first is not
// measured against the reference.
pub fn underlays_for_pages(
read_path: &str,
pages: &[String],
insertion: DVec3,
scale: f64,
rotation: f64,
) -> Vec<(String, EntityType)> {
let mut origin = insertion;
let (c, s) = (rotation.cos(), rotation.sin());
pages
.iter()
.map(|page| {
let mut underlay = Underlay::pdf();
underlay.insertion_point = Vector3::new(origin.x, origin.y, origin.z);
underlay.set_scale(scale);
underlay.rotation = rotation;
// On, clipped by its boundary and colour-adjusted for the
// background, as the reference creates an underlay.
underlay.flags = UnderlayDisplayFlags::ON
| UnderlayDisplayFlags::CLIPPING
| UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND;
let width = crate::scene::model::pdf_raster::page_size_inches(read_path, page)
.map_or(0.0, |(w, _)| w * scale);
origin += DVec3::new(c * width, s * width, 0.0);
(page.clone(), EntityType::Underlay(underlay))
})
.collect()
}
/// The `ACAD_PDFDEFINITIONS` dictionary under the named-objects root,
/// created when the drawing has none.
fn ensure_pdf_dictionary(document: &mut CadDocument) -> Option<Handle> {
let root = document.header.named_objects_dict_handle;
let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get(&root) else {
return None;
};
if let Some(existing) = root_dictionary.get("ACAD_PDFDEFINITIONS") {
if matches!(document.objects.get(&existing), Some(ObjectType::Dictionary(_))) {
return Some(existing);
}
}
let handle = document.allocate_handle();
let mut dictionary = Dictionary::new();
dictionary.handle = handle;
dictionary.owner = root;
document.objects.insert(handle, ObjectType::Dictionary(dictionary));
if let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get_mut(&root) {
root_dictionary.add_entry("ACAD_PDFDEFINITIONS", handle);
}
Some(handle)
}
fn same_path(a: &str, b: &str) -> bool {
let norm = |p: &str| p.replace('\\', "/").to_lowercase();
norm(a) == norm(b)
}
/// The PDF definition for a file and page: the existing one, or a new one
/// registered in ACAD_PDFDEFINITIONS as "<file> - <page>".
pub fn ensure_pdf_definition(document: &mut CadDocument, path: &str, page: &str) -> Handle {
let existing = document.objects.iter().find_map(|(handle, object)| match object {
ObjectType::UnderlayDefinition(def)
if matches!(def.underlay_type, acadrust::entities::UnderlayType::Pdf)
&& same_path(&def.file_path, path)
&& crate::entities::underlay::page_of(def) == page =>
{
Some(*handle)
}
_ => None,
});
if let Some(handle) = existing {
return handle;
}
let handle = document.allocate_handle();
let mut definition = UnderlayDefinition::pdf(path, page);
definition.handle = handle;
let base = crate::entities::underlay::definition_display_name(&definition);
if let Some(dictionary) = ensure_pdf_dictionary(document) {
definition.owner_handle = dictionary;
if let Some(ObjectType::Dictionary(entries)) = document.objects.get_mut(&dictionary) {
let mut key = base.clone();
let mut suffix = 1;
while entries.get(&key).is_some() {
suffix += 1;
key = format!("{base}({suffix})");
}
entries.add_entry(key, handle);
}
}
document
.objects
.insert(handle, ObjectType::UnderlayDefinition(definition));
handle
}
inventory::submit!(crate::command::CommandRegistration {
names: &["PDFATTACH"]
names: &["PDFATTACH", "-PDFATTACH"]
});
+347
View File
@@ -0,0 +1,347 @@
// PDFCLIP / CLIP — clip a PDF underlay to a boundary.
//
// Select PDF to clip: (CLIP: Select Object to clip:)
// Enter PDF clipping option [ON/OFF/Delete/New boundary] <New boundary>:
// Delete old boundary? [Yes/No] <Yes>: (when one exists)
// Outside mode - Objects outside boundary will be hidden.
// Specify clipping boundary or select invert option:
// [Select polyline/Polygonal/Rectangular/Invert clip] <Rectangular>:
//
// The boundary is stored in underlay units; the clip-inside bit follows the
// Invert choice.
use acadrust::entities::{Underlay, UnderlayDisplayFlags, UnderlayType};
use acadrust::types::{Handle, Vector2};
use acadrust::EntityType;
use glam::DVec3;
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind};
use crate::scene::model::wire_model::WireModel;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Step {
Select,
Option,
DeleteOld,
Mode,
RectFirst,
RectSecond,
PolyPoints,
Polyline,
}
pub struct PdfClipCommand {
/// CLIP asks for any object; PDFCLIP for a PDF.
generic: bool,
step: Step,
handle: Handle,
underlay: Option<Underlay>,
picked: Option<EntityType>,
inverted: bool,
points: Vec<DVec3>,
}
const OUTSIDE: &str = "Outside mode - Objects outside boundary will be hidden.";
const INSIDE: &str = "Inside mode - Objects inside boundary will be hidden.";
const BOUNDARY: &str = "Specify clipping boundary or select invert option:";
impl PdfClipCommand {
pub fn new(generic: bool) -> Self {
Self {
generic,
step: Step::Select,
handle: Handle::NULL,
underlay: None,
picked: None,
inverted: false,
points: Vec::new(),
}
}
/// A new boundary for an underlay already chosen (the underlay tab's
/// Create Clipping Boundary): the New boundary branch at once. Returns the
/// command and the result of its first step.
pub fn new_boundary(handle: Handle, underlay: Underlay) -> (Self, CmdResult) {
let mut command = Self::new(false);
command.handle = handle;
command.underlay = Some(underlay);
command.step = Step::Option;
let first = command.option("N");
(command, first)
}
fn finish(&self, underlay: Underlay) -> CmdResult {
CmdResult::UpdateEntityAndFinish {
handle: self.handle,
entity: EntityType::Underlay(underlay),
}
}
fn enter_mode(&mut self) -> CmdResult {
self.step = Step::Mode;
let mode = if self.inverted { INSIDE } else { OUTSIDE };
CmdResult::ReportMeasurement(format!("{mode}\n{BOUNDARY}"))
}
fn to_local(u: &Underlay, p: DVec3) -> Vector2 {
let (c, s) = (u.rotation.cos(), u.rotation.sin());
let dx = p.x - u.insertion_point.x;
let dy = p.y - u.insertion_point.y;
let sx = if u.x_scale.abs() > 1e-12 { u.x_scale } else { 1.0 };
let sy = if u.y_scale.abs() > 1e-12 { u.y_scale } else { 1.0 };
Vector2::new((dx * c + dy * s) / sx, (-dx * s + dy * c) / sy)
}
fn apply_boundary(&self, world: &[DVec3]) -> CmdResult {
let Some(mut underlay) = self.underlay.clone() else {
return CmdResult::Cancel;
};
underlay.clip_boundary_vertices = world.iter().map(|p| Self::to_local(&underlay, *p)).collect();
underlay.flags |= UnderlayDisplayFlags::CLIPPING;
underlay.clip_inverted = self.inverted;
self.finish(underlay)
}
fn option(&mut self, text: &str) -> CmdResult {
let Some(mut underlay) = self.underlay.clone() else {
return CmdResult::Cancel;
};
match text.trim().to_ascii_uppercase().as_str() {
"ON" => {
underlay.flags |= UnderlayDisplayFlags::CLIPPING;
self.finish(underlay)
}
"OFF" => {
underlay.flags -= UnderlayDisplayFlags::CLIPPING;
self.finish(underlay)
}
"D" | "DELETE" => {
underlay.clip_boundary_vertices.clear();
underlay.flags -= UnderlayDisplayFlags::CLIPPING;
self.finish(underlay)
}
"" | "N" | "NEW" | "NEW BOUNDARY" => {
if underlay.clip_boundary_vertices.is_empty() {
self.enter_mode()
} else {
self.step = Step::DeleteOld;
CmdResult::NeedPoint
}
}
_ => CmdResult::ReportError("Invalid option keyword.".to_string()),
}
}
fn mode(&mut self, text: &str) -> CmdResult {
match text.trim().to_ascii_uppercase().as_str() {
"" | "R" | "RECTANGULAR" => {
self.step = Step::RectFirst;
CmdResult::NeedPoint
}
"P" | "POLYGONAL" => {
self.points.clear();
self.step = Step::PolyPoints;
CmdResult::NeedPoint
}
"S" | "SELECT" | "SELECT POLYLINE" => {
self.step = Step::Polyline;
CmdResult::NeedPoint
}
"I" | "INVERT" | "INVERT CLIP" => {
self.inverted = !self.inverted;
self.enter_mode()
}
_ => CmdResult::ReportError("Invalid option keyword.".to_string()),
}
}
fn rectangle(a: DVec3, b: DVec3) -> Vec<DVec3> {
vec![
a,
DVec3::new(b.x, a.y, a.z),
b,
DVec3::new(a.x, b.y, a.z),
]
}
}
impl CadCommand for PdfClipCommand {
fn name(&self) -> &'static str {
if self.generic { "CLIP" } else { "PDFCLIP" }
}
fn prompt(&self) -> String {
match self.step {
Step::Select if self.generic => "Select Object to clip:".to_string(),
Step::Select => "Select PDF to clip:".to_string(),
Step::Option => {
"Enter PDF clipping option [ON/OFF/Delete/New boundary] <New boundary>:".to_string()
}
Step::DeleteOld => "Delete old boundary? [Yes/No] <Yes>:".to_string(),
Step::Mode => {
"[Select polyline/Polygonal/Rectangular/Invert clip] <Rectangular>:".to_string()
}
Step::RectFirst => "Specify first corner point:".to_string(),
Step::RectSecond => "Specify opposite corner point:".to_string(),
Step::PolyPoints => match self.points.len() {
0 => "Specify first point:".to_string(),
1 | 2 => "Specify next point or [Undo]:".to_string(),
_ => "Specify next point or [Close/Undo]:".to_string(),
},
Step::Polyline => "Select polyline:".to_string(),
}
}
fn options(&self) -> Vec<CmdOption> {
match self.step {
Step::Option => vec![
CmdOption::new("ON", "ON"),
CmdOption::new("OFF", "OFF"),
CmdOption::new("Delete", "D"),
CmdOption::new("New boundary", "N"),
],
Step::DeleteOld => vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")],
Step::Mode => vec![
CmdOption::new("Select polyline", "S"),
CmdOption::new("Polygonal", "P"),
CmdOption::new("Rectangular", "R"),
CmdOption::new("Invert clip", "I"),
],
Step::PolyPoints if self.points.len() >= 3 => {
vec![CmdOption::new("Close", "C"), CmdOption::new("Undo", "U")]
}
Step::PolyPoints if !self.points.is_empty() => vec![CmdOption::new("Undo", "U")],
_ => Vec::new(),
}
}
fn input_kind(&self) -> InputKind {
match self.step {
Step::Option | Step::DeleteOld | Step::Mode => InputKind::SingleToken,
_ => InputKind::Point,
}
}
fn point_step_accepts_keywords(&self) -> bool {
self.step == Step::PolyPoints
}
fn needs_entity_pick(&self) -> bool {
matches!(self.step, Step::Select | Step::Polyline)
}
fn inject_before_entity_pick(&self) -> bool {
true
}
fn inject_picked_entity(&mut self, entity: EntityType) {
self.picked = Some(entity);
}
fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult {
let picked = self.picked.take();
match self.step {
Step::Select => match picked {
Some(EntityType::Underlay(u)) if u.underlay_type == UnderlayType::Pdf => {
self.handle = handle;
self.underlay = Some(u);
self.step = Step::Option;
CmdResult::NeedPoint
}
_ => CmdResult::ReportError("Object selected was not a PDF underlay.".to_string()),
},
Step::Polyline => match picked {
Some(EntityType::LwPolyline(pl)) if pl.vertices.len() >= 3 => {
let z = pl.elevation;
let world: Vec<DVec3> = pl
.vertices
.iter()
.map(|v| DVec3::new(v.location.x, v.location.y, z))
.collect();
self.apply_boundary(&world)
}
_ => CmdResult::ReportError("Invalid object selected.".to_string()),
},
_ => CmdResult::NeedPoint,
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match self.step {
Step::RectFirst => {
self.points = vec![pt];
self.step = Step::RectSecond;
CmdResult::NeedPoint
}
Step::RectSecond => {
let first = self.points[0];
self.apply_boundary(&Self::rectangle(first, pt))
}
Step::PolyPoints => {
self.points.push(pt);
CmdResult::NeedPoint
}
_ => CmdResult::NeedPoint,
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
Some(match self.step {
Step::Option => self.option(text),
Step::DeleteOld => match text.trim().to_ascii_uppercase().as_str() {
"" | "Y" | "YES" => self.enter_mode(),
"N" | "NO" => match self.underlay.clone() {
Some(underlay) => self.finish(underlay),
None => CmdResult::Cancel,
},
_ => CmdResult::ReportError("Invalid option keyword.".to_string()),
},
Step::Mode => self.mode(text),
Step::PolyPoints => match text.trim().to_ascii_uppercase().as_str() {
"U" | "UNDO" => {
self.points.pop();
CmdResult::NeedPoint
}
"C" | "CLOSE" if self.points.len() >= 3 => {
let points = self.points.clone();
self.apply_boundary(&points)
}
_ => return None,
},
_ => return None,
})
}
fn on_enter(&mut self) -> CmdResult {
match self.step {
Step::Option => self.option(""),
Step::DeleteOld => self.enter_mode(),
Step::Mode => self.mode(""),
Step::PolyPoints if self.points.len() >= 3 => {
let points = self.points.clone();
self.apply_boundary(&points)
}
Step::PolyPoints => CmdResult::NeedPoint,
_ => CmdResult::Cancel,
}
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
let ring: Vec<DVec3> = match self.step {
Step::RectSecond => Self::rectangle(self.points[0], pt),
Step::PolyPoints if !self.points.is_empty() => {
let mut ring = self.points.clone();
ring.push(pt);
ring
}
_ => return None,
};
let mut points: Vec<[f32; 3]> = ring.iter().map(|p| [p.x as f32, p.y as f32, p.z as f32]).collect();
points.push(points[0]);
Some(WireModel::solid("pdf_clip_boundary".into(), points, WireModel::CYAN, false))
}
}
inventory::submit!(crate::command::CommandRegistration {
names: &["PDFCLIP", "CLIP"]
});
File diff suppressed because it is too large. Load diff
+2
View File
@@ -105,6 +105,8 @@ pub fn visualization_section(entity: &EntityType) -> Option<PropSection> {
| EntityType::Seqend(_)
| EntityType::Leader(_)
| EntityType::Wipeout(_)
// The reference lists no 3D group for an underlay.
| EntityType::Underlay(_)
| EntityType::Unknown(_)
// Non-plotting drawing-view border: never rendered, no properties.
| EntityType::ViewBorder(_)
+46 -8
View File
@@ -611,7 +611,7 @@ fn tessellate_entity_inner(
match e {
EntityType::Viewport(_) | EntityType::Insert(_) => {}
_ => {
let ab = entity_aabb(e);
let ab = entity_aabb_in(document, e);
if ab != WireModel::UNBOUNDED_AABB {
if let Some(view) = view_aabb {
if cache::block_cache::aabb_disjoint_xy(ab, view) {
@@ -736,7 +736,7 @@ fn tessellate_entity_inner(
bg_color,
false,
);
let ab = entity_aabb(e);
let ab = entity_aabb_in(document, e);
for w in &mut wires {
set_wire_aabb(w, ab);
}
@@ -990,7 +990,7 @@ fn tessellate_entity_inner(
)
.wires;
if !wires.is_empty() {
let aabb = entity_aabb(e);
let aabb = entity_aabb_in(document, e);
for wire in &mut wires {
if !wire.points.is_empty() || !wire.fill_tris.is_empty() {
set_wire_aabb(wire, aabb);
@@ -1002,7 +1002,7 @@ fn tessellate_entity_inner(
}
if let EntityType::Dimension(dim) = e {
let aabb = entity_aabb(e);
let aabb = entity_aabb_in(document, e);
use crate::entities::dimension::DimensionTess;
let mut wires = dim.tessellate(
document,
@@ -1032,7 +1032,7 @@ fn tessellate_entity_inner(
}
if let EntityType::MultiLeader(ml) = e {
let aabb = entity_aabb(e);
let aabb = entity_aabb_in(document, e);
use crate::entities::multileader::MultiLeaderTess;
let mut wires = ml.tessellate(
document,
@@ -1099,7 +1099,7 @@ fn tessellate_entity_inner(
)
.wires;
if !wires.is_empty() {
let aabb = entity_aabb(e);
let aabb = entity_aabb_in(document, e);
for wire in &mut wires {
if !wire.points.is_empty() || !wire.fill_tris.is_empty() {
set_wire_aabb(wire, aabb);
@@ -1150,7 +1150,7 @@ fn tessellate_entity_inner(
);
}
if !wires.is_empty() {
let aabb = entity_aabb(e);
let aabb = entity_aabb_in(document, e);
for wire in &mut wires {
wire.aci = aci;
if !wire.points.is_empty() || !wire.fill_tris.is_empty() {
@@ -1245,7 +1245,7 @@ fn tessellate_entity_inner(
return wires;
}
let aabb = entity_aabb(e);
let aabb = entity_aabb_in(document, e);
// TEXT / MTEXT / ATTDEF / ATTRIB / Tolerance all render as SDF glyph quads
// (crisp at every zoom), so there is no text LOD ladder — they fall through
@@ -1285,6 +1285,20 @@ fn tessellate_entity_inner(
b.set_fixed_screen_width(2.0);
}
}
// PDF underlay geometry: a hidden, unplotted wire that object snaps
// (nearest, intersection, perpendicular) find, beside the page frame.
if let EntityType::Underlay(underlay) = e {
let geometry = crate::scene::model::pdf_vector::underlay_snap_geometry(underlay, document);
if !geometry.is_empty() {
let (points, points_low) = convert::tessellate::points_to_ds(geometry);
let mut wire = WireModel::solid(h.value().to_string(), points, entity_color, sel);
wire.points_low = points_low;
wire.display_visible = false;
wire.plot_visible = false;
set_wire_aabb(&mut wire, aabb);
bases.push(wire);
}
}
// A hidden mask frame remains selectable and appears while selected, but
// contributes no visible line work during normal display. The interior
@@ -1753,6 +1767,30 @@ pub(crate) fn entity_bounds(e: &acadrust::EntityType) -> ([f64; 3], [f64; 3]) {
)
}
/// [`entity_bounds`] with the document, for entities whose extent is not
/// stored on the entity: an underlay spans its page (or its clip).
pub(crate) fn entity_bounds_in(
document: &acadrust::CadDocument,
e: &acadrust::EntityType,
) -> ([f64; 3], [f64; 3]) {
if let acadrust::EntityType::Underlay(underlay) = e {
if let Some(bounds) = crate::entities::underlay::world_bounds(underlay, document) {
return bounds;
}
}
entity_bounds(e)
}
/// [`entity_aabb`] with the document (see [`entity_bounds_in`]).
pub(crate) fn entity_aabb_in(document: &acadrust::CadDocument, e: &acadrust::EntityType) -> [f32; 4] {
if let acadrust::EntityType::Underlay(underlay) = e {
if let Some((min, max)) = crate::entities::underlay::world_bounds(underlay, document) {
return [min[0] as f32, min[1] as f32, max[0] as f32, max[1] as f32];
}
}
entity_aabb(e)
}
pub(crate) fn entity_aabb(e: &acadrust::EntityType) -> [f32; 4] {
let (min, max) = entity_bounds(e);
let min_x = min[0] as f32;
+28 -2
View File
@@ -152,7 +152,7 @@ impl Scene {
let _ = self.document.layers.add(layer);
}
pub(super) fn ensure_app_id(&mut self, name: &str) {
pub(crate) fn ensure_app_id(&mut self, name: &str) {
if name.trim().is_empty() || self.document.app_ids.contains(name) {
return;
}
@@ -673,6 +673,25 @@ impl Scene {
/// or drop them all if the handle is now absent. Mirrors the reseed block in
/// [`Scene::update_entity`]; used by delta-undo when it re-applies an
/// entity's before / after image so the fills and meshes follow.
/// Rebuild every underlay's raster and wire, after a setting they draw
/// or snap by (PDFOSNAP / UOSNAP) changed.
pub(crate) fn reseed_underlays(&mut self) {
let handles: Vec<Handle> = self
.document
.entities()
.filter(|entity| matches!(entity, EntityType::Underlay(_)))
.map(|entity| entity.common().handle)
.collect();
for handle in &handles {
self.reseed_derived_caches(*handle);
}
let changes: Vec<_> = handles
.iter()
.map(|handle| (*handle, crate::scene::ChangeKind::Modified))
.collect();
self.bump_entities(&changes);
}
pub(crate) fn reseed_derived_caches(&mut self, handle: Handle) {
let (hatch_seed, image_seed) = match self.document.get_entity(handle) {
None => (None, None),
@@ -2389,7 +2408,14 @@ impl Scene {
EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole),
EntityType::Underlay(u) => match self.document.objects.get(&u.definition_handle) {
Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => {
ImageModel::from_underlay(u, def)
// A paper-space underlay adjusts to the sheet, not the canvas.
let owner = u.common.owner_handle;
let background = if owner.is_null() || owner == self.model_space_block_handle() {
self.bg_color
} else {
self.paper_bg_color
};
ImageModel::from_underlay(u, def, background)
}
_ => None,
},
+35 -1
View File
@@ -1268,7 +1268,20 @@ fn build_derived_caches_impl(
EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole).map(|m| (handle, m)),
EntityType::Underlay(u) => match doc.objects.get(&u.definition_handle) {
Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => {
ImageModel::from_underlay(u, def).map(|m| (handle, m))
{
// Paper-space underlays adjust to the white sheet.
let model = doc.objects.values().find_map(|object| match object {
acadrust::objects::ObjectType::Layout(l) if l.name == "Model" => Some(l.block_record),
_ => None,
});
let owner = u.common.owner_handle;
let background = if owner.is_null() || model.is_none_or(|m| m == owner) {
LOAD_BG
} else {
[1.0, 1.0, 1.0, 1.0]
};
ImageModel::from_underlay(u, def, background).map(|m| (handle, m))
}
}
_ => None,
},
@@ -3396,6 +3409,27 @@ impl Scene {
self.refresh_dynamic_dimension_texts();
self.sync_native_parametric_graph();
}
// A modified image-bearing entity redraws its raster: an underlay's
// display adjustments and clip live on the entity, not the image.
for (handle, kind) in &changes {
if !matches!(kind, ChangeKind::Modified) {
continue;
}
let seed = match self.document.get_entity(*handle) {
Some(
entity @ (EntityType::RasterImage(_)
| EntityType::Ole2Frame(_)
| EntityType::Underlay(_)),
) => Some(self.image_seed_for(entity)),
_ => None,
};
if let Some(seed) = seed {
self.images.remove(handle);
if let Some(model) = seed {
self.images.insert(*handle, model);
}
}
}
if !changes.is_empty() {
self.refresh_dependency_index_for_changes(&changes);
}
+57 -41
View File
@@ -186,20 +186,26 @@ impl ImageModel {
impl ImageModel {
/// Build an ImageModel from a PDF UNDERLAY + its definition object.
///
/// The page rasterises through `pdf_raster` (system pdftoppm, cached);
/// the world quad sizes from the page's physical size — 1 underlay unit =
/// 1 PDF inch — times the entity's scale, placed at the insertion with
/// the entity's rotation. `None` when the underlay is off, non-PDF, or
/// the page can't be rendered (caller keeps the outline placeholder).
/// The page rasterises through `pdf_raster` with no page background, so
/// only the drawn content covers the drawing; contrast, monochrome and
/// the background adjustment recolour that raster and fade lowers its
/// opacity. The world quad is the page size in inches (1 drawing unit per
/// page inch) times the entity scale, placed at the insertion with the
/// entity rotation. A clip boundary (on, 2+ vertices, in underlay units)
/// limits the drawn area to its inside, or to the page outside it when
/// inverted. `None` when the underlay is off, non-PDF, or the page can't
/// be rendered (caller keeps the outline placeholder).
pub fn from_underlay(
u: &acadrust::entities::Underlay,
def: &acadrust::entities::UnderlayDefinition,
background: [f32; 4],
) -> Option<Self> {
use acadrust::entities::{UnderlayDisplayFlags, UnderlayType};
use super::pdf_raster::{self, PageAdjust};
if !u.flags.contains(UnderlayDisplayFlags::ON) {
return None;
}
if !matches!(def.underlay_type, UnderlayType::Pdf) {
if !matches!(def.underlay_type, UnderlayType::Pdf) || def.unloaded {
return None;
}
let page = if def.page_name.trim().is_empty() {
@@ -207,12 +213,27 @@ impl ImageModel {
} else {
def.page_name.trim()
};
let raster = super::pdf_raster::rasterize_page(&def.file_path, page)?;
let (page_w, page_h) = pdf_raster::page_size_inches(&def.file_path, page)?;
// Hidden PDF layers come from the underlay's layer overrides.
let source = super::pdf_layers::underlay_source(u, &def.file_path);
let raster = pdf_raster::rasterize_page_display(&source, page)?;
let bg_lum = 0.299 * background[0] + 0.587 * background[1] + 0.114 * background[2];
let pixels = pdf_raster::adjusted_pixels(
&source,
page,
&raster,
PageAdjust {
contrast: u.contrast.min(100),
monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME),
adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND),
dark_background: bg_lum <= 0.5,
},
);
// Page size in drawing units (1 unit per PDF inch), entity scale applied.
let w_du = raster.width as f64 / raster.dpi as f64 * u.x_scale;
let h_du = raster.height as f64 / raster.dpi as f64 * u.y_scale;
if w_du <= 0.0 || h_du <= 0.0 {
let w_du = page_w * u.x_scale;
let h_du = page_h * u.y_scale;
if w_du.abs() <= 0.0 || h_du.abs() <= 0.0 {
return None;
}
let (c, s) = (u.rotation.cos(), u.rotation.sin());
@@ -238,35 +259,7 @@ impl ImageModel {
];
let corners_low = [[oxl, oyl, ozl]; 4];
// Visible region: the clip polygon (vertices in underlay units,
// scaled like the quad) fan-triangulated, or the full page. Inverted
// clips fall back to the full page (rare; better whole than hidden).
let clipping = u.flags.contains(UnderlayDisplayFlags::CLIPPING)
&& !u.clip_inverted
&& u.clip_boundary_vertices.len() >= 3;
let tris_uv: Vec<[f64; 2]> = if clipping {
let pts: Vec<[f64; 2]> = u
.clip_boundary_vertices
.iter()
.map(|p| [p.x * u.x_scale / w_du, p.y * u.y_scale / h_du])
.collect();
let mut tris = Vec::with_capacity((pts.len().saturating_sub(2)) * 3);
for i in 1..pts.len() - 1 {
tris.push(pts[0]);
tris.push(pts[i]);
tris.push(pts[i + 1]);
}
tris
} else {
vec![
[0.0, 0.0],
[1.0, 0.0],
[1.0, 1.0],
[0.0, 0.0],
[1.0, 1.0],
[0.0, 1.0],
]
};
let tris_uv = underlay_visible_uv(u, page_w, page_h);
let verts: Vec<ImageQuadVertex> = tris_uv
.iter()
.map(|&[fu, fv]| {
@@ -282,10 +275,10 @@ impl ImageModel {
Some(Self {
render_instance: None,
file_path: def.file_path.clone(),
pixels: raster.pixels.clone(),
pixels,
width: raster.width,
height: raster.height,
opacity: 1.0 - u.fade as f32 / 100.0,
opacity: 1.0 - u.fade.min(100) as f32 / 100.0,
corners,
corners_low,
draw_depth: 0.0,
@@ -294,6 +287,29 @@ impl ImageModel {
}
}
/// Visible region of an underlay page as triangles in page UV (0..1, y up):
/// the whole page, the clip polygon, or — for an inverted clip — the page
/// with the polygon cut out.
fn underlay_visible_uv(u: &acadrust::entities::Underlay, page_w: f64, page_h: f64) -> Vec<[f64; 2]> {
use acadrust::entities::UnderlayDisplayFlags;
let page = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
let whole = || vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
let clip = crate::entities::underlay::clip_polygon_local(u);
if !u.flags.contains(UnderlayDisplayFlags::CLIPPING) || clip.len() < 3 {
return whole();
}
let ring: Vec<[f64; 2]> = clip.iter().map(|p| [p[0] / page_w, p[1] / page_h]).collect();
let (points, triangles) = if u.clip_inverted {
cadkernel::geom2d::triangulate(&page, &[ring])
} else {
cadkernel::geom2d::triangulate(&ring, &[])
};
triangles
.into_iter()
.flat_map(|t| t.map(|i| points[i]))
.collect()
}
impl ImageModel {
/// Build an ImageModel from an OLE2FRAME's embedded presentation.
/// The blob's compound file is parsed for the native raster or the cached
+2
View File
@@ -6,7 +6,9 @@ pub mod image_model;
pub mod instance_model;
pub mod material_model;
pub mod ole_pres;
pub mod pdf_layers;
pub mod pdf_raster;
pub mod pdf_vector;
pub mod mesh_model;
pub mod solid_model;
pub mod solid_history;
+255
View File
@@ -0,0 +1,255 @@
//! PDF layers (optional-content groups): what a file declares, and a source
//! whose layer visibility is overridden for an underlay.
//!
//! The override is an incremental update that replaces the catalog's default
//! optional-content configuration, so the rasteriser and the vector reader
//! both skip the hidden layers without any change to how they read a page.
use std::sync::Arc;
use hayro::hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier};
use hayro::hayro_syntax::Pdf;
use super::pdf_raster::{register_derived_source, source_bytes};
/// An optional-content group (layer) of a PDF.
#[derive(Clone, Debug, PartialEq)]
pub struct PdfLayer {
pub name: String,
/// Shown by the file's default configuration.
pub on_by_default: bool,
object: (i32, i32),
}
fn text_string(bytes: &[u8]) -> String {
if bytes.starts_with(&[0xFE, 0xFF]) {
let units: Vec<u16> = bytes[2..]
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
return String::from_utf16_lossy(&units);
}
bytes.iter().map(|&b| b as char).collect()
}
/// The layers a PDF declares, in file order.
pub fn layers(path: &str) -> Vec<PdfLayer> {
let Some(bytes) = source_bytes(path) else {
return Vec::new();
};
let Ok(pdf) = Pdf::new(bytes) else {
return Vec::new();
};
let xref = pdf.xref();
let Some(catalog) = xref.get::<Dict<'_>>(xref.root_id()) else {
return Vec::new();
};
let Some(props) = catalog.get::<Dict<'_>>(b"OCProperties") else {
return Vec::new();
};
let config = props.get::<Dict<'_>>(b"D").unwrap_or_default();
let ids = |key: &[u8]| -> Vec<(i32, i32)> {
config
.get::<Array<'_>>(key)
.map(|a| {
a.raw_iter()
.filter_map(|item| item.as_obj_ref())
.map(|r| (r.obj_number, r.gen_number))
.collect()
})
.unwrap_or_default()
};
let base_off = config
.get::<Name<'_>>(b"BaseState")
.is_some_and(|n| n.as_ref() == b"OFF");
let (on, off) = (ids(b"ON"), ids(b"OFF"));
let Some(ocgs) = props.get::<Array<'_>>(b"OCGs") else {
return Vec::new();
};
ocgs.raw_iter()
.filter_map(|item| item.as_obj_ref())
.filter_map(|r| {
let id = (r.obj_number, r.gen_number);
let dict = xref.get::<Dict<'_>>(ObjectIdentifier::from(r))?;
let name = dict
.get::<hayro::hayro_syntax::object::String<'_>>(b"Name")
.map(|s| text_string(s.as_bytes()))
.unwrap_or_default();
let on_by_default = if base_off {
on.contains(&id)
} else {
!off.contains(&id)
};
Some(PdfLayer {
name,
on_by_default,
object: id,
})
})
.collect()
}
/// A source whose `hidden` layers are off and every other layer on: the
/// original path when that is the file's own state, else a derived source
/// name registered with the updated bytes. Falls back to the original path
/// when the file cannot be updated (encrypted, unreadable catalog).
pub fn source_with_layers(path: &str, hidden: &[String]) -> String {
let all = layers(path);
if all.is_empty() {
return path.to_string();
}
let off: Vec<&PdfLayer> = all
.iter()
.filter(|l| hidden.iter().any(|h| *h == l.name))
.collect();
if off.is_empty() && all.iter().all(|l| l.on_by_default) {
return path.to_string();
}
let mut names: Vec<&str> = off.iter().map(|l| l.name.as_str()).collect();
names.sort_unstable();
let key = format!("{path}#layers-off={}", names.join("|"));
if source_bytes(&key).is_some() {
return key;
}
let Some(updated) = source_bytes(path).and_then(|bytes| with_layer_config(&bytes, &all, &off))
else {
return path.to_string();
};
register_derived_source(&key, Arc::new(updated));
key
}
/// The bytes with an incremental update whose catalog lists `off` layers
/// as off in the default configuration and all others on.
fn with_layer_config(bytes: &[u8], all: &[PdfLayer], off: &[&PdfLayer]) -> Option<Vec<u8>> {
let pdf = Pdf::new(Arc::new(bytes.to_vec())).ok()?;
let xref = pdf.xref();
let root = xref.root_id();
let catalog = xref.get::<Dict<'_>>(root)?;
let raw = std::str::from_utf8(catalog.data()).ok()?.trim().to_string();
let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(4096)..]).into_owned();
if tail.contains("/Encrypt") {
return None;
}
let prev: usize = tail
.rfind("startxref")
.and_then(|i| tail[i + 9..].split_whitespace().next()?.parse().ok())?;
let size: usize = {
let region = String::from_utf8_lossy(&bytes[prev.min(bytes.len())..]).into_owned();
let region = if region.contains("/Size") { region } else { tail.clone() };
let i = region.find("/Size")?;
region[i + 5..]
.trim_start()
.split(|c: char| !c.is_ascii_digit())
.next()?
.parse()
.ok()?
};
let body = without_key(&raw, "/OCProperties")?;
let refs = |layers: Vec<&PdfLayer>| {
layers
.iter()
.map(|l| format!("{} {} R", l.object.0, l.object.1))
.collect::<Vec<_>>()
.join(" ")
};
let props = format!(
"/OCProperties << /OCGs [{}] /D << /BaseState /ON /OFF [{}] >> >>",
refs(all.iter().collect()),
refs(off.to_vec()),
);
let open = body.trim_end().strip_suffix(">>")?.trim_end();
let catalog_text = format!("{open} {props} >>");
let mut out = bytes.to_vec();
if !out.ends_with(b"\n") {
out.push(b'\n');
}
let object_offset = out.len();
out.extend_from_slice(
format!(
"{} {} obj\n{catalog_text}\nendobj\n",
root.obj_number, root.gen_number
)
.as_bytes(),
);
let xref_offset = out.len();
out.extend_from_slice(
format!(
"xref\n0 1\n0000000000 65535 f \n{} 1\n{:010} {:05} n \ntrailer\n<< /Size {size} /Root {} {} R /Prev {prev} >>\nstartxref\n{xref_offset}\n%%EOF\n",
root.obj_number, object_offset, root.gen_number, root.obj_number, root.gen_number,
)
.as_bytes(),
);
Some(out)
}
/// A dictionary's text without one key and its value (a dictionary, an
/// array, a reference or a single token).
fn without_key(dict: &str, key: &str) -> Option<String> {
let Some(start) = dict.find(key) else {
return Some(dict.to_string());
};
let rest = &dict[start + key.len()..];
let value = rest.trim_start();
let lead = rest.len() - value.len();
let length = if value.starts_with("<<") || value.starts_with('[') {
let (open, close) = if value.starts_with('[') { ("[", "]") } else { ("<<", ">>") };
let mut depth = 0i32;
let mut i = 0;
loop {
if i >= value.len() {
return None;
}
if value[i..].starts_with(open) {
depth += 1;
i += open.len();
} else if value[i..].starts_with(close) {
depth -= 1;
i += close.len();
if depth == 0 {
break i;
}
} else {
i += value[i..].chars().next()?.len_utf8();
}
}
} else {
let parts: Vec<&str> = value.split_whitespace().take(3).collect();
if parts.len() == 3 && parts[2].starts_with('R') {
value.find('R')? + 1
} else {
value
.find(|c: char| c.is_whitespace() || c == '/' || c == '>')
.unwrap_or(value.len())
}
};
Some(format!("{}{}", &dict[..start], &rest[lead + length..]))
}
/// Layer names an underlay has switched off: the strings of its
/// `AdeskUnderlayLayerOverrideData` extended data, as the reference stores
/// them.
pub const LAYER_OVERRIDE_APP: &str = "AdeskUnderlayLayerOverrideData";
pub fn hidden_layers(u: &acadrust::entities::Underlay) -> Vec<String> {
u.common
.extended_data
.records()
.iter()
.filter(|r| r.application_name.eq_ignore_ascii_case(LAYER_OVERRIDE_APP))
.flat_map(|r| r.values.iter())
.filter_map(|v| match v {
acadrust::xdata::XDataValue::String(s) => Some(s.clone()),
_ => None,
})
.collect()
}
/// The source an underlay's page is read from, with its layer overrides.
pub fn underlay_source(u: &acadrust::entities::Underlay, file_path: &str) -> String {
let hidden = hidden_layers(u);
if hidden.is_empty() {
return file_path.to_string();
}
source_with_layers(file_path, &hidden)
}
+183 -10
View File
@@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock};
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::Pdf;
use hayro::vello_cpu::color::palette::css::WHITE;
use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE};
use hayro::{RenderCache, RenderSettings};
/// One rasterised PDF page. `dpi` ties its pixel size to its physical size.
@@ -18,7 +18,8 @@ pub struct PdfPage {
const RASTER_DPI: f32 = 150.0;
type PageKey = (String, String, u32);
/// Source path, page, DPI bits and whether the page background is transparent.
type PageKey = (String, String, u32, bool);
fn page_cache() -> &'static Mutex<HashMap<PageKey, Option<Arc<PdfPage>>>> {
static CACHE: OnceLock<Mutex<HashMap<PageKey, Option<Arc<PdfPage>>>>> = OnceLock::new();
@@ -40,7 +41,20 @@ pub fn register_source(path: &str, bytes: Arc<Vec<u8>>) {
page_cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
.retain(|(cached_path, _, _), _| cached_path != path);
.retain(|(cached_path, _, _, _), _| cached_path != path);
adjusted_cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
.retain(|key, _| key.0 != path);
}
/// Register bytes under a derived source name (a layer override of a
/// file), leaving the file's own caches alone.
pub(crate) fn register_derived_source(key: &str, bytes: Arc<Vec<u8>>) {
source_cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
.insert(key.to_string(), bytes);
}
/// Rasterise a 1-based PDF page, memoised by source path and page name.
@@ -51,7 +65,18 @@ pub fn rasterize_page(path: &str, page: &str) -> Option<Arc<PdfPage>> {
/// Rasterise a 1-based PDF page at a caller-chosen DPI, memoised per source
/// path, page and DPI (print quality differs from the on-screen 150 DPI).
pub fn rasterize_page_at_dpi(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>> {
let key = (path.to_string(), page.to_string(), dpi.to_bits());
rasterize_cached(path, page, dpi, false)
}
/// Rasterise a page for display: no page background (transparent pixels
/// outside the drawn content) and straight, not premultiplied, alpha — the
/// page is drawn over the drawing background like its vector content.
pub fn rasterize_page_display(path: &str, page: &str) -> Option<Arc<PdfPage>> {
rasterize_cached(path, page, RASTER_DPI, true)
}
fn rasterize_cached(path: &str, page: &str, dpi: f32, transparent: bool) -> Option<Arc<PdfPage>> {
let key = (path.to_string(), page.to_string(), dpi.to_bits(), transparent);
if let Some(hit) = page_cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
@@ -61,7 +86,7 @@ pub fn rasterize_page_at_dpi(path: &str, page: &str, dpi: f32) -> Option<Arc<Pdf
return hit;
}
let built = rasterize_uncached(path, page, dpi);
let built = rasterize_uncached(path, page, dpi, transparent);
page_cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
@@ -73,10 +98,10 @@ pub fn rasterize_page_at_dpi(path: &str, page: &str, dpi: f32) -> Option<Arc<Pdf
/// print job, whose 300 DPI page would otherwise stay cached for the whole
/// session (tens of megabytes per sheet).
pub fn rasterize_page_at_dpi_uncached(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>> {
rasterize_uncached(path, page, dpi)
rasterize_uncached(path, page, dpi, false)
}
fn rasterize_uncached(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>> {
fn rasterize_uncached(path: &str, page: &str, dpi: f32, transparent: bool) -> Option<Arc<PdfPage>> {
let bytes = source_bytes(path)?;
let pdf = Pdf::new(bytes).ok()?;
let page_no = page.trim().parse::<usize>().unwrap_or(1).max(1);
@@ -89,21 +114,169 @@ fn rasterize_uncached(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>>
&RenderSettings {
x_scale: scale,
y_scale: scale,
bg_color: WHITE,
bg_color: if transparent { TRANSPARENT } else { WHITE },
..Default::default()
},
);
let width = u32::from(pixmap.width());
let height = u32::from(pixmap.height());
let mut pixels = pixmap.data_as_u8_slice().to_vec();
if transparent {
unpremultiply(&mut pixels);
}
Some(Arc::new(PdfPage {
pixels: Arc::new(pixmap.data_as_u8_slice().to_vec()),
pixels: Arc::new(pixels),
width,
height,
dpi,
}))
}
fn source_bytes(path: &str) -> Option<Arc<Vec<u8>>> {
fn unpremultiply(pixels: &mut [u8]) {
for px in pixels.chunks_exact_mut(4) {
let a = px[3] as u32;
if a != 0 && a != 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
}
fn rgb_to_hsl([r, g, b]: [f32; 3]) -> (f32, f32, f32) {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let d = max - min;
if d <= f32::EPSILON {
return (0.0, 0.0, l);
}
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let h = if max == r {
((g - b) / d).rem_euclid(6.0)
} else if max == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
} / 6.0;
(h, s, l)
}
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] {
if s <= f32::EPSILON {
return [l; 3];
}
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
let p = 2.0 * l - q;
let channel = |t: f32| {
let t = t.rem_euclid(1.0);
if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 0.5 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
}
};
[channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0)]
}
/// Number of pages in a PDF source.
pub fn page_count(path: &str) -> Option<usize> {
let bytes = source_bytes(path)?;
let pdf = Pdf::new(bytes).ok()?;
Some(pdf.pages().len())
}
/// Size of a 1-based page in inches (its rendered width and height at 72
/// points per inch). An underlay is one drawing unit per page inch.
pub fn page_size_inches(path: &str, page: &str) -> Option<(f64, f64)> {
let bytes = source_bytes(path)?;
let pdf = Pdf::new(bytes).ok()?;
let page_no = page.trim().parse::<usize>().ok()?;
let page = pdf.pages().get(page_no.checked_sub(1)?)?;
let (w, h) = page.render_dimensions();
Some((w as f64 / 72.0, h as f64 / 72.0))
}
/// Display adjustments of an underlay page, applied to its display raster.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct PageAdjust {
/// 0..=100; 100 keeps the colours, lower values pull their lightness
/// towards one third.
pub contrast: u8,
pub monochrome: bool,
/// Lighten dark, unsaturated content on a dark background (black lines
/// and text become white), keeping coloured content.
pub adjust_for_background: bool,
pub dark_background: bool,
}
type AdjustKey = (String, String, PageAdjust);
fn adjusted_cache() -> &'static Mutex<HashMap<AdjustKey, Arc<Vec<u8>>>> {
static CACHE: OnceLock<Mutex<HashMap<AdjustKey, Arc<Vec<u8>>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// The display raster with the underlay's contrast / monochrome / background
/// adjustments applied, memoised per source, page and adjustment.
pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdjust) -> Arc<Vec<u8>> {
let identity = adjust.contrast >= 100 && !adjust.monochrome && !adjust.adjust_for_background;
if identity {
return raster.pixels.clone();
}
let key = (path.to_string(), page.to_string(), adjust);
if let Some(hit) = adjusted_cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
.get(&key)
.cloned()
{
return hit;
}
let mut pixels = raster.pixels.as_ref().clone();
for px in pixels.chunks_exact_mut(4) {
if px[3] == 0 {
continue;
}
let mut rgb = [px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0];
if adjust.monochrome {
let l = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
rgb = [l; 3];
}
if adjust.adjust_for_background && adjust.dark_background {
// On a dark background every colour's lightness is turned over
// (plus 20/255, capped at full), hue and saturation kept: black
// becomes white, dark red light pink. On a light background the
// colours stay as drawn.
// ponytail: offset and the light-background rule measured on the
// default backgrounds only (pure white on paper not measured).
let (h, s, l) = rgb_to_hsl(rgb);
rgb = hsl_to_rgb(h, s, (1.0 - l + 20.0 / 255.0).min(1.0));
}
if adjust.contrast < 100 {
// Lightness is pulled towards one third, hue and saturation kept:
// contrast 0 leaves every colour at a third of full lightness.
let (h, s, l) = rgb_to_hsl(rgb);
let k = adjust.contrast as f32 / 100.0;
rgb = hsl_to_rgb(h, s, 1.0 / 3.0 + (l - 1.0 / 3.0) * k);
}
for (dst, c) in px[..3].iter_mut().zip(rgb) {
*dst = (c.clamp(0.0, 1.0) * 255.0).round() as u8;
}
}
let pixels = Arc::new(pixels);
adjusted_cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
.insert(key, pixels.clone());
pixels
}
pub(crate) fn source_bytes(path: &str) -> Option<Arc<Vec<u8>>> {
if let Some(bytes) = source_cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
+648
View File
@@ -0,0 +1,648 @@
//! Vector content of a PDF page — paths and text runs in page inches (y up,
//! origin at the page's lower-left corner) — read through the PDF
//! interpreter. Drives snapping to an underlay's geometry and PDFIMPORT.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use hayro::hayro_interpret::font::Glyph;
use hayro::hayro_interpret::util::TransformExt;
use hayro::hayro_interpret::{
interpret_page, BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache,
InterpreterSettings, Paint, PathDrawMode, SoftMask,
};
use hayro::hayro_syntax::content::ops::TypedInstruction;
use hayro::hayro_syntax::object::Name;
use hayro::hayro_syntax::Pdf;
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape};
/// One segment of a path, in page inches.
#[derive(Clone, Debug, PartialEq)]
pub enum Segment {
Line([f64; 2], [f64; 2]),
Cubic([f64; 2], [f64; 2], [f64; 2], [f64; 2]),
}
impl Segment {
pub fn start(&self) -> [f64; 2] {
match self {
Segment::Line(a, _) | Segment::Cubic(a, ..) => *a,
}
}
pub fn end(&self) -> [f64; 2] {
match self {
Segment::Line(_, b) | Segment::Cubic(_, _, _, b) => *b,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SubPath {
pub segments: Vec<Segment>,
pub closed: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PdfPath {
pub subpaths: Vec<SubPath>,
/// Stroke colour (RGB) and width in points, for stroked paths.
pub stroke: Option<([u8; 3], f64)>,
/// Fill colour (RGB), for filled paths.
pub fill: Option<[u8; 3]>,
}
/// A run of glyphs on one baseline in one font and size.
#[derive(Clone, Debug, PartialEq)]
pub struct PdfText {
pub text: String,
/// Base font name without a subset prefix ("Helvetica").
pub font: String,
/// Baseline start, page inches.
pub origin: [f64; 2],
/// Height of the capital letters, inches.
pub cap_height: f64,
/// Advance width of the run, inches.
pub width: f64,
/// Baseline direction, radians.
pub rotation: f64,
pub color: [u8; 3],
}
/// A raster image drawn on a page: its pixels and where they land.
#[derive(Clone, Debug, PartialEq)]
pub struct PdfImage {
/// Straight RGBA, rows from the top.
pub rgba: Vec<u8>,
pub width: u32,
pub height: u32,
/// Lower-left corner, page inches.
pub origin: [f64; 2],
/// One pixel along the image's rows and up its columns, inches.
pub u: [f64; 2],
pub v: [f64; 2],
}
#[derive(Default, Debug)]
pub struct PageVectors {
pub paths: Vec<PdfPath>,
pub texts: Vec<PdfText>,
/// Filled only when images are asked for (`page_content`).
pub images: Vec<PdfImage>,
}
type Key = (String, String);
fn cache() -> &'static Mutex<HashMap<Key, Option<Arc<PageVectors>>>> {
static CACHE: OnceLock<Mutex<HashMap<Key, Option<Arc<PageVectors>>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Paths and text of a 1-based page, memoised per source and page.
pub fn page_vectors(path: &str, page: &str) -> Option<Arc<PageVectors>> {
let key = (path.to_string(), page.to_string());
if let Some(hit) = cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
.get(&key)
.cloned()
{
return hit;
}
let built = read_page(path, page).map(Arc::new);
cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
.insert(key, built.clone());
built
}
/// A page's paths, text and raster images (not cached; import only).
pub fn page_content(path: &str, page: &str) -> Option<PageVectors> {
read_page_with(path, page, true)
}
/// A page's content split by PDF layer: `None` for what no layer holds,
/// then each named layer that is not in `hidden`. Each layer's share is
/// what the page shows with that layer alone on, less what it shows with
/// every layer off.
// ponytail: content in several layers at once (membership dictionaries) is
// credited to the first layer that shows it.
pub fn page_content_by_layer(
path: &str,
page: &str,
hidden: &[String],
) -> Option<Vec<(Option<String>, PageVectors)>> {
let layers = super::pdf_layers::layers(path);
if layers.is_empty() {
return Some(vec![(None, page_content(path, page)?)]);
}
let names: Vec<String> = layers.iter().map(|l| l.name.clone()).collect();
let base = page_content(&super::pdf_layers::source_with_layers(path, &names), page)?;
let key = |s: &dyn std::fmt::Debug| format!("{s:?}");
let mut seen: HashMap<String, usize> = HashMap::new();
for item in base.paths.iter().map(|p| key(p)).chain(base.texts.iter().map(|t| key(t))) {
*seen.entry(item).or_default() += 1;
}
let mut out = Vec::new();
for layer in &layers {
if hidden.contains(&layer.name) {
continue;
}
let others: Vec<String> = names.iter().filter(|n| **n != layer.name).cloned().collect();
let Some(mut only) =
page_content(&super::pdf_layers::source_with_layers(path, &others), page)
else {
continue;
};
let mut left = seen.clone();
let mut fresh = |item: String| match left.get_mut(&item) {
Some(n) if *n > 0 => {
*n -= 1;
false
}
_ => true,
};
only.paths.retain(|p| fresh(key(p)));
only.texts.retain(|t| fresh(key(t)));
only.images.retain(|i| !base.images.contains(i));
out.push((Some(layer.name.clone()), only));
}
out.insert(0, (None, base));
Some(out)
}
fn read_page(path: &str, page: &str) -> Option<PageVectors> {
read_page_with(path, page, false)
}
fn read_page_with(path: &str, page: &str, images: bool) -> Option<PageVectors> {
let bytes = super::pdf_raster::source_bytes(path)?;
let pdf = Pdf::new(bytes).ok()?;
let page_no = page.trim().parse::<usize>().ok()?;
let page = pdf.pages().get(page_no.checked_sub(1)?)?;
let (w, h) = page.render_dimensions();
// Page points with y up → inches.
let initial = Affine::scale(1.0 / 72.0) * page.initial_transform(false).to_kurbo();
let cache = InterpreterCache::new();
let mut context = Context::new(
initial,
Rect::new(0.0, 0.0, w as f64, h as f64),
&cache,
page.xref(),
InterpreterSettings::default(),
);
let mut device = Collector {
want_images: images,
..Collector::default()
};
interpret_page(page, &mut context, &mut device);
device.finish_text();
// Base font names in the order the content stream selects them; glyph
// fonts are matched to them in their order of first use.
let mut names: Vec<String> = Vec::new();
let mut ops = page.typed_operations();
while let Some(op) = ops.next() {
if let TypedInstruction::TextFont(font) = op {
let name = page
.resources()
.get_font(font.0)
.and_then(|dict| dict.get::<Name>(b"BaseFont"))
.map(|name| String::from_utf8_lossy(&name).into_owned())
.unwrap_or_default();
let name = name.split_once('+').map(|(_, rest)| rest.to_string()).unwrap_or(name);
if !names.contains(&name) {
names.push(name);
}
}
}
let mut texts = device.texts;
for text in &mut texts {
let index = device.font_order.iter().position(|key| *key == text.font_key);
text.text.font = index
.and_then(|i| names.get(i).cloned())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "Text".to_string());
}
Some(PageVectors {
paths: device.paths,
texts: texts.into_iter().map(|t| t.text).collect(),
images: device.images,
})
}
struct PendingText {
text: PdfText,
font_key: u128,
/// Baseline end, inches, for joining the next glyph.
end: [f64; 2],
em: f64,
/// Tops of the capital letters seen so far, inches.
caps: Vec<f64>,
}
#[derive(Default)]
struct Collector {
paths: Vec<PdfPath>,
texts: Vec<PendingText>,
current: Option<PendingText>,
font_order: Vec<u128>,
want_images: bool,
images: Vec<PdfImage>,
}
impl Collector {
fn finish_text(&mut self) {
if let Some(text) = self.current.take() {
if !text.text.text.trim().is_empty() {
self.texts.push(text);
}
}
}
}
fn rgb(paint: &Paint<'_>) -> [u8; 3] {
match paint {
Paint::Color(color) => {
let c = color.to_rgba().to_rgba8();
[c[0], c[1], c[2]]
}
Paint::Pattern(_) => [0, 0, 0],
}
}
fn pt(p: Point) -> [f64; 2] {
[p.x, p.y]
}
fn subpaths(path: &BezPath, transform: Affine) -> Vec<SubPath> {
let mut out: Vec<SubPath> = Vec::new();
let mut start = Point::ZERO;
let mut last = Point::ZERO;
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => {
let p = transform * p;
out.push(SubPath { segments: Vec::new(), closed: false });
start = p;
last = p;
}
PathEl::LineTo(p) => {
let p = transform * p;
if let Some(sp) = out.last_mut() {
sp.segments.push(Segment::Line(pt(last), pt(p)));
}
last = p;
}
PathEl::QuadTo(c, p) => {
let (c, p) = (transform * c, transform * p);
let c1 = last + (c - last) * (2.0 / 3.0);
let c2 = p + (c - p) * (2.0 / 3.0);
if let Some(sp) = out.last_mut() {
sp.segments.push(Segment::Cubic(pt(last), pt(c1), pt(c2), pt(p)));
}
last = p;
}
PathEl::CurveTo(c1, c2, p) => {
let (c1, c2, p) = (transform * c1, transform * c2, transform * p);
if let Some(sp) = out.last_mut() {
sp.segments.push(Segment::Cubic(pt(last), pt(c1), pt(c2), pt(p)));
}
last = p;
}
PathEl::ClosePath => {
if let Some(sp) = out.last_mut() {
if (last - start).hypot() > 1e-9 {
sp.segments.push(Segment::Line(pt(last), pt(start)));
}
sp.closed = true;
}
last = start;
}
}
}
out.retain(|sp| !sp.segments.is_empty());
// A path drawn back to its start without a close operator is closed.
for sp in &mut out {
let (first, last) = (sp.segments[0].start(), sp.segments[sp.segments.len() - 1].end());
if !sp.closed && (first[0] - last[0]).hypot(first[1] - last[1]) < 1e-9 {
sp.closed = true;
}
}
out
}
impl<'a> Device<'a> for Collector {
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &Paint<'a>, mode: &PathDrawMode) {
self.finish_text();
let subpaths = subpaths(path, transform);
if subpaths.is_empty() {
return;
}
let (stroke, fill) = match mode {
PathDrawMode::Stroke(props) => {
// Width in points: the user-space width through the transform
// (which is in inches), back to points.
let scale = transform.determinant().abs().sqrt() * 72.0;
(Some((rgb(paint), props.line_width as f64 * scale)), None)
}
PathDrawMode::Fill(_) => (None, Some(rgb(paint))),
};
self.paths.push(PdfPath { subpaths, stroke, fill });
}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
fn draw_glyph(
&mut self,
glyph: &Glyph<'a>,
transform: Affine,
glyph_transform: Affine,
paint: &Paint<'a>,
_: &GlyphDrawMode,
) {
let Glyph::Outline(outline) = glyph else {
return;
};
let full = transform * glyph_transform;
let origin = full * Point::ZERO;
// Outlines are in 1000 units per em.
let em_vec = full * Point::new(0.0, 1000.0) - origin;
let em = em_vec.hypot();
if em <= 0.0 {
return;
}
let advance = outline.advance_width().unwrap_or(0.0) as f64;
let adv_vec = full * Point::new(advance, 0.0) - origin;
let run_dir = full * Point::new(1000.0, 0.0) - origin;
let rotation = run_dir.y.atan2(run_dir.x);
let text = outline
.as_unicode()
.map(|s| match s {
hayro::hayro_interpret::hayro_cmap::BfString::Char(c) => c.to_string(),
hayro::hayro_interpret::hayro_cmap::BfString::String(s) => s,
})
.unwrap_or_default();
let key = outline.font_cache_key();
if !self.font_order.contains(&key) {
self.font_order.push(key);
}
let bounds = outline.outline().bounding_box();
let glyph_top = text
.chars()
.any(|c| c.is_uppercase())
.then(|| bounds.y1 / 1000.0 * em);
let origin = pt(origin);
let joins = self.current.as_ref().is_some_and(|run| {
run.font_key == key
&& (run.em - em).abs() < 1e-6
&& (run.text.rotation - rotation).abs() < 1e-6
&& ((run.end[0] - origin[0]).powi(2) + (run.end[1] - origin[1]).powi(2)).sqrt()
< 0.3 * em
});
if !joins {
self.finish_text();
self.current = Some(PendingText {
text: PdfText {
text: String::new(),
font: String::new(),
origin,
cap_height: 0.0,
width: 0.0,
rotation,
color: rgb(paint),
},
font_key: key,
end: origin,
em,
caps: Vec::new(),
});
}
if let Some(run) = self.current.as_mut() {
run.text.text.push_str(&text);
if let Some(top) = glyph_top {
run.caps.push(top);
// Capital height: the median capital top, so round letters'
// overshoot (O, G) does not lift it.
let mut caps = run.caps.clone();
caps.sort_by(f64::total_cmp);
run.text.cap_height = caps[caps.len() / 2];
}
run.end = [origin[0] + adv_vec.x, origin[1] + adv_vec.y];
run.text.width = ((run.end[0] - run.text.origin[0]).powi(2)
+ (run.end[1] - run.text.origin[1]).powi(2))
.sqrt();
}
}
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
let Image::Raster(raster) = image else {
return;
};
if !self.want_images {
return;
}
let mut decoded: Option<PdfImage> = None;
raster.with_rgba(
|data, alpha| {
use hayro::hayro_interpret::ImageData;
let (w, h) = (data.width(), data.height());
let n = (w * h) as usize;
let mut rgba = vec![255u8; n * 4];
match &data {
ImageData::Rgb(rgb) => {
for i in 0..n {
rgba[i * 4..i * 4 + 3].copy_from_slice(&rgb.data[i * 3..i * 3 + 3]);
}
}
ImageData::Luma(luma) => {
for i in 0..n {
rgba[i * 4..i * 4 + 3].fill(luma.data[i]);
}
}
}
if let Some(a) = alpha.filter(|a| a.width == w && a.height == h) {
for i in 0..n {
rgba[i * 4 + 3] = a.data[i];
}
}
// The transform maps pixel space (y down) onto the page.
let at = |x: f64, y: f64| pt(transform * Point::new(x, y));
let (wf, hf) = (w as f64, h as f64);
let (o, x1, y1) = (at(0.0, hf), at(wf, hf), at(0.0, 0.0));
decoded = Some(PdfImage {
rgba,
width: w,
height: h,
origin: o,
u: [(x1[0] - o[0]) / w as f64, (x1[1] - o[1]) / w as f64],
v: [(y1[0] - o[0]) / h as f64, (y1[1] - o[1]) / h as f64],
});
},
None,
);
self.images.extend(decoded);
}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
/// A point of a segment at parameter `t` (0..1).
pub fn bezier(seg: &Segment, t: f64) -> [f64; 2] {
match seg {
Segment::Line(a, b) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t],
Segment::Cubic(p0, p1, p2, p3) => {
let u = 1.0 - t;
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
[
a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0],
a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1],
]
}
}
}
/// Centre and radius when a closed loop of cubics traces a circle.
pub fn circle_of(sp: &SubPath) -> Option<([f64; 2], f64)> {
if !sp.closed || sp.segments.len() < 4 {
return None;
}
let cubics: Vec<&Segment> = sp
.segments
.iter()
.filter(|s| matches!(s, Segment::Cubic(..)))
.collect();
if cubics.len() != sp.segments.len() && sp.segments.len() - cubics.len() > 1 {
return None;
}
let starts: Vec<[f64; 2]> = cubics.iter().map(|s| s.start()).collect();
let n = starts.len() as f64;
let c = [
starts.iter().map(|p| p[0]).sum::<f64>() / n,
starts.iter().map(|p| p[1]).sum::<f64>() / n,
];
let dist = |p: [f64; 2]| ((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2)).sqrt();
let r = starts.iter().map(|p| dist(*p)).sum::<f64>() / n;
if r <= 0.0 {
return None;
}
let tol = r * 2e-3;
let on_circle = cubics.iter().all(|s| {
[0.0, 0.25, 0.5, 0.75].iter().all(|t| (dist(bezier(s, *t)) - r).abs() <= tol)
});
on_circle.then_some((c, r))
}
// ── Snapping ────────────────────────────────────────────────────────────────
static PDF_OSNAP: AtomicBool = AtomicBool::new(true);
/// PDFOSNAP: whether object snaps find the geometry inside PDF underlays.
pub fn pdf_osnap() -> bool {
PDF_OSNAP.load(Ordering::Relaxed)
}
pub fn set_pdf_osnap(on: bool) {
PDF_OSNAP.store(on, Ordering::Relaxed);
}
/// Most snap points one underlay contributes.
// ponytail: a flat cap; a spatial index over the page segments if large
// drawings need every point.
const MAX_SNAP_POINTS: usize = 20_000;
/// Endpoint and midpoint snaps of the underlay's PDF geometry, in world
/// space, when PDFOSNAP is on and the page is shown.
pub fn underlay_snap_points(
u: &acadrust::entities::Underlay,
document: &acadrust::CadDocument,
) -> Vec<(glam::DVec3, crate::scene::model::wire_model::SnapHint)> {
use crate::scene::model::wire_model::SnapHint;
if !pdf_osnap() || !u.flags.contains(acadrust::entities::UnderlayDisplayFlags::ON) {
return Vec::new();
}
let Some(def) = crate::entities::underlay::definition(u, document) else {
return Vec::new();
};
let source = super::pdf_layers::underlay_source(u, &def.file_path);
let Some(vectors) = page_vectors(&source, crate::entities::underlay::page_of(def)) else {
return Vec::new();
};
let world = |p: [f64; 2]| {
let w = crate::entities::underlay::local_to_world(u, p);
glam::DVec3::new(w[0], w[1], w[2])
};
let mut out = Vec::new();
'paths: for path in &vectors.paths {
for sp in &path.subpaths {
// A PDF circle offers its centre and quadrants, as a circle does.
if let Some((c, r)) = circle_of(sp) {
out.push((world(c), SnapHint::Center));
for (dx, dy) in [(r, 0.0), (0.0, r), (-r, 0.0), (0.0, -r)] {
out.push((world([c[0] + dx, c[1] + dy]), SnapHint::Quadrant));
}
}
for seg in &sp.segments {
if out.len() >= MAX_SNAP_POINTS {
break 'paths;
}
out.push((world(seg.start()), SnapHint::Endpoint));
out.push((world(seg.end()), SnapHint::Endpoint));
if let Segment::Line(a, b) = seg {
out.push((
world([(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]),
SnapHint::Midpoint,
));
}
}
}
}
out
}
/// Most points of the snap-only geometry wire of one underlay.
// ponytail: flat cap; a spatial index over page segments for huge drawings.
const MAX_GEOMETRY_POINTS: usize = 200_000;
/// The underlay's PDF geometry as world polylines (NaN-separated, curves
/// flattened) for nearest, intersection and perpendicular snaps. Empty when
/// PDFOSNAP is off or the page is not shown.
pub fn underlay_snap_geometry(
u: &acadrust::entities::Underlay,
document: &acadrust::CadDocument,
) -> Vec<[f64; 3]> {
if !pdf_osnap() || !u.flags.contains(acadrust::entities::UnderlayDisplayFlags::ON) {
return Vec::new();
}
let Some(def) = crate::entities::underlay::definition(u, document) else {
return Vec::new();
};
if def.unloaded {
return Vec::new();
}
let source = super::pdf_layers::underlay_source(u, &def.file_path);
let Some(vectors) = page_vectors(&source, crate::entities::underlay::page_of(def)) else {
return Vec::new();
};
let world = |p: [f64; 2]| crate::entities::underlay::local_to_world(u, p);
let mut out: Vec<[f64; 3]> = Vec::new();
for path in &vectors.paths {
for sp in &path.subpaths {
if out.len() >= MAX_GEOMETRY_POINTS {
return out;
}
if !out.is_empty() {
out.push([f64::NAN; 3]);
}
out.push(world(sp.segments[0].start()));
for seg in &sp.segments {
let steps = if matches!(seg, Segment::Cubic(..)) { 12 } else { 1 };
for k in 1..=steps {
out.push(world(bezier(seg, k as f64 / steps as f64)));
}
}
}
}
out
}
+106 -70
View File
@@ -25,6 +25,8 @@ mod draw_panel;
pub(crate) use draw_panel::tools as panel_tools;
mod modify_panel;
mod color_dropdown;
mod underlay_tab;
pub use underlay_tab::{UnderlayContext, UnderlayTabMsg};
use widgets::{StyleContext, *};
pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID};
mod collapse;
@@ -103,6 +105,10 @@ pub struct Ribbon {
/// Set by `CollapsePanels` when the tool row is in its tight state; the mode
/// selector hides itself then to give the cramped tab row its space back.
collapse_tight: Arc<AtomicBool>,
/// The selected PDF underlay's values while the contextual tab exists.
underlay_ctx: Option<UnderlayContext>,
/// The contextual underlay tab is the one shown.
underlay_tab_active: bool,
}
/// Per-layer display data shown in the ribbon layer dropdown.
@@ -196,6 +202,8 @@ impl Ribbon {
tool_bar_h: Arc::new(AtomicU32::new(TOOL_BAR_H.to_bits())),
collapse_mode: CollapseMode::default(),
collapse_tight: Arc::new(AtomicBool::new(false)),
underlay_ctx: None,
underlay_tab_active: false,
}
}
@@ -273,6 +281,7 @@ impl Ribbon {
pub fn select(&mut self, index: usize) {
if index < self.modules.len() {
self.active = index;
self.underlay_tab_active = false;
}
}
@@ -336,6 +345,58 @@ impl Ribbon {
show_block_palette,
show_file_tabs: self.show_file_tabs,
show_layout_tabs: self.show_layout_tabs,
underlay_mono: self.underlay_ctx.as_ref().is_some_and(|c| c.monochrome),
underlay_shown: self.underlay_ctx.as_ref().is_some_and(|c| c.shown),
underlay_snap: self.underlay_ctx.as_ref().is_some_and(|c| c.snap),
}
}
/// One panel in its four densities; `lead` adds controls before the
/// group's own tools.
fn panel<'a>(
&'a self,
g: &'a RibbonGroup,
ts: widgets::ToggleState,
style_ctx: &StyleContext<'_>,
lead: &dyn Fn(bool) -> Vec<Element<'a, Message>>,
) -> Panel<'a> {
let group = |compact: bool| {
render_group(
compact,
g,
lead(compact),
&self.active_tool,
&self.open_dropdown,
&self.last_cmd,
ts,
&self.layer_infos,
&self.active_layer,
self.active_color,
&self.active_linetype,
self.active_lineweight,
style_ctx,
)
};
let button = |tight: bool| {
collapse_button(
g,
self.last_panel_tool.get(g.title).copied(),
&self.active_tool,
&self.open_dropdown,
&self.last_cmd,
ts,
&self.layer_infos,
&self.active_layer,
self.active_color,
&self.active_linetype,
self.active_lineweight,
style_ctx,
tight,
)
};
Panel {
id: g.title.to_string(),
elements: [group(false), group(true), button(false), button(true)],
}
}
@@ -454,7 +515,7 @@ impl Ribbon {
return acc;
}
let is_active = i == self.active;
let is_active = i == self.active && !self.underlay_tab_shown();
let is_contextual = module.id() == "layout";
let btn = container(
button(text(crate::i18n::ribbon_module_title(module.id(), module.title())).size(12))
@@ -524,6 +585,11 @@ impl Ribbon {
},
);
let mut tab_items = tab_items;
if self.underlay_ctx.is_some() {
tab_items.push(underlay_tab_button(self.underlay_tab_shown()));
}
// Tabs may squeeze their gaps to fit before wrapping: from the normal 6px
// down to -12px on a narrow (e.g. phone) tab row, tucking neighbours into
// each other's 14px side padding without overlapping the labels. When the
@@ -603,74 +669,15 @@ impl Ribbon {
// fit they degrade from the right — a panel's large buttons first
// shrink to compact icon columns, then it collapses to a ▾ flyout
// button. See `CollapsePanels`.
let panels: Vec<Panel<'_>> = groups
.iter()
.map(|g| {
let ts = self.toggle_state(show_block_palette);
Panel {
id: g.title.to_string(),
elements: [render_group(
false,
g,
&self.active_tool,
&self.open_dropdown,
&self.last_cmd,
ts,
&self.layer_infos,
&self.active_layer,
self.active_color,
&self.active_linetype,
self.active_lineweight,
&style_ctx,
),
render_group(
true,
g,
&self.active_tool,
&self.open_dropdown,
&self.last_cmd,
ts,
&self.layer_infos,
&self.active_layer,
self.active_color,
&self.active_linetype,
self.active_lineweight,
&style_ctx,
),
collapse_button(
g,
self.last_panel_tool.get(g.title).copied(),
&self.active_tool,
&self.open_dropdown,
&self.last_cmd,
ts,
&self.layer_infos,
&self.active_layer,
self.active_color,
&self.active_linetype,
self.active_lineweight,
&style_ctx,
false,
),
collapse_button(
g,
self.last_panel_tool.get(g.title).copied(),
&self.active_tool,
&self.open_dropdown,
&self.last_cmd,
ts,
&self.layer_infos,
&self.active_layer,
self.active_color,
&self.active_linetype,
self.active_lineweight,
&style_ctx,
true,
),
],
}
})
.collect();
let ts = self.toggle_state(show_block_palette);
let panels: Vec<Panel<'_>> = if self.underlay_tab_shown() {
self.underlay_panels(ts, &style_ctx)
} else {
groups
.iter()
.map(|g| self.panel(g, ts, &style_ctx, &|_| Vec::new()))
.collect()
};
CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H)
.report_height(self.tool_bar_h.clone())
.report_tight(self.collapse_tight.clone())
@@ -1322,6 +1329,7 @@ impl Ribbon {
fn render_group<'a>(
compact: bool,
group: &RibbonGroup,
lead: Vec<Element<'a, Message>>,
active_tool: &Option<String>,
open_dd: &Option<String>,
last_cmd: &HashMap<&'static str, &'static str>,
@@ -1333,7 +1341,7 @@ fn render_group<'a>(
active_lineweight: LineWeight,
style_ctx: &StyleContext<'_>,
) -> Element<'a, Message> {
let mut items_row: Vec<Element<Message>> = Vec::new();
let mut items_row: Vec<Element<Message>> = lead;
let mut small_buf: Vec<Element<Message>> = Vec::new();
let ctx = widgets::RenderCtx {
@@ -1615,3 +1623,31 @@ mod tests {
assert_eq!(ribbon.open_dropdown, None);
}
}
/// The contextual underlay tab's button: framed in the accent colour.
fn underlay_tab_button<'a>(active: bool) -> Element<'a, Message> {
let btn = button(text(t!("PDF Underlay")).size(12))
.on_press(Message::RibbonSelectUnderlayTab)
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let pair = match (active, status) {
(true, _) => palette.background.weakest,
(false, button::Status::Hovered) => palette.background.weak,
_ => palette.background.base,
};
button::Style {
background: (active || matches!(status, button::Status::Hovered))
.then_some(Background::Color(pair.color)),
text_color: if active { pair.text } else { palette.primary.base.color },
border: Border {
color: palette.primary.base.color,
width: if active { 2.0 } else { 1.0 },
radius: 0.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
}
})
.padding([5, 14]);
container(btn).into()
}
+230
View File
@@ -0,0 +1,230 @@
//! The contextual "PDF Underlay" tab: shown and brought forward while only
//! PDF underlays are selected, gone when the selection changes.
use iced::widget::{column, row, slider, text, text_input};
use iced::{Element, Length};
use super::*;
use crate::modules::{ModuleEvent, ToolDef};
/// What the tab shows for the selected underlay (the first one).
#[derive(Clone, Debug, PartialEq)]
pub struct UnderlayContext {
pub contrast: u8,
pub fade: u8,
pub monochrome: bool,
pub shown: bool,
pub snap: bool,
pub contrast_text: String,
pub fade_text: String,
}
impl UnderlayContext {
pub fn new(contrast: u8, fade: u8, monochrome: bool, shown: bool, snap: bool) -> Self {
Self {
contrast,
fade,
monochrome,
shown,
snap,
contrast_text: contrast.to_string(),
fade_text: fade.to_string(),
}
}
}
/// The tab's edits; the host writes them to the selected underlays.
#[derive(Clone, Debug)]
pub enum UnderlayTabMsg {
Contrast(u8),
Fade(u8),
ContrastText(String),
FadeText(String),
/// Slider released or value submitted: write contrast and fade.
Commit,
}
pub(super) const ADJUST: &str = "Adjust";
fn tool(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef {
ToolDef {
id,
label,
icon: IconKind::Svg(icon),
event: ModuleEvent::Command(id.to_string()),
}
}
fn groups() -> &'static [RibbonGroup] {
static GROUPS: std::sync::OnceLock<Vec<RibbonGroup>> = std::sync::OnceLock::new();
const FRAMES: &[u8] = include_bytes!("../../../assets/icons/underlay_frames.svg");
const CLIP: &[u8] = include_bytes!("../../../assets/icons/xclip.svg");
GROUPS.get_or_init(|| {
vec![
RibbonGroup {
title: ADJUST,
tools: vec![RibbonItem::LargeTool(tool(
"_PDFULMONO",
"Display in\nMonochrome",
FRAMES,
))],
},
RibbonGroup {
title: "Clipping",
tools: vec![
RibbonItem::LargeTool(tool("_PDFULCLIP", "Create Clipping\nBoundary", CLIP)),
RibbonItem::LargeTool(tool("_PDFULUNCLIP", "Remove\nClipping", CLIP)),
],
},
RibbonGroup {
title: "Options",
tools: vec![
RibbonItem::LargeTool(tool("_PDFULSHOW", "Show\nUnderlay", FRAMES)),
RibbonItem::LargeTool(tool(
"_PDFULSNAP",
"Enable\nSnap",
include_bytes!("../../../assets/icons/snap_underlays.svg"),
)),
RibbonItem::LargeTool(tool(
"EXTERNALREFERENCES",
"External\nReferences",
crate::ui::icons::FOLDER_OPEN,
)),
],
},
RibbonGroup {
title: "PDF Layers",
tools: vec![RibbonItem::LargeTool(tool(
"ULAYERS",
"Edit\nLayers",
include_bytes!("../../../assets/icons/underlay_layers.svg"),
))],
},
RibbonGroup {
title: "PDF Import",
tools: vec![RibbonItem::LargeTool(tool(
"_PDFULIMPORT",
"Import As\nObjects",
include_bytes!("../../../assets/icons/cui_import.svg"),
))],
},
]
})
}
/// Contrast / Fade: a label, a 0–100 slider and the value box.
fn adjust_rows<'a>(ctx: &'a UnderlayContext) -> Element<'a, Message> {
let line = |label: &'static str,
value: u8,
buffer: &'a str,
on_slide: fn(u8) -> UnderlayTabMsg,
on_text: fn(String) -> UnderlayTabMsg| {
row![
text(t!(label)).size(11).width(Length::Fixed(64.0)),
slider(0..=100u8, value, move |v| Message::UnderlayTab(on_slide(v)))
.on_release(Message::UnderlayTab(UnderlayTabMsg::Commit))
.width(Length::Fixed(110.0)),
text_input("", buffer)
.on_input(move |s| Message::UnderlayTab(on_text(s)))
.on_submit(Message::UnderlayTab(UnderlayTabMsg::Commit))
.size(11)
.padding([2, 4])
.width(Length::Fixed(44.0)),
]
.spacing(6)
.align_y(iced::Center)
};
column![
line(
"Contrast",
ctx.contrast,
&ctx.contrast_text,
UnderlayTabMsg::Contrast,
UnderlayTabMsg::ContrastText,
),
line(
"Fade",
ctx.fade,
&ctx.fade_text,
UnderlayTabMsg::Fade,
UnderlayTabMsg::FadeText,
),
]
.spacing(8)
.padding([6, 4])
.into()
}
impl Ribbon {
/// Show the tab for the selected underlay, or drop it (`None`). A tab
/// that appears comes to the front; one that goes returns the tab that
/// was active before it.
pub fn set_underlay_context(&mut self, context: Option<UnderlayContext>) {
match (&self.underlay_ctx, &context) {
(None, Some(_)) => self.underlay_tab_active = true,
(Some(_), None) => self.underlay_tab_active = false,
_ => {}
}
// A value being typed survives a refresh that leaves the value as is.
let mut context = context;
if let (Some(old), Some(new)) = (&self.underlay_ctx, &mut context) {
if (old.contrast, old.fade) == (new.contrast, new.fade) {
new.contrast_text = old.contrast_text.clone();
new.fade_text = old.fade_text.clone();
}
}
self.underlay_ctx = context;
}
pub fn underlay_context_mut(&mut self) -> Option<&mut UnderlayContext> {
self.underlay_ctx.as_mut()
}
/// Select a tab by its module id; "pdf_underlay" is the contextual tab.
pub fn select_by_id(&mut self, id: &str) -> bool {
if id == "pdf_underlay" {
self.select_underlay_tab();
return self.underlay_tab_active;
}
match self.modules.iter().position(|m| m.id() == id) {
Some(index) => {
self.select(index);
true
}
None => false,
}
}
pub fn select_underlay_tab(&mut self) {
if self.underlay_ctx.is_some() {
self.underlay_tab_active = true;
}
}
pub(super) fn underlay_tab_shown(&self) -> bool {
self.underlay_tab_active && self.underlay_ctx.is_some()
}
pub(super) fn underlay_panels<'a>(
&'a self,
ts: widgets::ToggleState,
style_ctx: &StyleContext<'_>,
) -> Vec<Panel<'a>> {
let Some(ctx) = self.underlay_ctx.as_ref() else {
return Vec::new();
};
groups()
.iter()
.map(|g| {
let lead = |_: bool| -> Vec<Element<'a, Message>> {
if g.title == ADJUST {
vec![adjust_rows(ctx)]
} else {
Vec::new()
}
};
self.panel(g, ts, style_ctx, &lead)
})
.collect()
}
}
+6
View File
@@ -40,6 +40,9 @@ pub(super) struct ToggleState {
pub show_block_palette: bool,
pub show_file_tabs: bool,
pub show_layout_tabs: bool,
pub underlay_mono: bool,
pub underlay_shown: bool,
pub underlay_snap: bool,
}
// ── Layout constants (single source of truth: ROW_H from ui::mod) ─────────
@@ -375,6 +378,9 @@ pub(super) fn is_active_tool(
"UCSICON" => state.show_ucs_icon,
"PROPERTIES" => state.show_properties,
"BLOCKPALETTE" => state.show_block_palette,
"_PDFULMONO" => state.underlay_mono,
"_PDFULSHOW" => state.underlay_shown,
"_PDFULSNAP" => state.underlay_snap,
"FILETAB" => state.show_file_tabs,
"LAYOUTTAB" => state.show_layout_tabs,
id => active_tool.as_deref() == Some(id),
+1
View File
@@ -24,6 +24,7 @@ pub mod annotation_data;
pub mod alias_editor;
pub mod find_replace;
pub mod named_parameters;
pub mod pdf_dialogs;
pub mod xref_attach;
pub mod xref_help;
pub mod xref_manager;
+842
View File
@@ -0,0 +1,842 @@
//! PDF dialogs: Attach PDF Underlay (pages, path type, insertion, scale,
//! rotation), Underlay Layers, PDF Import Settings and Import PDF.
use std::fmt;
use iced::widget::{
button, column, combo_box, container, image, pick_list, radio, row, scrollable, text,
text_input, Space,
};
use iced::{Background, Border, Element, Fill, Length, Theme};
use crate::app::Message;
use crate::io::xref_model::Pathtype;
use crate::modules::insert::pdf_import::{ImportLayers, PdfImportSettings};
use crate::t;
use crate::ui::style::common::muted_style;
use crate::ui::style::form::{button_style, field_style};
use crate::ui::window::block_definition::{group, labeled_checkbox};
/// One edit in any of the PDF dialogs.
#[derive(Debug, Clone)]
pub enum PdfDialogMsg {
// Attach
AttachName(String),
AttachBrowse,
AttachPage(usize),
AttachPathType(PathTypeChoice),
AttachInsertOnScreen(bool),
AttachInsert(usize, String),
AttachScaleOnScreen(bool),
AttachScale(String),
AttachRotationOnScreen(bool),
AttachRotation(String),
AttachDetails(bool),
AttachOk,
// Underlay Layers
LayersUnderlay(String),
LayersSearch(String),
LayersToggle(String),
LayersOk,
// Import settings (the settings dialog and the Import PDF dialog)
Vector(bool),
Fills(bool),
Text(bool),
Raster(bool),
Layers(ImportLayers),
AsBlock(bool),
Join(bool),
Hatches(bool),
Lineweights(bool),
Linetypes(bool),
SettingsOk,
// Import PDF
ImportBrowse,
ImportPage(usize),
ImportPageText(String),
ImportInsertOnScreen(bool),
ImportScale(String),
ImportRotation(RotationChoice),
ImportOk,
Options,
Help(&'static str),
}
fn msg(m: PdfDialogMsg) -> Message {
Message::PdfDialog(m)
}
/// Path type as the Attach dialog lists it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PathTypeChoice(pub Pathtype);
impl PathTypeChoice {
pub const ALL: [PathTypeChoice; 3] = [
PathTypeChoice(Pathtype::Full),
PathTypeChoice(Pathtype::Relative),
PathTypeChoice(Pathtype::None),
];
}
impl fmt::Display for PathTypeChoice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let label = match self.0 {
Pathtype::Full => t!("Full path"),
Pathtype::Relative => t!("Relative path"),
Pathtype::None => t!("No path"),
};
f.write_str(label.as_ref())
}
}
/// Rotation of an imported page: the four right angles.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RotationChoice(pub u16);
impl RotationChoice {
pub const ALL: [RotationChoice; 4] = [
RotationChoice(0),
RotationChoice(90),
RotationChoice(180),
RotationChoice(270),
];
}
impl fmt::Display for RotationChoice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// A page of the chosen file and its thumbnail.
#[derive(Clone)]
pub struct PageThumb {
pub label: String,
pub image: Option<image::Handle>,
}
/// Thumbnails of every page, about 100 px wide.
// ponytail: all pages are rasterised when the dialog opens; lazy per-row
// thumbnails if files with hundreds of pages matter.
pub fn page_thumbs(path: &str) -> Vec<PageThumb> {
use crate::scene::model::pdf_raster::{page_count, page_size_inches, rasterize_page_at_dpi};
let count = page_count(path).unwrap_or(0);
(1..=count)
.map(|n| {
let label = n.to_string();
let image = page_size_inches(path, &label).and_then(|(w, h)| {
let dpi = (100.0 / w.max(h).max(0.1)) as f32;
let page = rasterize_page_at_dpi(path, &label, dpi)?;
Some(image::Handle::from_rgba(page.width, page.height, page.pixels.to_vec()))
});
PageThumb { label, image }
})
.collect()
}
/// Ctrl adds or removes a page, Shift takes the range from the last click,
/// a plain click selects that page alone.
pub fn click_page(selected: &mut Vec<usize>, anchor: &mut usize, page: usize, ctrl: bool, shift: bool) {
if shift {
let (a, b) = if *anchor <= page { (*anchor, page) } else { (page, *anchor) };
*selected = (a..=b).collect();
} else if ctrl {
match selected.iter().position(|p| *p == page) {
Some(at) if selected.len() > 1 => {
selected.remove(at);
}
Some(_) => {}
None => {
selected.push(page);
selected.sort_unstable();
}
}
*anchor = page;
} else {
*selected = vec![page];
*anchor = page;
}
}
fn frame_style(theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(theme.palette().background.base.color)),
border: Border {
width: 1.0,
radius: 2.0.into(),
color: theme.palette().background.strong.color,
},
..Default::default()
}
}
fn page_grid<'a>(
pages: &'a [PageThumb],
selected: &[usize],
on_click: fn(usize) -> PdfDialogMsg,
height: f32,
) -> Element<'a, Message> {
let tiles = pages.iter().enumerate().map(|(index, page)| {
let chosen = selected.contains(&index);
let picture: Element<'a, Message> = match &page.image {
Some(handle) => image(handle.clone()).width(Fill).height(Fill).into(),
None => Space::new().width(Fill).height(Fill).into(),
};
let tile = column![
container(picture)
.width(Length::Fixed(100.0))
.height(Length::Fixed(60.0))
.padding(2)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(iced::Color::WHITE)),
border: Border {
width: if chosen { 2.0 } else { 1.0 },
radius: 1.0.into(),
color: if chosen {
theme.palette().primary.base.color
} else {
theme.palette().background.strong.color
},
},
..Default::default()
}),
container(text(page.label.clone()).size(11))
.width(Length::Fixed(100.0))
.align_x(iced::Center)
.style(move |theme: &Theme| container::Style {
background: chosen
.then(|| Background::Color(theme.palette().primary.weak.color)),
..Default::default()
}),
]
.spacing(1);
button(tile)
.on_press(msg(on_click(index)))
.padding(2)
.style(|_: &Theme, _| button::Style::default())
.into()
});
container(scrollable(
iced::widget::Row::with_children(tiles.collect::<Vec<_>>())
.spacing(8)
.wrap()
.vertical_spacing(8),
))
.padding(6)
.width(Fill)
.height(Length::Fixed(height))
.style(frame_style)
.into()
}
fn value_field<'a>(
label: String,
value: &'a str,
enabled: bool,
label_width: f32,
on_input: impl Fn(String) -> Message + 'a,
) -> Element<'a, Message> {
let mut field = text_input("", value)
.size(11)
.padding([3, 6])
.width(Fill)
.style(field_style);
if enabled {
field = field.on_input(on_input);
}
row![
text(label).size(11).style(muted_style).width(Length::Fixed(label_width)),
field
]
.spacing(6)
.align_y(iced::Center)
.into()
}
fn read_only<'a>(label: String, value: &'a str, label_width: f32) -> Element<'a, Message> {
value_field(label, value, false, label_width, |_| Message::Noop)
}
fn footer<'a>(ok: PdfDialogMsg, help: &'static str) -> iced::widget::Row<'a, Message> {
row![
Space::new().width(Fill),
button(text(t!("OK")).size(11))
.on_press(msg(ok))
.style(button_style(true))
.padding([4, 16]),
button(text(t!("Cancel")).size(11))
.on_press(Message::CloseModal)
.style(button_style(false))
.padding([4, 12]),
button(text(t!("Help")).size(11))
.on_press(msg(PdfDialogMsg::Help(help)))
.style(button_style(false))
.padding([4, 12]),
]
.spacing(6)
.align_y(iced::Center)
}
// ── Attach PDF Underlay ────────────────────────────────────────────────────
/// Choices the Attach dialog keeps for its next opening.
#[derive(Clone)]
pub struct AttachMemory {
pub path_type: PathTypeChoice,
pub insert_on_screen: bool,
pub scale_on_screen: bool,
pub scale: String,
pub rotation_on_screen: bool,
pub rotation: String,
pub details: bool,
}
impl Default for AttachMemory {
fn default() -> Self {
Self {
path_type: PathTypeChoice(Pathtype::Relative),
insert_on_screen: true,
scale_on_screen: false,
scale: "1.0000".into(),
rotation_on_screen: false,
rotation: "0".into(),
details: false,
}
}
}
pub static ATTACH_MEMORY: std::sync::Mutex<Option<AttachMemory>> = std::sync::Mutex::new(None);
pub struct PdfAttachState {
/// The file read (absolute).
pub path: String,
pub name: String,
/// PDF files already attached: name and read path.
pub existing: Vec<(String, String)>,
pub name_combo: combo_box::State<String>,
pub pages: Vec<PageThumb>,
pub selected: Vec<usize>,
pub anchor: usize,
pub path_type: PathTypeChoice,
pub insert_on_screen: bool,
pub insert: [String; 3],
pub scale_on_screen: bool,
pub scale: String,
pub rotation_on_screen: bool,
pub rotation: String,
pub found_in: String,
pub saved_path: String,
pub page_size: String,
pub details: bool,
}
impl PdfAttachState {
pub fn new(existing: Vec<(String, String)>) -> Self {
let memory = ATTACH_MEMORY
.lock()
.ok()
.and_then(|m| m.clone())
.unwrap_or_default();
let names = existing.iter().map(|(name, _)| name.clone()).collect();
Self {
path: String::new(),
name: String::new(),
existing,
name_combo: combo_box::State::new(names),
pages: Vec::new(),
selected: vec![0],
anchor: 0,
path_type: memory.path_type,
insert_on_screen: memory.insert_on_screen,
insert: ["0.0000".into(), "0.0000".into(), "0.0000".into()],
scale_on_screen: memory.scale_on_screen,
scale: memory.scale,
rotation_on_screen: memory.rotation_on_screen,
rotation: memory.rotation,
found_in: String::new(),
saved_path: String::new(),
page_size: String::new(),
details: memory.details,
}
}
pub fn remember(&self) {
if let Ok(mut m) = ATTACH_MEMORY.lock() {
*m = Some(AttachMemory {
path_type: self.path_type,
insert_on_screen: self.insert_on_screen,
scale_on_screen: self.scale_on_screen,
scale: self.scale.clone(),
rotation_on_screen: self.rotation_on_screen,
rotation: self.rotation.clone(),
details: self.details,
});
}
}
pub fn page_labels(&self) -> Vec<String> {
self.selected
.iter()
.filter_map(|i| self.pages.get(*i).map(|p| p.label.clone()))
.collect()
}
}
pub fn view_attach<'a>(
state: &'a PdfAttachState,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let name_combo = combo_box(
&state.name_combo,
"",
(!state.name.is_empty()).then_some(&state.name),
|chosen: String| msg(PdfDialogMsg::AttachName(chosen)),
)
.size(11)
.padding([3, 6])
.width(Fill);
let name_section = column![
text(t!("Name:")).size(11).style(muted_style),
row![
name_combo,
button(text(t!("Browse...")).size(11))
.on_press(msg(PdfDialogMsg::AttachBrowse))
.style(button_style(false))
.padding([4, 12])
.width(Length::Fixed(96.0)),
]
.spacing(8)
.align_y(iced::Center),
]
.spacing(3);
let pages = group(
t!("Select one or more pages from the PDF file:").into_owned(),
page_grid(&state.pages, &state.selected, PdfDialogMsg::AttachPage, 250.0),
Length::Shrink,
);
let path_type = group(
t!("Path type").into_owned(),
pick_list(Some(state.path_type), PathTypeChoice::ALL, |c| c.to_string())
.on_select(|v| msg(PdfDialogMsg::AttachPathType(v)))
.text_size(11)
.padding([3, 6])
.width(Fill),
Length::Shrink,
);
let insert_enabled = !state.insert_on_screen;
let insertion = group(
t!("Insertion point").into_owned(),
column![
labeled_checkbox(t!("Specify On-screen"), state.insert_on_screen, |v| msg(
PdfDialogMsg::AttachInsertOnScreen(v)
)),
value_field("X:".into(), &state.insert[0], insert_enabled, 16.0, |v| msg(
PdfDialogMsg::AttachInsert(0, v)
)),
value_field("Y:".into(), &state.insert[1], insert_enabled, 16.0, |v| msg(
PdfDialogMsg::AttachInsert(1, v)
)),
value_field("Z:".into(), &state.insert[2], insert_enabled, 16.0, |v| msg(
PdfDialogMsg::AttachInsert(2, v)
)),
]
.spacing(5),
Length::Shrink,
);
let middle = column![path_type, insertion].spacing(6).width(Length::FillPortion(1));
let mut scale_field = text_input("", &state.scale)
.size(11)
.padding([3, 6])
.width(Fill)
.style(field_style);
if !state.scale_on_screen {
scale_field = scale_field.on_input(|v| msg(PdfDialogMsg::AttachScale(v)));
}
let scale = group(
t!("Scale").into_owned(),
column![
labeled_checkbox(t!("Specify On-screen"), state.scale_on_screen, |v| msg(
PdfDialogMsg::AttachScaleOnScreen(v)
)),
scale_field,
]
.spacing(5),
Length::Shrink,
);
let rotation = group(
t!("Rotation").into_owned(),
column![
labeled_checkbox(t!("Specify On-screen"), state.rotation_on_screen, |v| msg(
PdfDialogMsg::AttachRotationOnScreen(v)
)),
value_field(
t!("Angle:").into_owned(),
&state.rotation,
!state.rotation_on_screen,
44.0,
|v| msg(PdfDialogMsg::AttachRotation(v))
),
]
.spacing(5),
Length::Shrink,
);
let right = column![scale, rotation].spacing(6).width(Length::FillPortion(1));
let body = row![
container(pages).width(Length::FillPortion(1)),
middle,
right
]
.spacing(6)
.width(Fill);
let details: Element<'a, Message> = if state.details {
group(
t!("Location").into_owned(),
column![
read_only(t!("Found in:").into_owned(), &state.found_in, 92.0),
read_only(t!("Saved path:").into_owned(), &state.saved_path, 92.0),
read_only(t!("Page size:").into_owned(), &state.page_size, 92.0),
]
.spacing(5),
Length::Shrink,
)
} else {
Space::new().height(0).into()
};
let details_label = if state.details {
t!("Hide Details")
} else {
t!("Show Details")
};
let footer = row![button(text(details_label).size(11))
.on_press(msg(PdfDialogMsg::AttachDetails(!state.details)))
.style(button_style(false))
.padding([4, 12])]
.push(footer(PdfDialogMsg::AttachOk, "attach"))
.align_y(iced::Center);
column![name_section, body, details, footer]
.spacing(6)
.padding([8, 10])
.width(sizing.width)
.into()
}
// ── Underlay Layers ────────────────────────────────────────────────────────
/// One PDF underlay the dialog can show: its name, its file's layers, and
/// which of them it turns off.
#[derive(Clone)]
pub struct LayerTarget {
pub name: String,
pub handle: acadrust::Handle,
pub layers: Vec<String>,
pub hidden: Vec<String>,
}
pub struct UnderlayLayersState {
pub targets: Vec<LayerTarget>,
pub current: usize,
pub search: String,
}
pub fn view_layers<'a>(
state: &'a UnderlayLayersState,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let names: Vec<String> = state.targets.iter().map(|t| t.name.clone()).collect();
let target = state.targets.get(state.current);
let reference = row![
text(t!("Reference name:")).size(11).width(Length::Fixed(110.0)),
pick_list(target.map(|t| t.name.clone()), names, |n: &String| n.clone())
.on_select(|n| msg(PdfDialogMsg::LayersUnderlay(n)))
.text_size(11)
.padding([3, 6])
.width(Fill),
]
.spacing(6)
.align_y(iced::Center);
let search = text_input(t!("Search for layer").as_ref(), &state.search)
.on_input(|s| msg(PdfDialogMsg::LayersSearch(s)))
.size(11)
.padding([3, 6])
.width(Length::Fixed(220.0))
.style(field_style);
let header = row![
text(t!("On")).size(11).style(muted_style).width(Length::Fixed(44.0)),
text(t!("Name")).size(11).style(muted_style),
]
.padding([4, 8]);
let body: Element<'a, Message> = match target {
Some(t) if !t.layers.is_empty() => {
let needle = state.search.to_lowercase();
let rows = t
.layers
.iter()
.filter(|name| needle.is_empty() || name.to_lowercase().contains(&needle))
.map(|name| {
let on = !t.hidden.contains(name);
let bulb = button(text("●").size(12).style(move |theme: &Theme| text::Style {
color: Some(if on {
theme.palette().warning.base.color
} else {
theme.palette().background.strong.color
}),
}))
.on_press(msg(PdfDialogMsg::LayersToggle(name.clone())))
.padding([0, 4])
.style(|_: &Theme, _| button::Style::default());
row![
container(bulb).width(Length::Fixed(44.0)),
text(name.clone()).size(11)
]
.padding([2, 8])
.align_y(iced::Center)
.into()
});
scrollable(iced::widget::Column::with_children(rows.collect::<Vec<_>>()))
.height(Fill)
.into()
}
_ => container(text(t!("This file does not contain any layers.")).size(11))
.width(Fill)
.height(Fill)
.align_x(iced::Center)
.align_y(iced::Center)
.into(),
};
let list = container(column![header, body])
.width(Fill)
.height(Length::Fixed(300.0))
.style(frame_style);
column![
text(t!("Select an underlay to view its layers.")).size(11),
reference,
search,
list,
footer(PdfDialogMsg::LayersOk, "layers"),
]
.spacing(8)
.padding([8, 10])
.width(sizing.width)
.into()
}
// ── PDF Import Settings ────────────────────────────────────────────────────
fn settings_groups<'a>(s: &'a PdfImportSettings) -> (Element<'a, Message>, Element<'a, Message>, Element<'a, Message>) {
let fills: Element<'a, Message> = if s.vector {
labeled_checkbox(t!("Solid fills"), s.fills, |v| msg(PdfDialogMsg::Fills(v)))
} else {
labeled_checkbox(t!("Solid fills"), s.fills, move |_| Message::Noop)
};
let data = group(
t!("PDF data to import").into_owned(),
column![
labeled_checkbox(t!("Vector geometry"), s.vector, |v| msg(PdfDialogMsg::Vector(v))),
container(fills).padding(iced::Padding { left: 20.0, ..Default::default() }),
labeled_checkbox(t!("TrueType text"), s.text, |v| msg(PdfDialogMsg::Text(v))),
labeled_checkbox(t!("Raster images"), s.raster, |v| msg(PdfDialogMsg::Raster(v))),
]
.spacing(6),
Length::Fill,
);
let layer_radio = |label: std::borrow::Cow<'static, str>, value: ImportLayers| {
radio(label, value, Some(s.layers), |v| msg(PdfDialogMsg::Layers(v)))
.size(14)
.text_size(11)
};
let layers = group(
t!("Layers").into_owned(),
column![
layer_radio(t!("Use PDF layers"), ImportLayers::Pdf),
layer_radio(t!("Create object layers"), ImportLayers::Object),
layer_radio(t!("Current layer"), ImportLayers::Current),
]
.spacing(6),
Length::Fill,
);
let options = group(
t!("Import options").into_owned(),
column![
labeled_checkbox(t!("Import as block"), s.as_block, |v| msg(PdfDialogMsg::AsBlock(v))),
labeled_checkbox(t!("Join line and arc segments"), s.join, |v| msg(
PdfDialogMsg::Join(v)
)),
labeled_checkbox(t!("Convert solid fills to hatches"), s.hatches, |v| msg(
PdfDialogMsg::Hatches(v)
)),
labeled_checkbox(t!("Apply lineweight properties"), s.lineweights, |v| msg(
PdfDialogMsg::Lineweights(v)
)),
labeled_checkbox(t!("Infer linetypes from collinear dashes"), s.linetypes, |v| msg(
PdfDialogMsg::Linetypes(v)
)),
]
.spacing(6),
Length::Shrink,
);
(data, layers, options)
}
pub fn view_import_settings<'a>(
settings: &'a PdfImportSettings,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let (data, layers, options) = settings_groups(settings);
let side_button = |label: std::borrow::Cow<'static, str>, on: Message, accent: bool| {
button(text(label).size(11).width(Fill).align_x(iced::Center))
.on_press(on)
.style(button_style(accent))
.padding([4, 12])
.width(Length::Fixed(110.0))
};
let left = column![
row![data, layers].spacing(8).height(Length::Fixed(140.0)),
options
]
.spacing(8)
.width(Fill);
let right = column![
side_button(t!("OK"), msg(PdfDialogMsg::SettingsOk), true),
side_button(t!("Cancel"), Message::CloseModal, false),
side_button(t!("Options..."), msg(PdfDialogMsg::Options), false),
side_button(t!("Help"), msg(PdfDialogMsg::Help("settings")), false),
]
.spacing(8);
row![left, right]
.spacing(10)
.padding([8, 10])
.width(sizing.width)
.into()
}
// ── Import PDF (File) ──────────────────────────────────────────────────────
pub struct PdfImportFileState {
pub path: String,
pub pages: Vec<PageThumb>,
pub selected: usize,
pub page_text: String,
pub insert_on_screen: bool,
pub scale: String,
pub rotation: RotationChoice,
pub page_size: String,
pub settings: PdfImportSettings,
}
pub fn view_import_file<'a>(
state: &'a PdfImportFileState,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let file_name = std::path::Path::new(&state.path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let file_row = row![
text(t!("File name:")).size(11).width(Length::Fixed(70.0)),
text_input("", &file_name)
.size(11)
.padding([3, 6])
.width(Fill)
.style(field_style),
button(text(t!("Browse...")).size(11))
.on_press(msg(PdfDialogMsg::ImportBrowse))
.style(button_style(false))
.padding([4, 12])
.width(Length::Fixed(96.0)),
]
.spacing(8)
.align_y(iced::Center);
let selected = [state.selected];
let page_group = group(
t!("Page to import").into_owned(),
column![
row![
text(t!("Page:")).size(11),
text_input("", &state.page_text)
.on_input(|s| msg(PdfDialogMsg::ImportPageText(s)))
.size(11)
.padding([3, 6])
.width(Length::Fixed(56.0))
.style(field_style),
text(crate::tf!("Total: {count}", count = state.pages.len()))
.size(11)
.style(muted_style),
]
.spacing(8)
.align_y(iced::Center),
page_grid(&state.pages, &selected, PdfDialogMsg::ImportPage, 300.0),
row![
text(crate::tf!("Page size: {size}", size = state.page_size.clone()))
.size(11)
.style(muted_style),
Space::new().width(Fill),
text(crate::tf!("PDF scale: {scale}:1", scale = state.scale.trim()))
.size(11)
.style(muted_style),
],
]
.spacing(6),
Length::Shrink,
);
let location = group(
t!("Location").into_owned(),
column![
labeled_checkbox(t!("Specify insertion point on-screen"), state.insert_on_screen, |v| {
msg(PdfDialogMsg::ImportInsertOnScreen(v))
}),
row![
text(t!("Scale:")).size(11).style(muted_style),
text_input("", &state.scale)
.on_input(|s| msg(PdfDialogMsg::ImportScale(s)))
.size(11)
.padding([3, 6])
.width(Length::Fixed(80.0))
.style(field_style),
Space::new().width(Length::Fixed(20.0)),
text(t!("Rotation:")).size(11).style(muted_style),
pick_list(Some(state.rotation), RotationChoice::ALL, |c| c.to_string())
.on_select(|v| msg(PdfDialogMsg::ImportRotation(v)))
.text_size(11)
.padding([3, 6])
.width(Length::Fixed(76.0)),
]
.spacing(6)
.align_y(iced::Center),
]
.spacing(6),
Length::Shrink,
);
let (data, layers, options) = settings_groups(&state.settings);
let right = column![
location,
row![data, layers].spacing(8).height(Length::Fixed(140.0)),
options
]
.spacing(8)
.width(Length::FillPortion(1));
let body = row![container(page_group).width(Length::FillPortion(1)), right].spacing(8);
let footer = row![button(text(t!("Options...")).size(11))
.on_press(msg(PdfDialogMsg::Options))
.style(button_style(false))
.padding([4, 12])]
.push(footer(PdfDialogMsg::ImportOk, "import"))
.align_y(iced::Center);
column![file_row, body, footer]
.spacing(8)
.padding([8, 10])
.width(sizing.width)
.into()
}