forked from 1b-agent/opencadstudio-upstream
- Gradients: the middle colour sits (horizon - 0.5) viewport widths from the centre and blends over height viewport heights each side; two colours blend over a viewport height each side. - Image backgrounds: offset in device pixels (right, down), negative scale mirrors, fitted with aspect ratio covers the viewport; the dialog keeps an image's offset and scale. - Layouts: a model-space view restored on a sheet asks for the viewport, enters it and gives it the view's camera, background and visual style; inside a viewport the view goes there; a paper-space view's background is kept on the sheet viewport but not drawn; floating viewports draw their background. - Background objects no view or viewport uses any more are erased; a view without a background restored where the viewport already is leaves the viewport's background. - The viewport's named visual style (Realistic, ...) shows by name and the render-mode picker records its style on the viewport record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2431 lines
76 KiB
Rust
2431 lines
76 KiB
Rust
//! PDF dialogs: Attach PDF Underlay (pages, path type, insertion, scale,
|
||
//! rotation), Underlay Layers, PDF Import Settings and Import PDF, built
|
||
//! from cards, segmented choices and toggle chips; the point cloud Attach
|
||
//! and Color Map dialogs share them.
|
||
|
||
use std::fmt;
|
||
|
||
use iced::widget::{
|
||
button, column, combo_box, container, image, pick_list, 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, dialog_button, field_style};
|
||
|
||
/// One edit in any of the PDF dialogs.
|
||
#[derive(Debug, Clone)]
|
||
pub enum PdfDialogMsg {
|
||
// Attach
|
||
AttachName(String),
|
||
AttachBrowse,
|
||
AttachPage(usize),
|
||
/// DGN conversion units: sub (true) or master.
|
||
AttachSubUnits(bool),
|
||
AttachPathType(PathTypeChoice),
|
||
AttachInsertOnScreen(bool),
|
||
AttachInsert(usize, String),
|
||
AttachScaleOnScreen(bool),
|
||
AttachScale(String),
|
||
AttachRotationOnScreen(bool),
|
||
AttachRotation(String),
|
||
AttachDetails(bool),
|
||
AttachOk,
|
||
// Attach Point Cloud
|
||
CloudBrowse,
|
||
CloudPathType(PathTypeChoice),
|
||
CloudInsertOnScreen(bool),
|
||
CloudInsert(usize, String),
|
||
CloudScaleOnScreen(bool),
|
||
CloudScale(String),
|
||
CloudRotationOnScreen(bool),
|
||
CloudRotation(String),
|
||
CloudLock(bool),
|
||
CloudZoom(bool),
|
||
CloudOk,
|
||
// Point Cloud Color Map
|
||
/// Elevation tab (else intensity).
|
||
MapTab(bool),
|
||
MapScheme(String),
|
||
MapCount(usize),
|
||
MapEven,
|
||
MapReverse,
|
||
MapGradient(bool),
|
||
MapNew,
|
||
MapDelete,
|
||
MapRename,
|
||
MapNameInput(String),
|
||
MapNameOk,
|
||
MapNameCancel,
|
||
MapMax(String),
|
||
MapMin(String),
|
||
MapInterval(String),
|
||
MapExtents(bool),
|
||
MapOutOfRange(OutOfRange),
|
||
MapCurrent(bool),
|
||
MapApply,
|
||
MapOk,
|
||
// Extract Section Lines from Point Cloud
|
||
SecPerimeter(bool),
|
||
SecMaxPoints(String),
|
||
SecLayer(String),
|
||
SecColor(LineColor),
|
||
SecPolylines(bool),
|
||
SecWidth(String),
|
||
SecMinLength(String),
|
||
SecConnect(String),
|
||
SecAngle(String),
|
||
/// Measure the connect tolerance (else the minimum length) on screen.
|
||
SecPick(bool),
|
||
SecPreview(bool),
|
||
SecCreate,
|
||
// 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),
|
||
// View background
|
||
BgKind(BgKind),
|
||
/// Open the colour window for a slot: 0 solid, 1 top, 2 middle, 3 bottom.
|
||
BgPickColor(u8),
|
||
BgColor(u8, [u8; 3]),
|
||
BgThree(bool),
|
||
BgRotation(String),
|
||
BgImage(String),
|
||
BgBrowse,
|
||
BgPosition(BgPosition),
|
||
BgAspect(bool),
|
||
BgApply,
|
||
}
|
||
|
||
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 220 px wide (sharp at preview size).
|
||
// 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 = (220.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()
|
||
}
|
||
|
||
/// Thumbnails of a file's pages (PDF), sheets (DWF) or models (DGN).
|
||
pub fn item_thumbs(kind: codec::entities::UnderlayType, path: &str) -> Vec<PageThumb> {
|
||
use crate::scene::model::underlay_vector;
|
||
if kind == codec::entities::UnderlayType::Pdf {
|
||
return page_thumbs(path);
|
||
}
|
||
underlay_vector::item_names(kind, path)
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.map(|label| {
|
||
let backdrop = underlay_vector::Backdrop {
|
||
max_channel: 0,
|
||
light: false,
|
||
};
|
||
let image = underlay_vector::display_raster(kind, path, &label, &[], 220.0, backdrop)
|
||
.map(|page| {
|
||
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;
|
||
}
|
||
}
|
||
|
||
// ── Shared look ────────────────────────────────────────────────────────────
|
||
//
|
||
// The PDF dialogs are built from rounded cards on a slightly raised surface,
|
||
// accent-coloured card titles, segmented choices and toggle chips, with the
|
||
// help button on the left of the footer and the named action on the right.
|
||
|
||
pub(crate) fn accent_text(theme: &Theme) -> text::Style {
|
||
text::Style {
|
||
color: Some(theme.palette().primary.base.color),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn card_style(theme: &Theme) -> container::Style {
|
||
container::Style {
|
||
background: Some(Background::Color(theme.palette().background.weak.color)),
|
||
border: Border {
|
||
width: 0.0,
|
||
radius: 8.0.into(),
|
||
color: iced::Color::TRANSPARENT,
|
||
},
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
pub(crate) fn well_style(theme: &Theme) -> container::Style {
|
||
container::Style {
|
||
background: Some(Background::Color(theme.palette().background.base.color)),
|
||
border: Border {
|
||
width: 0.0,
|
||
radius: 6.0.into(),
|
||
color: iced::Color::TRANSPARENT,
|
||
},
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// A titled card.
|
||
fn card<'a>(title: String, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||
container(
|
||
column![
|
||
text(title.to_uppercase()).size(10).style(accent_text),
|
||
content.into()
|
||
]
|
||
.spacing(8),
|
||
)
|
||
.padding([10, 12])
|
||
.width(Fill)
|
||
.style(card_style)
|
||
.into()
|
||
}
|
||
|
||
/// One choice out of a few, as joined buttons.
|
||
fn segmented<'a, T: Copy + PartialEq + 'a>(
|
||
options: Vec<(T, String)>,
|
||
current: T,
|
||
on: fn(T) -> PdfDialogMsg,
|
||
) -> Element<'a, Message> {
|
||
let buttons = options.into_iter().map(|(value, label)| {
|
||
button(text(label).size(11).width(Fill).align_x(iced::Center))
|
||
.on_press(msg(on(value)))
|
||
.style(button_style(value == current))
|
||
.padding([5, 6])
|
||
.width(Fill)
|
||
.into()
|
||
});
|
||
container(iced::widget::Row::with_children(buttons.collect::<Vec<_>>()).spacing(2))
|
||
.padding(2)
|
||
.width(Fill)
|
||
.style(well_style)
|
||
.into()
|
||
}
|
||
|
||
/// A switch shown as a chip: filled with a tick while on.
|
||
fn chip<'a>(label: String, on: bool, message: Message) -> Element<'a, Message> {
|
||
// The tick is an SVG: the web build's font has no check-mark glyph.
|
||
let mut content = row![].spacing(4).align_y(iced::Center);
|
||
if on {
|
||
content = content.push(
|
||
iced::widget::svg(crate::ui::icons::themed_handle(crate::ui::icons::CHECK))
|
||
.width(11.0)
|
||
.height(11.0)
|
||
.style(|theme: &Theme, _| iced::widget::svg::Style {
|
||
color: Some(theme.palette().primary.base.text),
|
||
}),
|
||
);
|
||
}
|
||
button(content.push(text(label).size(11)))
|
||
.on_press(message)
|
||
.style(button_style(on))
|
||
.padding([4, 10])
|
||
.into()
|
||
}
|
||
|
||
/// A labelled field on one row; read-only while `enabled` is false.
|
||
fn field<'a>(
|
||
label: String,
|
||
value: &'a str,
|
||
enabled: bool,
|
||
label_width: f32,
|
||
on_input: impl Fn(String) -> Message + 'a,
|
||
) -> Element<'a, Message> {
|
||
let mut input = text_input("", value)
|
||
.size(11)
|
||
.padding([4, 8])
|
||
.width(Fill)
|
||
.style(field_style);
|
||
if enabled {
|
||
input = input.on_input(on_input);
|
||
}
|
||
row![
|
||
text(label)
|
||
.size(11)
|
||
.style(muted_style)
|
||
.width(Length::Fixed(label_width)),
|
||
input
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center)
|
||
.into()
|
||
}
|
||
|
||
/// A label and a value on one line; a long value keeps its end.
|
||
fn info_line<'a>(label: String, value: &'a str) -> Element<'a, Message> {
|
||
const MAX: usize = 70;
|
||
let count = value.chars().count();
|
||
let shown = if count > MAX {
|
||
format!(
|
||
"…{}",
|
||
value.chars().skip(count - (MAX - 1)).collect::<String>()
|
||
)
|
||
} else {
|
||
value.to_string()
|
||
};
|
||
row![
|
||
text(label)
|
||
.size(11)
|
||
.style(muted_style)
|
||
.width(Length::Fixed(96.0)),
|
||
text(shown)
|
||
.size(11)
|
||
.width(Fill)
|
||
.wrapping(iced::advanced::text::Wrapping::None),
|
||
]
|
||
.spacing(6)
|
||
.into()
|
||
}
|
||
|
||
/// Help on the left, extra buttons, then Cancel and the named action.
|
||
fn footer<'a>(
|
||
help: &'static str,
|
||
extra: Option<Element<'a, Message>>,
|
||
action: std::borrow::Cow<'static, str>,
|
||
ok: PdfDialogMsg,
|
||
) -> Element<'a, Message> {
|
||
let mut left = row![button(text("?").size(12))
|
||
.on_press(msg(PdfDialogMsg::Help(help)))
|
||
.style(button_style(false))
|
||
.padding([5, 11])]
|
||
.spacing(6)
|
||
.align_y(iced::Center);
|
||
if let Some(extra) = extra {
|
||
left = left.push(extra);
|
||
}
|
||
row![
|
||
left,
|
||
Space::new().width(Fill),
|
||
dialog_button(t!("Cancel"), Message::CloseModal, false),
|
||
dialog_button(action, msg(ok), true),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center)
|
||
.into()
|
||
}
|
||
|
||
/// Page thumbnails as cards; the chosen ones framed in the accent colour
|
||
/// with a ticked page number.
|
||
fn page_tiles<'a>(
|
||
pages: &'a [PageThumb],
|
||
selected: &[usize],
|
||
on_click: fn(usize) -> PdfDialogMsg,
|
||
) -> 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 tick: Element<'a, Message> = if chosen {
|
||
crate::ui::icons::themed_primary(crate::ui::icons::CHECK, 11.0)
|
||
} else {
|
||
Space::new().width(0.0).into()
|
||
};
|
||
let tile = column![
|
||
container(picture)
|
||
.width(Length::Fixed(132.0))
|
||
.height(Length::Fixed(86.0))
|
||
.padding(4)
|
||
.style(move |theme: &Theme| container::Style {
|
||
background: Some(Background::Color(iced::Color::WHITE)),
|
||
border: Border {
|
||
width: if chosen { 2.5 } else { 0.0 },
|
||
radius: 6.0.into(),
|
||
color: theme.palette().primary.base.color,
|
||
},
|
||
..Default::default()
|
||
}),
|
||
container(
|
||
row![
|
||
tick,
|
||
text(page.label.clone())
|
||
.size(11)
|
||
.style(move |theme: &Theme| text::Style {
|
||
color: Some(if chosen {
|
||
theme.palette().primary.base.color
|
||
} else {
|
||
theme.palette().background.base.text.scale_alpha(0.68)
|
||
}),
|
||
})
|
||
]
|
||
.spacing(4)
|
||
.align_y(iced::Center),
|
||
)
|
||
.width(Length::Fixed(132.0))
|
||
.align_x(iced::Center),
|
||
]
|
||
.spacing(4);
|
||
button(tile)
|
||
.on_press(msg(on_click(index)))
|
||
.padding(4)
|
||
.style(|_: &Theme, _| button::Style::default())
|
||
.into()
|
||
});
|
||
scrollable(
|
||
iced::widget::Row::with_children(tiles.collect::<Vec<_>>())
|
||
.spacing(6)
|
||
.wrap()
|
||
.vertical_spacing(6),
|
||
)
|
||
.height(Fill)
|
||
.into()
|
||
}
|
||
|
||
// ── 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 {
|
||
/// PDF pages, DWF sheets or DGN models.
|
||
pub kind: codec::entities::UnderlayType,
|
||
/// DGN: sub units chosen (the scale then turns them into master units).
|
||
pub sub_units: bool,
|
||
/// DGN: sub units per master unit of the chosen model.
|
||
pub sub_per_master: f64,
|
||
/// 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 {
|
||
kind: codec::entities::UnderlayType::Pdf,
|
||
sub_units: false,
|
||
sub_per_master: 1.0,
|
||
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> {
|
||
// File: the attached PDFs to pick from, Browse, and the page count.
|
||
let name_combo = combo_box(
|
||
&state.name_combo,
|
||
"",
|
||
(!state.name.is_empty()).then_some(&state.name),
|
||
|chosen: String| msg(PdfDialogMsg::AttachName(chosen)),
|
||
)
|
||
.size(12)
|
||
.padding([5, 8])
|
||
.width(Fill);
|
||
use codec::entities::UnderlayType;
|
||
let count_text = match state.kind {
|
||
UnderlayType::Pdf => crate::tf!("{count} pages", count = state.pages.len()),
|
||
UnderlayType::Dwf => crate::tf!("{count} sheets", count = state.pages.len()),
|
||
UnderlayType::Dgn => crate::tf!("{count} models", count = state.pages.len()),
|
||
};
|
||
let count = container(text(count_text).size(11).style(accent_text))
|
||
.padding([4, 10])
|
||
.style(well_style);
|
||
let file = row![
|
||
name_combo,
|
||
count,
|
||
button(text(t!("Browse...")).size(11))
|
||
.on_press(msg(PdfDialogMsg::AttachBrowse))
|
||
.style(button_style(false))
|
||
.padding([5, 12]),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center);
|
||
|
||
// Pages: the thumbnails, then which ones are chosen.
|
||
let chosen = state.page_labels().join(", ");
|
||
let (items_title, chosen_text, size_label) = match state.kind {
|
||
UnderlayType::Pdf => (
|
||
t!("Pages"),
|
||
crate::tf!("Selected pages: {pages}", pages = chosen),
|
||
t!("Page size:"),
|
||
),
|
||
UnderlayType::Dwf => (
|
||
t!("Sheets"),
|
||
crate::tf!("Selected sheet: {sheet}", sheet = chosen),
|
||
t!("Sheet size:"),
|
||
),
|
||
UnderlayType::Dgn => (
|
||
t!("Models"),
|
||
crate::tf!("Selected model: {model}", model = chosen),
|
||
t!("Model size:"),
|
||
),
|
||
};
|
||
let pages = card(
|
||
items_title.into_owned(),
|
||
column![
|
||
container(page_tiles(
|
||
&state.pages,
|
||
&state.selected,
|
||
PdfDialogMsg::AttachPage
|
||
))
|
||
.padding(6)
|
||
.height(Length::Fixed(226.0))
|
||
.width(Fill)
|
||
.style(well_style),
|
||
text(chosen_text).size(11).style(muted_style),
|
||
]
|
||
.spacing(6),
|
||
);
|
||
|
||
// Placement: insertion point, scale and rotation, each with an
|
||
// "On screen" chip that leaves the value to the command line.
|
||
let on_screen = t!("On screen").into_owned();
|
||
let insert_enabled = !state.insert_on_screen;
|
||
let xyz = row![
|
||
field("X".into(), &state.insert[0], insert_enabled, 12.0, |v| msg(
|
||
PdfDialogMsg::AttachInsert(0, v)
|
||
)),
|
||
field("Y".into(), &state.insert[1], insert_enabled, 12.0, |v| msg(
|
||
PdfDialogMsg::AttachInsert(1, v)
|
||
)),
|
||
field("Z".into(), &state.insert[2], insert_enabled, 12.0, |v| msg(
|
||
PdfDialogMsg::AttachInsert(2, v)
|
||
)),
|
||
]
|
||
.spacing(8);
|
||
let heading = |label: std::borrow::Cow<'static, str>, on: bool, message: Message| {
|
||
row![
|
||
text(label).size(11).width(Fill),
|
||
chip(on_screen.clone(), on, message)
|
||
]
|
||
.align_y(iced::Center)
|
||
};
|
||
let placement = card(
|
||
t!("Placement").into_owned(),
|
||
column![
|
||
heading(
|
||
t!("Insertion point"),
|
||
state.insert_on_screen,
|
||
msg(PdfDialogMsg::AttachInsertOnScreen(!state.insert_on_screen))
|
||
),
|
||
xyz,
|
||
heading(
|
||
t!("Scale"),
|
||
state.scale_on_screen,
|
||
msg(PdfDialogMsg::AttachScaleOnScreen(!state.scale_on_screen))
|
||
),
|
||
field(
|
||
String::new(),
|
||
&state.scale,
|
||
!state.scale_on_screen,
|
||
0.0,
|
||
|v| msg(PdfDialogMsg::AttachScale(v))
|
||
),
|
||
heading(
|
||
t!("Rotation"),
|
||
state.rotation_on_screen,
|
||
msg(PdfDialogMsg::AttachRotationOnScreen(
|
||
!state.rotation_on_screen
|
||
))
|
||
),
|
||
field(
|
||
String::new(),
|
||
&state.rotation,
|
||
!state.rotation_on_screen,
|
||
0.0,
|
||
|v| msg(PdfDialogMsg::AttachRotation(v))
|
||
),
|
||
]
|
||
.spacing(6),
|
||
);
|
||
|
||
let path = card(
|
||
t!("Path type").into_owned(),
|
||
segmented(
|
||
PathTypeChoice::ALL
|
||
.iter()
|
||
.map(|c| (*c, c.to_string()))
|
||
.collect(),
|
||
state.path_type,
|
||
PdfDialogMsg::AttachPathType,
|
||
),
|
||
);
|
||
|
||
let details = card(
|
||
t!("File details").into_owned(),
|
||
column![
|
||
info_line(t!("Found in:").into_owned(), &state.found_in),
|
||
info_line(t!("Saved path:").into_owned(), &state.saved_path),
|
||
info_line(size_label.into_owned(), &state.page_size),
|
||
]
|
||
.spacing(4),
|
||
);
|
||
|
||
let mut right = column![placement, path]
|
||
.spacing(8)
|
||
.width(Length::FillPortion(2));
|
||
// A DGN model is attached in its master or its sub units.
|
||
if state.kind == UnderlayType::Dgn {
|
||
right = right.push(card(
|
||
t!("Conversion units").into_owned(),
|
||
column![
|
||
segmented(
|
||
vec![
|
||
(false, t!("Master units").into_owned()),
|
||
(true, t!("Sub units").into_owned())
|
||
],
|
||
state.sub_units,
|
||
PdfDialogMsg::AttachSubUnits,
|
||
),
|
||
text(crate::tf!(
|
||
"Sub units make the default scale {scale}",
|
||
scale = 1.0 / state.sub_per_master.max(1e-12)
|
||
))
|
||
.size(11)
|
||
.style(muted_style),
|
||
]
|
||
.spacing(6),
|
||
));
|
||
}
|
||
let body = row![
|
||
column![pages, details]
|
||
.spacing(8)
|
||
.width(Length::FillPortion(3)),
|
||
right,
|
||
]
|
||
.spacing(10);
|
||
|
||
column![
|
||
file,
|
||
body,
|
||
footer("attach", None, t!("Attach"), PdfDialogMsg::AttachOk)
|
||
]
|
||
.spacing(10)
|
||
.padding([10, 12])
|
||
.width(sizing.width)
|
||
.into()
|
||
}
|
||
|
||
// ── Attach Point Cloud ─────────────────────────────────────────────────────
|
||
|
||
/// The Attach Point Cloud dialog: the scan or project chosen, what it
|
||
/// holds, and how it is placed.
|
||
pub struct PointCloudAttachState {
|
||
/// The file read (absolute).
|
||
pub path: String,
|
||
pub name: String,
|
||
pub preview: Option<image::Handle>,
|
||
/// "1 scan · 365281 points".
|
||
pub summary: String,
|
||
/// Which data the scans carry (colour, intensity, normals; then
|
||
/// classification and segmentation).
|
||
pub data: String,
|
||
pub data_more: String,
|
||
/// Width × length × height.
|
||
pub size: String,
|
||
pub unit: String,
|
||
pub found_in: String,
|
||
pub saved_path: String,
|
||
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 lock: bool,
|
||
pub zoom: bool,
|
||
}
|
||
|
||
/// A switch that cannot be used here: dimmed, not pressable.
|
||
fn off_chip<'a>(label: String) -> Element<'a, Message> {
|
||
container(text(label).size(11).style(muted_style))
|
||
.padding([4, 10])
|
||
.into()
|
||
}
|
||
|
||
pub fn view_point_cloud_attach<'a>(
|
||
state: &'a PointCloudAttachState,
|
||
sizing: crate::ui::modal::ModalSizing,
|
||
) -> Element<'a, Message> {
|
||
let file = row![
|
||
text_input("", &state.name)
|
||
.size(12)
|
||
.padding([5, 8])
|
||
.width(Fill)
|
||
.style(field_style),
|
||
container(text(state.summary.clone()).size(11).style(accent_text))
|
||
.padding([4, 10])
|
||
.style(well_style),
|
||
button(text(t!("Browse...")).size(11))
|
||
.on_press(msg(PdfDialogMsg::CloudBrowse))
|
||
.style(button_style(false))
|
||
.padding([5, 12]),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center);
|
||
|
||
let picture: Element<'a, Message> = match &state.preview {
|
||
Some(handle) => image(handle.clone()).width(Fill).height(Fill).into(),
|
||
None => Space::new().width(Fill).height(Fill).into(),
|
||
};
|
||
let preview = card(
|
||
t!("Preview").into_owned(),
|
||
container(picture)
|
||
.padding(6)
|
||
.height(Length::Fixed(226.0))
|
||
.width(Fill)
|
||
.style(well_style),
|
||
);
|
||
|
||
let details = card(
|
||
t!("File details").into_owned(),
|
||
column![
|
||
info_line(t!("Found in:").into_owned(), &state.found_in),
|
||
info_line(t!("Saved path:").into_owned(), &state.saved_path),
|
||
info_line(t!("Data:").into_owned(), &state.data),
|
||
info_line(String::new(), &state.data_more),
|
||
info_line(t!("Size:").into_owned(), &state.size),
|
||
info_line(t!("Unit:").into_owned(), &state.unit),
|
||
]
|
||
.spacing(4),
|
||
);
|
||
|
||
let on_screen = t!("On screen").into_owned();
|
||
let heading = |label: std::borrow::Cow<'static, str>, on: bool, message: Message| {
|
||
row![
|
||
text(label).size(11).width(Fill),
|
||
chip(on_screen.clone(), on, message)
|
||
]
|
||
.align_y(iced::Center)
|
||
};
|
||
let insert_enabled = !state.insert_on_screen;
|
||
let xyz = row![
|
||
field("X".into(), &state.insert[0], insert_enabled, 12.0, |v| msg(
|
||
PdfDialogMsg::CloudInsert(0, v)
|
||
)),
|
||
field("Y".into(), &state.insert[1], insert_enabled, 12.0, |v| msg(
|
||
PdfDialogMsg::CloudInsert(1, v)
|
||
)),
|
||
field("Z".into(), &state.insert[2], insert_enabled, 12.0, |v| msg(
|
||
PdfDialogMsg::CloudInsert(2, v)
|
||
)),
|
||
]
|
||
.spacing(8);
|
||
let placement = card(
|
||
t!("Placement").into_owned(),
|
||
column![
|
||
heading(
|
||
t!("Insertion point"),
|
||
state.insert_on_screen,
|
||
msg(PdfDialogMsg::CloudInsertOnScreen(!state.insert_on_screen))
|
||
),
|
||
xyz,
|
||
heading(
|
||
t!("Scale"),
|
||
state.scale_on_screen,
|
||
msg(PdfDialogMsg::CloudScaleOnScreen(!state.scale_on_screen))
|
||
),
|
||
field(
|
||
String::new(),
|
||
&state.scale,
|
||
!state.scale_on_screen,
|
||
0.0,
|
||
|v| msg(PdfDialogMsg::CloudScale(v))
|
||
),
|
||
heading(
|
||
t!("Rotation"),
|
||
state.rotation_on_screen,
|
||
msg(PdfDialogMsg::CloudRotationOnScreen(
|
||
!state.rotation_on_screen
|
||
))
|
||
),
|
||
field(
|
||
String::new(),
|
||
&state.rotation,
|
||
!state.rotation_on_screen,
|
||
0.0,
|
||
|v| msg(PdfDialogMsg::CloudRotation(v))
|
||
),
|
||
]
|
||
.spacing(6),
|
||
);
|
||
let path = card(
|
||
t!("Path type").into_owned(),
|
||
segmented(
|
||
PathTypeChoice::ALL
|
||
.iter()
|
||
.map(|c| (*c, c.to_string()))
|
||
.collect(),
|
||
state.path_type,
|
||
PdfDialogMsg::CloudPathType,
|
||
),
|
||
);
|
||
let options = card(
|
||
t!("Options").into_owned(),
|
||
column![
|
||
off_chip(t!("Use geographic location").into_owned()),
|
||
chip(
|
||
t!("Lock point cloud").into_owned(),
|
||
state.lock,
|
||
msg(PdfDialogMsg::CloudLock(!state.lock))
|
||
),
|
||
chip(
|
||
t!("Zoom to point cloud").into_owned(),
|
||
state.zoom,
|
||
msg(PdfDialogMsg::CloudZoom(!state.zoom))
|
||
),
|
||
]
|
||
.spacing(6),
|
||
);
|
||
|
||
let body = row![
|
||
column![preview, details]
|
||
.spacing(8)
|
||
.width(Length::FillPortion(3)),
|
||
column![placement, path, options]
|
||
.spacing(8)
|
||
.width(Length::FillPortion(2)),
|
||
]
|
||
.spacing(10);
|
||
column![
|
||
file,
|
||
body,
|
||
footer("pointcloud", None, t!("Attach"), PdfDialogMsg::CloudOk)
|
||
]
|
||
.spacing(10)
|
||
.padding([10, 12])
|
||
.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: codec::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 underlay = pick_list(target.map(|t| t.name.clone()), names, |n: &String| {
|
||
n.clone()
|
||
})
|
||
.on_select(|n| msg(PdfDialogMsg::LayersUnderlay(n)))
|
||
.text_size(12)
|
||
.padding([5, 8])
|
||
.width(Fill);
|
||
let search = text_input(t!("Search for layer").as_ref(), &state.search)
|
||
.on_input(|s| msg(PdfDialogMsg::LayersSearch(s)))
|
||
.size(11)
|
||
.padding([5, 8])
|
||
.width(Fill)
|
||
.style(field_style);
|
||
|
||
let (total, hidden) = target.map_or((0, 0), |t| (t.layers.len(), t.hidden.len()));
|
||
let summary = text(crate::tf!(
|
||
"{count} layers, {hidden} hidden",
|
||
count = total,
|
||
hidden = hidden
|
||
))
|
||
.size(11)
|
||
.style(muted_style);
|
||
|
||
// Each layer: its name and an On / Off switch.
|
||
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 switch = button(
|
||
text(if on { t!("On") } else { t!("Off") })
|
||
.size(11)
|
||
.width(Fill)
|
||
.align_x(iced::Center),
|
||
)
|
||
.on_press(msg(PdfDialogMsg::LayersToggle(name.clone())))
|
||
.style(button_style(on))
|
||
.padding([3, 6])
|
||
.width(Length::Fixed(64.0));
|
||
container(
|
||
row![text(name.clone()).size(12).width(Fill), switch].align_y(iced::Center),
|
||
)
|
||
.padding([5, 10])
|
||
.style(well_style)
|
||
.into()
|
||
});
|
||
scrollable(iced::widget::Column::with_children(rows.collect::<Vec<_>>()).spacing(4))
|
||
.height(Fill)
|
||
.into()
|
||
}
|
||
_ => container(
|
||
text(t!("This file does not contain any layers"))
|
||
.size(11)
|
||
.style(muted_style),
|
||
)
|
||
.width(Fill)
|
||
.height(Fill)
|
||
.align_x(iced::Center)
|
||
.align_y(iced::Center)
|
||
.into(),
|
||
};
|
||
|
||
column![
|
||
card(t!("Underlay").into_owned(), underlay),
|
||
card(
|
||
t!("Layers").into_owned(),
|
||
column![
|
||
search,
|
||
summary,
|
||
container(body).height(Length::Fixed(250.0))
|
||
]
|
||
.spacing(6)
|
||
),
|
||
footer("layers", None, t!("Apply"), PdfDialogMsg::LayersOk),
|
||
]
|
||
.spacing(10)
|
||
.padding([10, 12])
|
||
.width(sizing.width)
|
||
.into()
|
||
}
|
||
|
||
// ── PDF Import Settings ────────────────────────────────────────────────────
|
||
|
||
/// Content as chips, layers as a segmented choice, options as chips.
|
||
fn settings_cards<'a>(
|
||
s: &'a PdfImportSettings,
|
||
) -> (
|
||
Element<'a, Message>,
|
||
Element<'a, Message>,
|
||
Element<'a, Message>,
|
||
) {
|
||
let fills = if s.vector {
|
||
chip(
|
||
t!("Solid fills").into_owned(),
|
||
s.fills,
|
||
msg(PdfDialogMsg::Fills(!s.fills)),
|
||
)
|
||
} else {
|
||
chip(t!("Solid fills").into_owned(), s.fills, Message::Noop)
|
||
};
|
||
let content = card(
|
||
t!("PDF data to import").into_owned(),
|
||
row![
|
||
chip(
|
||
t!("Vector geometry").into_owned(),
|
||
s.vector,
|
||
msg(PdfDialogMsg::Vector(!s.vector))
|
||
),
|
||
fills,
|
||
chip(
|
||
t!("TrueType text").into_owned(),
|
||
s.text,
|
||
msg(PdfDialogMsg::Text(!s.text))
|
||
),
|
||
chip(
|
||
t!("Raster images").into_owned(),
|
||
s.raster,
|
||
msg(PdfDialogMsg::Raster(!s.raster))
|
||
),
|
||
]
|
||
.spacing(6)
|
||
.wrap()
|
||
.vertical_spacing(6),
|
||
);
|
||
let layers = card(
|
||
t!("Layers").into_owned(),
|
||
segmented(
|
||
vec![
|
||
(ImportLayers::Pdf, t!("Use PDF layers").into_owned()),
|
||
(
|
||
ImportLayers::Object,
|
||
t!("Create object layers").into_owned(),
|
||
),
|
||
(ImportLayers::Current, t!("Current layer").into_owned()),
|
||
],
|
||
s.layers,
|
||
PdfDialogMsg::Layers,
|
||
),
|
||
);
|
||
let options = card(
|
||
t!("Import options").into_owned(),
|
||
row![
|
||
chip(
|
||
t!("Import as block").into_owned(),
|
||
s.as_block,
|
||
msg(PdfDialogMsg::AsBlock(!s.as_block))
|
||
),
|
||
chip(
|
||
t!("Join line and arc segments").into_owned(),
|
||
s.join,
|
||
msg(PdfDialogMsg::Join(!s.join))
|
||
),
|
||
chip(
|
||
t!("Convert solid fills to hatches").into_owned(),
|
||
s.hatches,
|
||
msg(PdfDialogMsg::Hatches(!s.hatches))
|
||
),
|
||
chip(
|
||
t!("Apply lineweight properties").into_owned(),
|
||
s.lineweights,
|
||
msg(PdfDialogMsg::Lineweights(!s.lineweights))
|
||
),
|
||
chip(
|
||
t!("Infer linetypes from collinear dashes").into_owned(),
|
||
s.linetypes,
|
||
msg(PdfDialogMsg::Linetypes(!s.linetypes))
|
||
),
|
||
]
|
||
.spacing(6)
|
||
.wrap()
|
||
.vertical_spacing(6),
|
||
);
|
||
(content, layers, options)
|
||
}
|
||
|
||
pub fn view_import_settings<'a>(
|
||
settings: &'a PdfImportSettings,
|
||
sizing: crate::ui::modal::ModalSizing,
|
||
) -> Element<'a, Message> {
|
||
let (content, layers, options) = settings_cards(settings);
|
||
let more = button(text(t!("Options...")).size(11))
|
||
.on_press(msg(PdfDialogMsg::Options))
|
||
.style(button_style(false))
|
||
.padding([5, 12]);
|
||
column![
|
||
content,
|
||
layers,
|
||
options,
|
||
footer(
|
||
"settings",
|
||
Some(more.into()),
|
||
t!("Save"),
|
||
PdfDialogMsg::SettingsOk
|
||
),
|
||
]
|
||
.spacing(10)
|
||
.padding([10, 12])
|
||
.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![
|
||
container(text(file_name).size(12))
|
||
.padding([6, 10])
|
||
.width(Fill)
|
||
.style(well_style),
|
||
button(text(t!("Browse...")).size(11))
|
||
.on_press(msg(PdfDialogMsg::ImportBrowse))
|
||
.style(button_style(false))
|
||
.padding([5, 12]),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center);
|
||
|
||
// One page at a time: a large preview with previous / next.
|
||
let count = state.pages.len();
|
||
let preview: Element<'a, Message> = match state
|
||
.pages
|
||
.get(state.selected)
|
||
.and_then(|p| p.image.clone())
|
||
{
|
||
Some(handle) => image(handle).width(Fill).height(Fill).into(),
|
||
None => Space::new().width(Fill).height(Fill).into(),
|
||
};
|
||
let step = |icon: Element<'a, Message>, to: Option<usize>| {
|
||
button(icon)
|
||
.on_press_maybe(to.map(|p| msg(PdfDialogMsg::ImportPage(p))))
|
||
.style(button_style(false))
|
||
.padding([2, 12])
|
||
};
|
||
let navigator = row![
|
||
step(
|
||
crate::ui::icons::themed_arrow_left(12.0),
|
||
state.selected.checked_sub(1)
|
||
),
|
||
Space::new().width(Fill),
|
||
text_input("", &state.page_text)
|
||
.on_input(|s| msg(PdfDialogMsg::ImportPageText(s)))
|
||
.size(11)
|
||
.padding([3, 6])
|
||
.width(Length::Fixed(48.0))
|
||
.style(field_style),
|
||
text(format!("/ {count}")).size(11).style(muted_style),
|
||
Space::new().width(Fill),
|
||
step(
|
||
crate::ui::icons::themed_arrow_right(12.0),
|
||
(state.selected + 1 < count).then_some(state.selected + 1),
|
||
),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center);
|
||
let page = card(
|
||
t!("Pages").into_owned(),
|
||
column![
|
||
container(preview)
|
||
.padding(8)
|
||
.height(Length::Fixed(300.0))
|
||
.width(Fill)
|
||
.style(|_: &Theme| container::Style {
|
||
background: Some(Background::Color(iced::Color::WHITE)),
|
||
border: Border {
|
||
width: 0.0,
|
||
radius: 6.0.into(),
|
||
color: iced::Color::TRANSPARENT
|
||
},
|
||
..Default::default()
|
||
}),
|
||
navigator,
|
||
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(8),
|
||
);
|
||
|
||
let placement = card(
|
||
t!("Placement").into_owned(),
|
||
column![
|
||
row![
|
||
text(t!("Insertion point")).size(11).width(Fill),
|
||
chip(
|
||
t!("On screen").into_owned(),
|
||
state.insert_on_screen,
|
||
msg(PdfDialogMsg::ImportInsertOnScreen(!state.insert_on_screen))
|
||
),
|
||
]
|
||
.align_y(iced::Center),
|
||
field(t!("Scale").into_owned(), &state.scale, true, 60.0, |s| msg(
|
||
PdfDialogMsg::ImportScale(s)
|
||
)),
|
||
row![
|
||
text(t!("Rotation"))
|
||
.size(11)
|
||
.style(muted_style)
|
||
.width(Length::Fixed(60.0)),
|
||
segmented(
|
||
RotationChoice::ALL
|
||
.iter()
|
||
.map(|r| (*r, format!("{}\u{b0}", r.0)))
|
||
.collect(),
|
||
state.rotation,
|
||
PdfDialogMsg::ImportRotation,
|
||
),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center),
|
||
]
|
||
.spacing(8),
|
||
);
|
||
let (content, layers, options) = settings_cards(&state.settings);
|
||
let right = scrollable(column![placement, content, layers, options].spacing(8)).height(Fill);
|
||
let body = row![
|
||
container(page).width(Length::FillPortion(1)),
|
||
container(right).width(Length::FillPortion(1)),
|
||
]
|
||
.spacing(10)
|
||
.height(Length::Fixed(420.0));
|
||
|
||
let more = button(text(t!("Options...")).size(11))
|
||
.on_press(msg(PdfDialogMsg::Options))
|
||
.style(button_style(false))
|
||
.padding([5, 12]);
|
||
column![
|
||
file,
|
||
body,
|
||
footer(
|
||
"import",
|
||
Some(more.into()),
|
||
t!("Import"),
|
||
PdfDialogMsg::ImportOk
|
||
)
|
||
]
|
||
.spacing(10)
|
||
.padding([10, 12])
|
||
.width(sizing.width)
|
||
.into()
|
||
}
|
||
|
||
// ── Point Cloud Color Map ──────────────────────────────────────────────────
|
||
|
||
/// A colour scheme as the dialog edits it: identifier, name and colours
|
||
/// (high end first).
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct MapRamp {
|
||
pub id: String,
|
||
pub name: String,
|
||
pub colors: Vec<[u8; 3]>,
|
||
}
|
||
|
||
/// What a scheme does with values outside its range.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct OutOfRange(pub i16);
|
||
|
||
impl OutOfRange {
|
||
pub const ALL: [OutOfRange; 3] = [OutOfRange(0), OutOfRange(1), OutOfRange(2)];
|
||
}
|
||
|
||
impl fmt::Display for OutOfRange {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
let label = match self.0 {
|
||
0 => t!("Use min/max colors"),
|
||
2 => t!("Hide points"),
|
||
_ => t!("Use RGB scan colors"),
|
||
};
|
||
f.write_str(label.as_ref())
|
||
}
|
||
}
|
||
|
||
/// One tab's settings: its scheme and range.
|
||
#[derive(Debug, Clone)]
|
||
pub struct MapSlot {
|
||
pub scheme: String,
|
||
pub gradient: bool,
|
||
pub max: String,
|
||
pub min: String,
|
||
pub out_of_range: OutOfRange,
|
||
}
|
||
|
||
/// The Point Cloud Color Map dialog: the drawing's colour schemes and, for
|
||
/// a chosen cloud, its intensity and elevation colouring.
|
||
pub struct PointCloudColorMapState {
|
||
/// The cloud edited; `None` edits the drawing's default schemes.
|
||
pub cloud: Option<codec::Handle>,
|
||
/// Elevation tab (else intensity).
|
||
pub elevation_tab: bool,
|
||
pub ramps: Vec<MapRamp>,
|
||
pub intensity: MapSlot,
|
||
pub elevation: MapSlot,
|
||
/// Elevation over the cloud's own height (else the fixed range).
|
||
pub extents: bool,
|
||
/// The cloud's height range.
|
||
pub height: (f64, f64),
|
||
pub make_current: bool,
|
||
/// The height of one band (elevation), as shown and typed.
|
||
pub interval: String,
|
||
/// A name being typed for a new scheme (`true`) or a rename.
|
||
pub naming: Option<(bool, String)>,
|
||
}
|
||
|
||
impl PointCloudColorMapState {
|
||
pub fn slot(&self) -> &MapSlot {
|
||
if self.elevation_tab {
|
||
&self.elevation
|
||
} else {
|
||
&self.intensity
|
||
}
|
||
}
|
||
|
||
pub fn slot_mut(&mut self) -> &mut MapSlot {
|
||
if self.elevation_tab {
|
||
&mut self.elevation
|
||
} else {
|
||
&mut self.intensity
|
||
}
|
||
}
|
||
|
||
pub fn ramp(&self) -> Option<&MapRamp> {
|
||
let id = &self.slot().scheme;
|
||
self.ramps
|
||
.iter()
|
||
.find(|r| r.id.eq_ignore_ascii_case(id))
|
||
.or(self.ramps.first())
|
||
}
|
||
|
||
pub fn ramp_mut(&mut self) -> Option<&mut MapRamp> {
|
||
let id = self.slot().scheme.clone();
|
||
let at = self
|
||
.ramps
|
||
.iter()
|
||
.position(|r| r.id.eq_ignore_ascii_case(&id))
|
||
.unwrap_or(0);
|
||
self.ramps.get_mut(at)
|
||
}
|
||
|
||
/// The range the ramp spans on the current tab.
|
||
pub fn range(&self) -> Option<(f64, f64)> {
|
||
if self.elevation_tab && self.extents {
|
||
return Some(self.height);
|
||
}
|
||
let slot = self.slot();
|
||
Some((slot.min.trim().parse().ok()?, slot.max.trim().parse().ok()?))
|
||
}
|
||
}
|
||
|
||
/// `t` (0 at the high end) along colours, blended; channels are cut to
|
||
/// whole values, as the reference stores resampled schemes.
|
||
pub fn blend(colors: &[[u8; 3]], t: f64) -> [u8; 3] {
|
||
if colors.len() < 2 {
|
||
return colors.first().copied().unwrap_or([255; 3]);
|
||
}
|
||
let x = t.clamp(0.0, 1.0) * (colors.len() - 1) as f64;
|
||
let k = (x as usize).min(colors.len() - 2);
|
||
let f = x - k as f64;
|
||
let (a, b) = (colors[k], colors[k + 1]);
|
||
[0, 1, 2].map(|c| (f64::from(a[c]) + (f64::from(b[c]) - f64::from(a[c])) * f) as u8)
|
||
}
|
||
|
||
/// A number as the dialog labels it: at most two decimals.
|
||
pub fn short(value: f64) -> String {
|
||
let value = if value.abs() < 0.005 { 0.0 } else { value };
|
||
let text = format!("{:.2}", value);
|
||
text.trim_end_matches('0').trim_end_matches('.').to_string()
|
||
}
|
||
|
||
fn swatch<'a>(color: [u8; 3], width: f32, height: f32) -> Element<'a, Message> {
|
||
let color = iced::Color::from_rgb8(color[0], color[1], color[2]);
|
||
container(Space::new().width(width).height(height))
|
||
.style(move |_: &Theme| container::Style {
|
||
background: Some(Background::Color(color)),
|
||
border: Border {
|
||
width: 1.0,
|
||
radius: 3.0.into(),
|
||
color: iced::Color::from_rgb8(0x77, 0x77, 0x77),
|
||
},
|
||
..Default::default()
|
||
})
|
||
.into()
|
||
}
|
||
|
||
/// The ramp as a bar, high end on top, blended or in bands, with the values
|
||
/// where the bands meet (blended: the ends and the inner band centres).
|
||
fn ramp_bar<'a>(
|
||
colors: &[[u8; 3]],
|
||
gradient: bool,
|
||
range: Option<(f64, f64)>,
|
||
percent: bool,
|
||
) -> Element<'a, Message> {
|
||
const HEIGHT: f32 = 360.0;
|
||
let n = colors.len().max(1);
|
||
let strips: Vec<Element<'a, Message>> = if gradient {
|
||
(0..72)
|
||
.map(|k| {
|
||
let c = blend(colors, (k as f64 + 0.5) / 72.0);
|
||
let color = iced::Color::from_rgb8(c[0], c[1], c[2]);
|
||
container(Space::new().width(Fill).height(Fill))
|
||
.height(Length::FillPortion(1))
|
||
.style(move |_: &Theme| container::Style {
|
||
background: Some(Background::Color(color)),
|
||
..Default::default()
|
||
})
|
||
.into()
|
||
})
|
||
.collect()
|
||
} else {
|
||
colors
|
||
.iter()
|
||
.map(|c| {
|
||
let color = iced::Color::from_rgb8(c[0], c[1], c[2]);
|
||
container(Space::new().width(Fill).height(Fill))
|
||
.height(Length::FillPortion(1))
|
||
.style(move |_: &Theme| container::Style {
|
||
background: Some(Background::Color(color)),
|
||
..Default::default()
|
||
})
|
||
.into()
|
||
})
|
||
.collect()
|
||
};
|
||
let bar = container(iced::widget::Column::with_children(strips))
|
||
.width(Length::Fixed(56.0))
|
||
.height(Length::Fixed(HEIGHT));
|
||
// Label positions from the top (0) to the bottom (1).
|
||
let marks: Vec<f64> = if gradient {
|
||
let mut marks = vec![0.0];
|
||
marks.extend((1..n.saturating_sub(1)).map(|k| (k as f64 + 0.5) / n as f64));
|
||
marks.push(1.0);
|
||
marks
|
||
} else {
|
||
(0..=n).map(|k| k as f64 / n as f64).collect()
|
||
};
|
||
let mut labels = column![];
|
||
if let Some((low, high)) = range {
|
||
// Each label is centred on its mark, kept inside the bar.
|
||
let mut used = 0.0;
|
||
for mark in marks {
|
||
let value = high - (high - low) * mark;
|
||
let label = if percent {
|
||
format!("{}%", value.round())
|
||
} else {
|
||
short(value)
|
||
};
|
||
let top = (mark * f64::from(HEIGHT) - 7.0).clamp(0.0, f64::from(HEIGHT) - 14.0);
|
||
labels = labels
|
||
.push(Space::new().height((top - used).max(0.0) as f32))
|
||
.push(text(label).size(10).height(14.0));
|
||
used = top.max(used) + 14.0;
|
||
}
|
||
}
|
||
row![bar, labels.width(Length::Fixed(44.0))]
|
||
.spacing(4)
|
||
.into()
|
||
}
|
||
|
||
pub fn view_point_cloud_color_map<'a>(
|
||
state: &'a PointCloudColorMapState,
|
||
sizing: crate::ui::modal::ModalSizing,
|
||
) -> Element<'a, Message> {
|
||
let tab = |label: std::borrow::Cow<'static, str>,
|
||
on: bool,
|
||
message: Option<Message>|
|
||
-> Element<'a, Message> {
|
||
let mut b = button(text(label).size(11).width(Fill).align_x(iced::Center))
|
||
.style(button_style(on))
|
||
.padding([5, 6])
|
||
.width(Length::Fixed(118.0));
|
||
if let Some(message) = message {
|
||
b = b.on_press(message);
|
||
}
|
||
b.into()
|
||
};
|
||
let tabs = container(
|
||
row![
|
||
tab(
|
||
t!("Intensity"),
|
||
!state.elevation_tab,
|
||
Some(msg(PdfDialogMsg::MapTab(false)))
|
||
),
|
||
tab(
|
||
t!("Elevation"),
|
||
state.elevation_tab,
|
||
Some(msg(PdfDialogMsg::MapTab(true)))
|
||
),
|
||
tab(t!("Classification"), false, None),
|
||
]
|
||
.spacing(2),
|
||
)
|
||
.padding(2)
|
||
.style(well_style);
|
||
|
||
let slot = state.slot();
|
||
let ramp = state.ramp();
|
||
let colors: Vec<[u8; 3]> = ramp.map(|r| r.colors.clone()).unwrap_or_default();
|
||
let ramp_card = card(
|
||
t!("Color ramp").into_owned(),
|
||
ramp_bar(
|
||
&colors,
|
||
slot.gradient,
|
||
state.cloud.and(state.range()),
|
||
!state.elevation_tab,
|
||
),
|
||
);
|
||
|
||
// Listed by name, as the reference lists them.
|
||
let mut names: Vec<String> = state.ramps.iter().map(|r| r.name.clone()).collect();
|
||
names.sort_by_key(|n| n.to_lowercase());
|
||
let scheme = pick_list(ramp.map(|r| r.name.clone()), names, |n: &String| n.clone())
|
||
.on_select(|n| msg(PdfDialogMsg::MapScheme(n)))
|
||
.text_size(12)
|
||
.padding([5, 8])
|
||
.width(Fill);
|
||
let small = |label: std::borrow::Cow<'static, str>, message: PdfDialogMsg| {
|
||
button(text(label).size(11).width(Fill).align_x(iced::Center))
|
||
.on_press(msg(message))
|
||
.style(button_style(false))
|
||
.padding([5, 8])
|
||
.width(Length::Fixed(118.0))
|
||
};
|
||
let counts: Vec<usize> = (2..=25).collect();
|
||
let count = pick_list(Some(colors.len().max(2)), counts, |n: &usize| n.to_string())
|
||
.on_select(|n| msg(PdfDialogMsg::MapCount(n)))
|
||
.text_size(12)
|
||
.padding([4, 8])
|
||
.width(Length::Fixed(70.0));
|
||
// Blend: a strip from the first colour to the last.
|
||
let ends = [
|
||
colors.first().copied().unwrap_or([255; 3]),
|
||
colors.last().copied().unwrap_or([255; 3]),
|
||
];
|
||
let blend_strip = row((0..6).map(|k| swatch(blend(&ends, k as f64 / 5.0), 3.0, 14.0)));
|
||
let even = button(blend_strip)
|
||
.on_press(msg(PdfDialogMsg::MapEven))
|
||
.style(button_style(false))
|
||
.padding([5, 6]);
|
||
let reverse = button(
|
||
iced::widget::svg(crate::ui::icons::themed_handle(include_bytes!(
|
||
"../../../assets/icons/modify_reverse.svg"
|
||
)))
|
||
.width(16.0)
|
||
.height(16.0),
|
||
)
|
||
.on_press(msg(PdfDialogMsg::MapReverse))
|
||
.style(button_style(false))
|
||
.padding([4, 6]);
|
||
let mut scheme_rows = column![
|
||
row![scheme, small(t!("New"), PdfDialogMsg::MapNew)]
|
||
.spacing(8)
|
||
.align_y(iced::Center),
|
||
row![
|
||
text(t!("Number of colors"))
|
||
.size(11)
|
||
.width(Length::Fixed(110.0)),
|
||
count,
|
||
even,
|
||
reverse,
|
||
Space::new().width(Fill),
|
||
small(t!("Delete"), PdfDialogMsg::MapDelete)
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center),
|
||
row![
|
||
chip(
|
||
t!("Display as gradient").into_owned(),
|
||
slot.gradient,
|
||
msg(PdfDialogMsg::MapGradient(!slot.gradient))
|
||
),
|
||
Space::new().width(Fill),
|
||
small(t!("Rename"), PdfDialogMsg::MapRename)
|
||
]
|
||
.align_y(iced::Center),
|
||
]
|
||
.spacing(8);
|
||
if let Some((_, name)) = &state.naming {
|
||
scheme_rows = scheme_rows.push(
|
||
row![
|
||
text(t!("Scheme name"))
|
||
.size(11)
|
||
.style(muted_style)
|
||
.width(Length::Fixed(110.0)),
|
||
text_input("", name)
|
||
.on_input(|v| msg(PdfDialogMsg::MapNameInput(v)))
|
||
.on_submit(msg(PdfDialogMsg::MapNameOk))
|
||
.size(11)
|
||
.padding([4, 8])
|
||
.width(Fill)
|
||
.style(field_style),
|
||
dialog_button(t!("Cancel"), msg(PdfDialogMsg::MapNameCancel), false),
|
||
dialog_button(t!("OK"), msg(PdfDialogMsg::MapNameOk), true),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center),
|
||
);
|
||
}
|
||
let scheme_card = card(t!("Color scheme").into_owned(), scheme_rows);
|
||
|
||
let has_cloud = state.cloud.is_some();
|
||
let top = colors.first().copied().unwrap_or([255; 3]);
|
||
let bottom = colors.last().copied().unwrap_or([255; 3]);
|
||
let editable = has_cloud && !(state.elevation_tab && state.extents);
|
||
let with_swatch = |field: Element<'a, Message>, color: [u8; 3]| -> Element<'a, Message> {
|
||
row![field, swatch(color, 22.0, 22.0)]
|
||
.spacing(8)
|
||
.align_y(iced::Center)
|
||
.into()
|
||
};
|
||
let out_of_range = {
|
||
let list = pick_list(
|
||
Some(slot.out_of_range),
|
||
OutOfRange::ALL.to_vec(),
|
||
|o: &OutOfRange| o.to_string(),
|
||
)
|
||
.text_size(12)
|
||
.padding([4, 8])
|
||
.width(Fill);
|
||
let list = if editable {
|
||
list.on_select(|o| msg(PdfDialogMsg::MapOutOfRange(o)))
|
||
} else {
|
||
list
|
||
};
|
||
row![
|
||
text(t!("Out of range points"))
|
||
.size(11)
|
||
.style(muted_style)
|
||
.width(Length::Fixed(150.0)),
|
||
list
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center)
|
||
};
|
||
let range_rows = if state.elevation_tab {
|
||
let (low, high) = state.height;
|
||
let interval = state.interval.as_str();
|
||
column![
|
||
chip(
|
||
format!(
|
||
"{} ({} ~ {})",
|
||
t!("Apply to extents of point cloud"),
|
||
short(low),
|
||
short(high)
|
||
),
|
||
state.extents,
|
||
msg(PdfDialogMsg::MapExtents(!state.extents))
|
||
),
|
||
field(
|
||
t!("Interval height").into_owned(),
|
||
interval,
|
||
editable,
|
||
150.0,
|
||
|v| msg(PdfDialogMsg::MapInterval(v))
|
||
),
|
||
with_swatch(
|
||
field(
|
||
t!("Maximum elevation").into_owned(),
|
||
&slot.max,
|
||
editable,
|
||
150.0,
|
||
|v| msg(PdfDialogMsg::MapMax(v))
|
||
),
|
||
top
|
||
),
|
||
with_swatch(
|
||
field(
|
||
t!("Minimum elevation").into_owned(),
|
||
&slot.min,
|
||
editable,
|
||
150.0,
|
||
|v| msg(PdfDialogMsg::MapMin(v))
|
||
),
|
||
bottom
|
||
),
|
||
out_of_range,
|
||
]
|
||
.spacing(8)
|
||
} else {
|
||
column![
|
||
with_swatch(
|
||
field(
|
||
t!("Maximum intensity").into_owned(),
|
||
&slot.max,
|
||
editable,
|
||
150.0,
|
||
|v| msg(PdfDialogMsg::MapMax(v))
|
||
),
|
||
top
|
||
),
|
||
with_swatch(
|
||
field(
|
||
t!("Minimum intensity").into_owned(),
|
||
&slot.min,
|
||
editable,
|
||
150.0,
|
||
|v| msg(PdfDialogMsg::MapMin(v))
|
||
),
|
||
bottom
|
||
),
|
||
out_of_range,
|
||
]
|
||
.spacing(8)
|
||
};
|
||
let range_card = card(t!("Range of colorized points").into_owned(), range_rows);
|
||
|
||
let body = row![
|
||
container(ramp_card).width(Length::Fixed(150.0)),
|
||
column![scheme_card, range_card].spacing(8).width(Fill),
|
||
]
|
||
.spacing(10);
|
||
|
||
let mut left = row![button(text("?").size(12))
|
||
.on_press(msg(PdfDialogMsg::Help("pointcloudcolormap")))
|
||
.style(button_style(false))
|
||
.padding([5, 11])]
|
||
.spacing(8)
|
||
.align_y(iced::Center);
|
||
if has_cloud {
|
||
left = left.push(chip(
|
||
t!("Make stylization current").into_owned(),
|
||
state.make_current,
|
||
msg(PdfDialogMsg::MapCurrent(!state.make_current)),
|
||
));
|
||
}
|
||
let footer = row![
|
||
left,
|
||
Space::new().width(Fill),
|
||
dialog_button(t!("Cancel"), Message::CloseModal, false),
|
||
dialog_button(t!("Apply"), msg(PdfDialogMsg::MapApply), false),
|
||
dialog_button(t!("OK"), msg(PdfDialogMsg::MapOk), true),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center);
|
||
column![tabs, body, footer]
|
||
.spacing(10)
|
||
.padding([10, 12])
|
||
.width(sizing.width)
|
||
.into()
|
||
}
|
||
|
||
// ── Extract Section Lines from Point Cloud ─────────────────────────────────
|
||
|
||
/// A colour the extracted lines can take (ACI; 256 ByLayer, 0 ByBlock).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct LineColor(pub i16);
|
||
|
||
impl LineColor {
|
||
pub const ALL: [LineColor; 9] = [
|
||
LineColor(256),
|
||
LineColor(0),
|
||
LineColor(1),
|
||
LineColor(2),
|
||
LineColor(3),
|
||
LineColor(4),
|
||
LineColor(5),
|
||
LineColor(6),
|
||
LineColor(7),
|
||
];
|
||
}
|
||
|
||
impl fmt::Display for LineColor {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
let label = match self.0 {
|
||
256 => t!("ByLayer"),
|
||
0 => t!("ByBlock"),
|
||
1 => t!("Red"),
|
||
2 => t!("Yellow"),
|
||
3 => t!("Green"),
|
||
4 => t!("Cyan"),
|
||
5 => t!("Blue"),
|
||
6 => t!("Magenta"),
|
||
_ => t!("White"),
|
||
};
|
||
f.write_str(label.as_ref())
|
||
}
|
||
}
|
||
|
||
/// The dialog's fields; numbers as typed.
|
||
pub struct PcSectionState {
|
||
pub perimeter: bool,
|
||
pub max_points: String,
|
||
/// "Use Current" first, then the drawing's layers.
|
||
pub layers: Vec<String>,
|
||
pub layer: String,
|
||
pub color: LineColor,
|
||
pub polylines: bool,
|
||
pub width: String,
|
||
pub min_length: String,
|
||
pub connect: String,
|
||
pub angle: String,
|
||
pub preview: bool,
|
||
}
|
||
|
||
pub fn view_pc_section<'a>(
|
||
state: &'a PcSectionState,
|
||
sizing: crate::ui::modal::ModalSizing,
|
||
) -> Element<'a, Message> {
|
||
let extract = card(
|
||
t!("Extract").into_owned(),
|
||
segmented(
|
||
vec![
|
||
(false, t!("Entire cross section").into_owned()),
|
||
(true, t!("Perimeter only").into_owned()),
|
||
],
|
||
state.perimeter,
|
||
PdfDialogMsg::SecPerimeter,
|
||
),
|
||
);
|
||
let points = state
|
||
.max_points
|
||
.trim()
|
||
.parse::<f32>()
|
||
.unwrap_or(18_000.0)
|
||
.clamp(1_000.0, 200_000.0);
|
||
// ponytail: the time shown scales with the points (18000 ≈ 1 minute, as
|
||
// the reference shows it); the real rate depends on the machine.
|
||
let minutes = (points / 18_000.0).ceil().max(1.0) as u32;
|
||
let max_points = card(
|
||
t!("Maximum points to process").into_owned(),
|
||
column![
|
||
row![
|
||
iced::widget::slider(1_000.0..=200_000.0, points, |v| msg(
|
||
PdfDialogMsg::SecMaxPoints(((v / 1_000.0).round() * 1_000.0).to_string())
|
||
))
|
||
.step(1_000.0)
|
||
.width(Fill),
|
||
text_input("", &state.max_points)
|
||
.on_input(|v| msg(PdfDialogMsg::SecMaxPoints(v)))
|
||
.size(11)
|
||
.padding([4, 8])
|
||
.width(Length::Fixed(90.0))
|
||
.style(field_style),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center),
|
||
row![
|
||
text(t!("Faster")).size(10).style(muted_style),
|
||
Space::new().width(Fill),
|
||
text(t!("More accurate")).size(10).style(muted_style),
|
||
Space::new().width(Fill),
|
||
text(crate::tf!(
|
||
"Estimated time: {count} minutes",
|
||
count = minutes
|
||
))
|
||
.size(10)
|
||
.style(muted_style),
|
||
],
|
||
]
|
||
.spacing(6),
|
||
);
|
||
let layer = pick_list(
|
||
Some(state.layer.clone()),
|
||
state.layers.clone(),
|
||
|n: &String| n.clone(),
|
||
)
|
||
.on_select(|n| msg(PdfDialogMsg::SecLayer(n)))
|
||
.text_size(12)
|
||
.padding([4, 8])
|
||
.width(Fill);
|
||
let color = pick_list(
|
||
Some(state.color),
|
||
LineColor::ALL.to_vec(),
|
||
|c: &LineColor| c.to_string(),
|
||
)
|
||
.on_select(|c| msg(PdfDialogMsg::SecColor(c)))
|
||
.text_size(12)
|
||
.padding([4, 8])
|
||
.width(Fill);
|
||
let labelled = |label: std::borrow::Cow<'static, str>,
|
||
control: Element<'a, Message>|
|
||
-> Element<'a, Message> {
|
||
row![
|
||
text(label)
|
||
.size(11)
|
||
.style(muted_style)
|
||
.width(Length::Fixed(70.0)),
|
||
control
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center)
|
||
.into()
|
||
};
|
||
let output = card(
|
||
t!("Output geometry").into_owned(),
|
||
column![
|
||
labelled(t!("Layer"), layer.into()),
|
||
labelled(t!("Color"), color.into()),
|
||
segmented(
|
||
vec![
|
||
(false, t!("Lines").into_owned()),
|
||
(true, t!("2D Polylines").into_owned())
|
||
],
|
||
state.polylines,
|
||
PdfDialogMsg::SecPolylines,
|
||
),
|
||
field(
|
||
t!("Polyline width").into_owned(),
|
||
&state.width,
|
||
state.polylines,
|
||
150.0,
|
||
|v| msg(PdfDialogMsg::SecWidth(v))
|
||
),
|
||
]
|
||
.spacing(8),
|
||
);
|
||
let pick = |connect: bool| {
|
||
button(text("⌖").size(12))
|
||
.on_press(msg(PdfDialogMsg::SecPick(connect)))
|
||
.style(button_style(false))
|
||
.padding([4, 8])
|
||
};
|
||
let tolerances = card(
|
||
t!("Extraction tolerances").into_owned(),
|
||
column![
|
||
row![
|
||
field(
|
||
t!("Minimum line length").into_owned(),
|
||
&state.min_length,
|
||
true,
|
||
170.0,
|
||
|v| msg(PdfDialogMsg::SecMinLength(v))
|
||
),
|
||
pick(false)
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center),
|
||
row![
|
||
field(
|
||
t!("Connect lines tolerance").into_owned(),
|
||
&state.connect,
|
||
true,
|
||
170.0,
|
||
|v| msg(PdfDialogMsg::SecConnect(v))
|
||
),
|
||
pick(true)
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center),
|
||
row![
|
||
field(
|
||
t!("Collinear angle tolerance").into_owned(),
|
||
&state.angle,
|
||
true,
|
||
170.0,
|
||
|v| msg(PdfDialogMsg::SecAngle(v))
|
||
),
|
||
text("0–10°").size(10).style(muted_style)
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center),
|
||
]
|
||
.spacing(8),
|
||
);
|
||
let body = column![
|
||
row![extract, max_points].spacing(10),
|
||
row![output, tolerances].spacing(10),
|
||
]
|
||
.spacing(10);
|
||
let footer = row![
|
||
button(text("?").size(12))
|
||
.on_press(msg(PdfDialogMsg::Help("pcsection")))
|
||
.style(button_style(false))
|
||
.padding([5, 11]),
|
||
chip(
|
||
t!("Preview result").into_owned(),
|
||
state.preview,
|
||
msg(PdfDialogMsg::SecPreview(!state.preview))
|
||
),
|
||
Space::new().width(Fill),
|
||
dialog_button(t!("Cancel"), Message::CloseModal, false),
|
||
dialog_button(t!("Create"), msg(PdfDialogMsg::SecCreate), true),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center);
|
||
column![body, footer]
|
||
.spacing(10)
|
||
.padding([10, 12])
|
||
.width(sizing.width)
|
||
.into()
|
||
}
|
||
|
||
// ── View background (-VIEW sEttings Background) ─────────────────────────
|
||
|
||
/// What a named view shows behind the drawing.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum BgKind {
|
||
None,
|
||
Solid,
|
||
Gradient,
|
||
Image,
|
||
}
|
||
|
||
/// Where an image background sits: centred, stretched over the view, or
|
||
/// repeated.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum BgPosition {
|
||
Center,
|
||
Stretch,
|
||
Tile,
|
||
}
|
||
|
||
impl BgPosition {
|
||
pub const ALL: [BgPosition; 3] = [BgPosition::Center, BgPosition::Stretch, BgPosition::Tile];
|
||
}
|
||
|
||
impl fmt::Display for BgPosition {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.write_str(&match self {
|
||
BgPosition::Center => crate::tr!("view-background", "center"),
|
||
BgPosition::Stretch => crate::tr!("view-background", "stretch"),
|
||
BgPosition::Tile => crate::tr!("view-background", "tile"),
|
||
})
|
||
}
|
||
}
|
||
|
||
/// How an image background is scaled: to the screen, or keeping its
|
||
/// aspect ratio.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct BgAspect(pub bool);
|
||
|
||
impl fmt::Display for BgAspect {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.write_str(&if self.0 {
|
||
crate::tr!("view-background", "aspect")
|
||
} else {
|
||
crate::tr!("view-background", "fit")
|
||
})
|
||
}
|
||
}
|
||
|
||
/// The Background dialog's fields for one named view.
|
||
pub struct ViewBackgroundState {
|
||
pub view: String,
|
||
pub kind: BgKind,
|
||
pub solid: [u8; 3],
|
||
pub top: [u8; 3],
|
||
pub middle: [u8; 3],
|
||
pub bottom: [u8; 3],
|
||
pub three: bool,
|
||
pub rotation: String,
|
||
pub image: String,
|
||
pub position: BgPosition,
|
||
pub aspect: bool,
|
||
/// The image's offset (pixels) and scale, kept from the view's image.
|
||
pub offset: [f64; 2],
|
||
pub scale: [f64; 2],
|
||
/// The drawing area colour, shown for no background.
|
||
pub canvas: [u8; 3],
|
||
}
|
||
|
||
pub fn view_background<'a>(
|
||
state: &'a ViewBackgroundState,
|
||
sizing: crate::ui::modal::ModalSizing,
|
||
) -> Element<'a, Message> {
|
||
let kind = segmented(
|
||
vec![
|
||
(BgKind::None, crate::tr!("view-background", "none")),
|
||
(BgKind::Solid, crate::tr!("view-background", "solid")),
|
||
(BgKind::Gradient, crate::tr!("view-background", "gradient")),
|
||
(BgKind::Image, crate::tr!("view-background", "image")),
|
||
],
|
||
state.kind,
|
||
PdfDialogMsg::BgKind,
|
||
);
|
||
let rgb = |c: [u8; 3]| iced::Color::from_rgb8(c[0], c[1], c[2]);
|
||
let fill = |background: Background| {
|
||
move |_: &Theme| container::Style {
|
||
background: Some(background),
|
||
border: Border {
|
||
width: 1.0,
|
||
radius: 4.0.into(),
|
||
color: iced::Color::from_rgb8(0x4a, 0x4a, 0x4a),
|
||
},
|
||
..Default::default()
|
||
}
|
||
};
|
||
let note = |label: String| text(label).size(11).style(muted_style);
|
||
let preview: Element<'a, Message> = match state.kind {
|
||
BgKind::None => container(note(crate::tr!("view-background", "canvas")))
|
||
.center(Fill)
|
||
.style(fill(Background::Color(rgb(state.canvas))))
|
||
.into(),
|
||
BgKind::Solid => container(Space::new())
|
||
.width(Fill)
|
||
.height(Fill)
|
||
.style(fill(Background::Color(rgb(state.solid))))
|
||
.into(),
|
||
BgKind::Gradient => {
|
||
// Bottom colour at the start, top at the end, turned
|
||
// counter-clockwise by the rotation.
|
||
let angle = state.rotation.trim().parse::<f32>().unwrap_or(0.0);
|
||
let mut linear = iced::gradient::Linear::new(iced::Radians(-angle.to_radians()))
|
||
.add_stop(0.0, rgb(state.bottom));
|
||
if state.three {
|
||
linear = linear.add_stop(0.5, rgb(state.middle));
|
||
}
|
||
linear = linear.add_stop(1.0, rgb(state.top));
|
||
container(Space::new())
|
||
.width(Fill)
|
||
.height(Fill)
|
||
.style(fill(Background::Gradient(iced::Gradient::Linear(linear))))
|
||
.into()
|
||
}
|
||
BgKind::Image => {
|
||
if std::path::Path::new(state.image.trim()).is_file() {
|
||
let fit = match (state.position, state.aspect) {
|
||
(BgPosition::Stretch, false) => iced::ContentFit::Fill,
|
||
(BgPosition::Stretch, true) => iced::ContentFit::Contain,
|
||
_ => iced::ContentFit::None,
|
||
};
|
||
container(
|
||
image(image::Handle::from_path(state.image.trim()))
|
||
.content_fit(fit)
|
||
.width(Fill)
|
||
.height(Fill),
|
||
)
|
||
.center(Fill)
|
||
.style(fill(Background::Color(rgb(state.canvas))))
|
||
.into()
|
||
} else {
|
||
container(note(crate::tr!("view-background", "no-image")))
|
||
.center(Fill)
|
||
.style(fill(Background::Color(iced::Color::from_rgb8(0x1a, 0x1a, 0x1a))))
|
||
.into()
|
||
}
|
||
}
|
||
};
|
||
let preview = card(
|
||
crate::tr!("view-background", "preview"),
|
||
container(preview).width(Fill).height(Length::Fixed(150.0)),
|
||
);
|
||
let label = |value: String, enabled: bool| {
|
||
text(value)
|
||
.size(11)
|
||
.style(move |theme: &Theme| {
|
||
if enabled {
|
||
muted_style(theme)
|
||
} else {
|
||
text::Style {
|
||
color: Some(theme.palette().background.strong.color),
|
||
}
|
||
}
|
||
})
|
||
.width(Length::Fixed(92.0))
|
||
};
|
||
let color_row = |name: String, slot: u8, color: [u8; 3], enabled: bool| -> Element<'a, Message> {
|
||
let mut pick = button(text(crate::tr!("view-background", "select")).size(11))
|
||
.style(button_style(false))
|
||
.padding([4, 10]);
|
||
if enabled {
|
||
pick = pick.on_press(msg(PdfDialogMsg::BgPickColor(slot)));
|
||
}
|
||
row![
|
||
label(name, enabled),
|
||
swatch(color, 64.0, 22.0),
|
||
text(format!("{},{},{}", color[0], color[1], color[2]))
|
||
.size(11)
|
||
.style(muted_style),
|
||
Space::new().width(Fill),
|
||
pick,
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center)
|
||
.into()
|
||
};
|
||
let options: Element<'a, Message> = match state.kind {
|
||
BgKind::None => card(
|
||
crate::tr!("view-background", "info"),
|
||
note(crate::tr!("view-background", "none-info")),
|
||
),
|
||
BgKind::Solid => card(
|
||
crate::tr!("view-background", "solid-color"),
|
||
color_row(crate::tr!("view-background", "color"), 0, state.solid, true),
|
||
),
|
||
BgKind::Gradient => card(
|
||
crate::tr!("view-background", "gradient"),
|
||
column![
|
||
row![
|
||
chip(
|
||
crate::tr!("view-background", "three-colors"),
|
||
state.three,
|
||
msg(PdfDialogMsg::BgThree(!state.three))
|
||
),
|
||
Space::new().width(Fill),
|
||
text(crate::tr!("view-background", "rotation"))
|
||
.size(11)
|
||
.style(muted_style),
|
||
text_input("", &state.rotation)
|
||
.on_input(|v| msg(PdfDialogMsg::BgRotation(v)))
|
||
.size(11)
|
||
.padding([4, 8])
|
||
.width(Length::Fixed(64.0))
|
||
.style(field_style),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center),
|
||
color_row(crate::tr!("view-background", "top"), 1, state.top, true),
|
||
color_row(crate::tr!("view-background", "middle"), 2, state.middle, state.three),
|
||
color_row(crate::tr!("view-background", "bottom"), 3, state.bottom, true),
|
||
]
|
||
.spacing(8),
|
||
),
|
||
BgKind::Image => card(
|
||
crate::tr!("view-background", "image"),
|
||
column![
|
||
row![
|
||
text_input("", &state.image)
|
||
.on_input(|v| msg(PdfDialogMsg::BgImage(v)))
|
||
.size(11)
|
||
.padding([4, 8])
|
||
.width(Fill)
|
||
.style(field_style),
|
||
button(text(crate::tr!("view-background", "browse")).size(11))
|
||
.on_press(msg(PdfDialogMsg::BgBrowse))
|
||
.style(button_style(false))
|
||
.padding([4, 10]),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center),
|
||
row![
|
||
label(crate::tr!("view-background", "position"), true),
|
||
pick_list(Some(state.position), BgPosition::ALL.to_vec(), |p: &BgPosition| {
|
||
p.to_string()
|
||
})
|
||
.on_select(|p| msg(PdfDialogMsg::BgPosition(p)))
|
||
.text_size(12)
|
||
.padding([4, 8])
|
||
.width(Fill),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center),
|
||
row![
|
||
label(crate::tr!("view-background", "scale"), true),
|
||
pick_list(
|
||
Some(BgAspect(state.aspect)),
|
||
vec![BgAspect(false), BgAspect(true)],
|
||
|a: &BgAspect| a.to_string()
|
||
)
|
||
.on_select(|a: BgAspect| msg(PdfDialogMsg::BgAspect(a.0)))
|
||
.text_size(12)
|
||
.padding([4, 8])
|
||
.width(Fill),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center),
|
||
]
|
||
.spacing(8),
|
||
),
|
||
};
|
||
column![
|
||
kind,
|
||
preview,
|
||
options,
|
||
footer(
|
||
"background",
|
||
None,
|
||
crate::tr!("view-background", "apply").into(),
|
||
PdfDialogMsg::BgApply
|
||
),
|
||
]
|
||
.spacing(10)
|
||
.padding([10, 12])
|
||
.width(sizing.width)
|
||
.into()
|
||
}
|