forked from 1b-agent/opencadstudio-upstream
Merge pull request #1553 from ramox81/codex/pointcloud-attach
Point clouds: drawn from their scans, attached, cropped and stylized. The fork's codec patch is resolved to the upstream pins: the point cloud records landed in opencadcodec 5ef9376, carried through opencadkernel 68eeda1 and opencadgraph e204966. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
3dcebf0dc6
81 files changed
+9162
-305
No files matched your search
@@ -46,6 +46,8 @@ pub enum PanelId {
|
||||
Browser,
|
||||
/// Node library of the node graph overlay.
|
||||
NodeGraph,
|
||||
/// Regions and scans of the drawing's point clouds.
|
||||
PointCloudManager,
|
||||
}
|
||||
|
||||
impl PanelId {
|
||||
@@ -57,6 +59,7 @@ impl PanelId {
|
||||
PanelId::ExternalReferences => "External References",
|
||||
PanelId::Browser => "Browser",
|
||||
PanelId::NodeGraph => "Node Graph",
|
||||
PanelId::PointCloudManager => "Point Cloud Manager",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +71,7 @@ impl PanelId {
|
||||
PanelId::ExternalReferences => 460.0,
|
||||
PanelId::Browser => 230.0,
|
||||
PanelId::NodeGraph => 220.0,
|
||||
PanelId::PointCloudManager => 280.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +161,7 @@ impl DockState {
|
||||
PanelId::ExternalReferences,
|
||||
PanelId::Browser,
|
||||
PanelId::NodeGraph,
|
||||
PanelId::PointCloudManager,
|
||||
] {
|
||||
self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id));
|
||||
}
|
||||
|
||||
+79
-1
@@ -966,6 +966,80 @@ struct SelectionCanvas {
|
||||
constraint_cursor_badge: Option<String>,
|
||||
}
|
||||
|
||||
/// The move gizmo: an arrow per axis (X red, Y green, Z blue) from the
|
||||
/// centre grip and a square per axis pair; the hovered or dragged part is
|
||||
/// yellow.
|
||||
fn draw_move_gizmo(frame: &mut canvas::Frame, grips: &[GripMarker]) {
|
||||
use crate::scene::pick::grip::{gizmo_plane_axes, GIZMO_AXIS_PX};
|
||||
const COLORS: [Color; 3] = [
|
||||
Color::from_rgb(0.90, 0.22, 0.20),
|
||||
Color::from_rgb(0.27, 0.70, 0.29),
|
||||
Color::from_rgb(0.18, 0.52, 0.93),
|
||||
];
|
||||
let active = Color::from_rgb(1.0, 0.84, 0.0);
|
||||
// Unit screen direction (y down) and tip of each shown arrow.
|
||||
let mut axes: [Option<(Point, iced::Vector)>; 3] = [None; 3];
|
||||
for grip in grips {
|
||||
if let (GripShape::GizmoAxis(k), Some([dx, dy])) = (grip.shape, grip.dir) {
|
||||
axes[k as usize % 3] = Some((grip.pos, iced::Vector::new(dx, -dy)));
|
||||
}
|
||||
}
|
||||
let Some(center) = axes
|
||||
.iter()
|
||||
.flatten()
|
||||
.next()
|
||||
.map(|(tip, d)| Point::new(tip.x - d.x * GIZMO_AXIS_PX, tip.y - d.y * GIZMO_AXIS_PX))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for grip in grips {
|
||||
let GripShape::GizmoPlane(k) = grip.shape else {
|
||||
continue;
|
||||
};
|
||||
let (a, b) = gizmo_plane_axes(k);
|
||||
let (Some((_, a)), Some((_, b))) = (axes[a as usize], axes[b as usize]) else {
|
||||
continue;
|
||||
};
|
||||
let corner = |u: f32, v: f32| {
|
||||
Point::new(
|
||||
center.x + (a.x * u + b.x * v) * GIZMO_AXIS_PX,
|
||||
center.y + (a.y * u + b.y * v) * GIZMO_AXIS_PX,
|
||||
)
|
||||
};
|
||||
let quad = canvas::Path::new(|p| {
|
||||
p.move_to(corner(0.18, 0.18));
|
||||
p.line_to(corner(0.42, 0.18));
|
||||
p.line_to(corner(0.42, 0.42));
|
||||
p.line_to(corner(0.18, 0.42));
|
||||
p.close();
|
||||
});
|
||||
let lit = grip.is_hovered || grip.is_hot;
|
||||
let color = if lit { active } else { Color::from_rgb(0.85, 0.85, 0.85) };
|
||||
frame.fill(&quad, color.scale_alpha(if lit { 0.55 } else { 0.25 }));
|
||||
frame.stroke(&quad, canvas::Stroke::default().with_width(1.0).with_color(color));
|
||||
}
|
||||
for grip in grips {
|
||||
let (GripShape::GizmoAxis(k), Some((tip, d))) =
|
||||
(grip.shape, grip.dir.map(|[dx, dy]| (grip.pos, iced::Vector::new(dx, -dy))))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let color = if grip.is_hovered || grip.is_hot { active } else { COLORS[k as usize % 3] };
|
||||
let base = Point::new(tip.x - d.x * 13.0, tip.y - d.y * 13.0);
|
||||
frame.stroke(
|
||||
&canvas::Path::line(center, base),
|
||||
canvas::Stroke::default().with_width(2.5).with_color(color),
|
||||
);
|
||||
let head = canvas::Path::new(|p| {
|
||||
p.move_to(tip);
|
||||
p.line_to(Point::new(base.x - d.y * 6.0, base.y + d.x * 6.0));
|
||||
p.line_to(Point::new(base.x + d.y * 6.0, base.y - d.x * 6.0));
|
||||
p.close();
|
||||
});
|
||||
frame.fill(&head, color);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_grip_marker(
|
||||
frame: &mut canvas::Frame,
|
||||
grip: &GripMarker,
|
||||
@@ -1019,6 +1093,7 @@ fn draw_grip_marker(
|
||||
b.close();
|
||||
}),
|
||||
GripShape::Circle => canvas::Path::circle(Point::new(sp.x, sp.y), h),
|
||||
GripShape::GizmoAxis(_) | GripShape::GizmoPlane(_) => return,
|
||||
GripShape::Dropdown | GripShape::DropdownAdjacent => canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - h, sp.y - h * 0.5));
|
||||
b.line_to(Point::new(sp.x + h, sp.y - h * 0.5));
|
||||
@@ -1393,8 +1468,11 @@ impl canvas::Program<Message> for SelectionCanvas {
|
||||
);
|
||||
}
|
||||
}
|
||||
draw_move_gizmo(frame, &self.grips);
|
||||
for grip in &self.grips {
|
||||
draw_grip_marker(frame, grip, theme, &self.selection_visual);
|
||||
if !matches!(grip.shape, GripShape::GizmoAxis(_) | GripShape::GizmoPlane(_)) {
|
||||
draw_grip_marker(frame, grip, theme, &self.selection_visual);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,6 +59,24 @@ pub fn pdf_underlay_tools(kind: codec::entities::UnderlayType) -> Vec<ToolDef> {
|
||||
tools
|
||||
}
|
||||
|
||||
/// Point cloud tools: the three crops, showing, inverting and removing
|
||||
/// them, and the palette.
|
||||
pub fn point_cloud_tools() -> Vec<ToolDef> {
|
||||
vec![
|
||||
tool("_PCCROPRECT", "Rectangular\nCrop", CLIP),
|
||||
tool("_PCCROPPOLY", "Polygonal\nCrop", CLIP),
|
||||
tool("_PCCROPCIRC", "Circular\nCrop", CLIP),
|
||||
tool(
|
||||
"_PCCROPSHOW",
|
||||
"Show\nCropping",
|
||||
include_bytes!("../../../assets/icons/underlay_frames.svg"),
|
||||
),
|
||||
tool("_PCCROPINVERT", "Invert\nCropping", CLIP),
|
||||
tool("_PCUNCROP", "Remove All\nCropping", UNCLIP),
|
||||
tool("EXTERNALREFERENCES", "External\nReferences", crate::ui::icons::FOLDER_OPEN),
|
||||
]
|
||||
}
|
||||
|
||||
/// External reference tools: edit, open, clipping and the palette.
|
||||
pub fn xref_tools() -> Vec<ToolDef> {
|
||||
vec![
|
||||
@@ -90,6 +108,15 @@ impl Ribbon {
|
||||
self.xref_ctx
|
||||
}
|
||||
|
||||
/// Only point clouds are selected: whether the first shows its crops.
|
||||
pub fn set_point_cloud_context(&mut self, context: Option<bool>) {
|
||||
self.point_cloud_ctx = context;
|
||||
}
|
||||
|
||||
pub fn point_cloud_context(&self) -> Option<bool> {
|
||||
self.point_cloud_ctx
|
||||
}
|
||||
|
||||
/// Select a tab by its module id.
|
||||
pub fn select_by_id(&mut self, id: &str) -> bool {
|
||||
match self.modules.iter().position(|m| m.id() == id) {
|
||||
|
||||
@@ -26,7 +26,7 @@ pub(crate) use draw_panel::tools as panel_tools;
|
||||
mod modify_panel;
|
||||
mod color_dropdown;
|
||||
mod context_tools;
|
||||
pub use context_tools::{pdf_underlay_tools, xref_tools, UnderlayContext};
|
||||
pub use context_tools::{pdf_underlay_tools, point_cloud_tools, xref_tools, UnderlayContext};
|
||||
use widgets::{StyleContext, *};
|
||||
pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID};
|
||||
mod collapse;
|
||||
@@ -109,6 +109,8 @@ pub struct Ribbon {
|
||||
underlay_ctx: Option<UnderlayContext>,
|
||||
/// Only xrefs are selected.
|
||||
xref_ctx: bool,
|
||||
/// Only point clouds are selected: whether the first shows its crops.
|
||||
point_cloud_ctx: Option<bool>,
|
||||
/// XDWGFADECTL as the Reference slide-out shows it (negative = off).
|
||||
pub xref_fade: i32,
|
||||
}
|
||||
@@ -206,6 +208,7 @@ impl Ribbon {
|
||||
collapse_tight: Arc::new(AtomicBool::new(false)),
|
||||
underlay_ctx: None,
|
||||
xref_ctx: false,
|
||||
point_cloud_ctx: None,
|
||||
xref_fade: 50,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod annotation_data;
|
||||
pub mod alias_editor;
|
||||
pub mod find_replace;
|
||||
pub mod named_parameters;
|
||||
pub mod pc_manager;
|
||||
pub mod pdf_dialogs;
|
||||
pub mod xref_attach;
|
||||
pub mod xref_help;
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
//! Point Cloud Manager (POINTCLOUDMANAGER): the regions and scans of the
|
||||
//! drawing's point clouds as a tree, each with a switch that shows or hides
|
||||
//! its points. The switches are the cloud's own hidden scans and regions, so
|
||||
//! the tree is read straight from the document on every view.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::scene::model::point_cloud::{self, UNASSIGNED_OFF};
|
||||
use crate::ui::dock::PanelId;
|
||||
use codec::entities::ExtendedEntityData;
|
||||
use codec::{CadDocument, EntityType, Handle};
|
||||
use iced::widget::{button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, tooltip, Space};
|
||||
use iced::{Background, Border, Element, Fill, Length, Theme};
|
||||
use std::collections::HashSet;
|
||||
|
||||
const CLOUD_ICON: &[u8] = include_bytes!("../../../assets/icons/pc_attach.svg");
|
||||
const REGION_ICON: &[u8] = include_bytes!("../../../assets/icons/region.svg");
|
||||
const UNASSIGNED_ICON: &[u8] = include_bytes!("../../../assets/icons/revcloud.svg");
|
||||
|
||||
/// Palette state that is not in the drawing: shown, folded nodes, the
|
||||
/// highlighted row and the search text.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PcManager {
|
||||
pub show: bool,
|
||||
pub collapsed: HashSet<String>,
|
||||
pub selected: Option<String>,
|
||||
pub search: String,
|
||||
}
|
||||
|
||||
/// A row with a switch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Row {
|
||||
Cloud,
|
||||
Unassigned,
|
||||
Scans,
|
||||
/// A scan, by identifier.
|
||||
Scan(String),
|
||||
}
|
||||
|
||||
/// A switch's state: a parent is mixed while some children are on.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Switch {
|
||||
On,
|
||||
Off,
|
||||
Mixed,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
/// `cloud`, `unassigned`, `scans` or `scan:<name or identifier>`
|
||||
/// (automation).
|
||||
pub fn parse(text: &str) -> Option<Self> {
|
||||
match text {
|
||||
"cloud" => Some(Self::Cloud),
|
||||
"unassigned" => Some(Self::Unassigned),
|
||||
"scans" => Some(Self::Scans),
|
||||
_ => text.strip_prefix("scan:").map(|name| Self::Scan(name.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PcManagerMsg {
|
||||
Toggle(Handle, Row),
|
||||
Expand(String),
|
||||
Select(String),
|
||||
Search(String),
|
||||
CollapseAll,
|
||||
ExpandAll,
|
||||
}
|
||||
|
||||
/// One cloud of the tree.
|
||||
pub struct Cloud {
|
||||
pub handle: Handle,
|
||||
pub name: String,
|
||||
pub hidden: Vec<String>,
|
||||
/// Each scan's name and identifier.
|
||||
pub scans: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// The drawing's point clouds; only the selected ones when any is selected.
|
||||
pub fn clouds(document: &CadDocument, selected: &[Handle]) -> Vec<Cloud> {
|
||||
let all: Vec<Cloud> = document
|
||||
.entities()
|
||||
.filter_map(|entity| {
|
||||
let EntityType::Extended(extended) = entity else {
|
||||
return None;
|
||||
};
|
||||
let ExtendedEntityData::PointCloudEx(data) = &extended.data else {
|
||||
return None;
|
||||
};
|
||||
let common = entity.common();
|
||||
let scans = point_cloud::resolve_source(document, data)
|
||||
.and_then(|path| point_cloud::load(&path))
|
||||
.map(|cloud| cloud.scan_ranges.iter().map(|s| (s.name.clone(), s.id.clone())).collect())
|
||||
.unwrap_or_default();
|
||||
Some(Cloud {
|
||||
handle: common.handle,
|
||||
name: if data.name.is_empty() { format!("{:X}", common.handle.value()) } else { data.name.clone() },
|
||||
hidden: point_cloud::hidden(data),
|
||||
scans,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if all.iter().any(|cloud| selected.contains(&cloud.handle)) {
|
||||
all.into_iter().filter(|cloud| selected.contains(&cloud.handle)).collect()
|
||||
} else {
|
||||
all
|
||||
}
|
||||
}
|
||||
|
||||
/// A row's switch. A parent is on with all its children on, off with all
|
||||
/// off, mixed otherwise (the reference shows the cloud mixed with its only
|
||||
/// scan off and its unassigned points on).
|
||||
pub fn switch_state(hidden: &[String], scans: &[(String, String)], row: &Row) -> Switch {
|
||||
let on = |id: &str| !hidden.iter().any(|h| h == id);
|
||||
let of = |states: &[bool]| match (states.iter().all(|s| *s), states.iter().any(|s| *s)) {
|
||||
(true, _) => Switch::On,
|
||||
(false, false) => Switch::Off,
|
||||
_ => Switch::Mixed,
|
||||
};
|
||||
let scan_states: Vec<bool> = scans.iter().map(|(_, id)| on(id)).collect();
|
||||
match row {
|
||||
Row::Scan(id) => of(&[on(id)]),
|
||||
Row::Scans => of(&scan_states),
|
||||
Row::Unassigned => of(&[on(UNASSIGNED_OFF)]),
|
||||
Row::Cloud => {
|
||||
let mut all = scan_states;
|
||||
all.push(on(UNASSIGNED_OFF));
|
||||
of(&all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The hidden list after clicking `row`: an on switch turns its rows off,
|
||||
/// an off or mixed one turns them all on.
|
||||
pub fn toggled(hidden: &[String], scans: &[(String, String)], row: &Row) -> Vec<String> {
|
||||
fn set(out: &mut Vec<String>, id: &str, off: bool) {
|
||||
out.retain(|h| h != id);
|
||||
if off {
|
||||
out.push(id.to_string());
|
||||
}
|
||||
}
|
||||
let off = switch_state(hidden, scans, row) == Switch::On;
|
||||
let mut out = hidden.to_vec();
|
||||
match row {
|
||||
Row::Cloud => {
|
||||
scans.iter().for_each(|(_, id)| set(&mut out, id, off));
|
||||
set(&mut out, UNASSIGNED_OFF, off);
|
||||
}
|
||||
Row::Unassigned => set(&mut out, UNASSIGNED_OFF, off),
|
||||
Row::Scans => scans.iter().for_each(|(_, id)| set(&mut out, id, off)),
|
||||
Row::Scan(id) => set(&mut out, id, off),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Keys of the rows that fold: each cloud and its Scans.
|
||||
pub fn folding_keys(clouds: &[Cloud]) -> Vec<String> {
|
||||
clouds.iter().flat_map(|c| [key(c.handle), format!("{}/scans", key(c.handle))]).collect()
|
||||
}
|
||||
|
||||
fn key(handle: Handle) -> String {
|
||||
format!("{:X}", handle.value())
|
||||
}
|
||||
|
||||
fn msg(m: PcManagerMsg) -> Message {
|
||||
Message::PcManager(m)
|
||||
}
|
||||
|
||||
/// One tree row: indent, fold arrow, icon, label and switch (`None` for a
|
||||
/// row that has none to give, drawn off and disabled).
|
||||
fn tree_row<'a>(
|
||||
state: &PcManager,
|
||||
key: String,
|
||||
depth: u16,
|
||||
fold: Option<bool>,
|
||||
icon: Element<'a, Message>,
|
||||
label: String,
|
||||
switch: Option<(Switch, Message)>,
|
||||
) -> Element<'a, Message> {
|
||||
let arrow: Element<'a, Message> = match fold {
|
||||
Some(open) => button(if open {
|
||||
crate::ui::icons::themed_arrow_down(10.0)
|
||||
} else {
|
||||
crate::ui::icons::themed_arrow_right(10.0)
|
||||
})
|
||||
.on_press(msg(PcManagerMsg::Expand(key.clone())))
|
||||
.style(button::text)
|
||||
.padding(2)
|
||||
.into(),
|
||||
None => Space::new().width(14).into(),
|
||||
};
|
||||
let enabled = switch.is_some();
|
||||
let label = text(label).size(12).width(Fill);
|
||||
let label = if enabled { label } else { label.style(crate::ui::style::common::muted_style) };
|
||||
let switch: Element<'a, Message> = match switch {
|
||||
// Mixed: a dash in the box, as the reference's indeterminate state.
|
||||
Some((Switch::Mixed, message)) => button(crate::ui::icons::themed_primary(crate::ui::icons::MINUS, 10.0))
|
||||
.on_press(message)
|
||||
.style(button::secondary)
|
||||
.padding(1)
|
||||
.width(Length::Fixed(16.0))
|
||||
.height(Length::Fixed(16.0))
|
||||
.into(),
|
||||
Some((on, message)) => checkbox(on == Switch::On).on_toggle(move |_| message.clone()).size(14).into(),
|
||||
None => checkbox(false).size(14).into(),
|
||||
};
|
||||
let selected = state.selected.as_deref() == Some(key.as_str());
|
||||
let cells = row![Space::new().width(Length::Fixed(f32::from(depth) * 16.0)), arrow, icon, label, switch]
|
||||
.spacing(5)
|
||||
.align_y(iced::Center);
|
||||
mouse_area(
|
||||
container(cells)
|
||||
.width(Fill)
|
||||
.padding([3, 6])
|
||||
.style(move |theme: &Theme| container::Style {
|
||||
background: selected.then(|| Background::Color(theme.palette().primary.weak.color)),
|
||||
text_color: selected.then(|| theme.palette().primary.weak.text),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.on_press(msg(PcManagerMsg::Select(key)))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn fold_button<'a>(icon: &'static [u8], tip: String, message: PcManagerMsg) -> Element<'a, Message> {
|
||||
let b = button(crate::ui::icons::themed_secondary(icon, 12.0))
|
||||
.on_press(msg(message))
|
||||
.style(button::subtle)
|
||||
.padding([2, 4]);
|
||||
tooltip(b, text(tip).size(10), tooltip::Position::Bottom).gap(4).into()
|
||||
}
|
||||
|
||||
pub fn view<'a>(
|
||||
state: &'a PcManager,
|
||||
document: &'a CadDocument,
|
||||
selected: &[Handle],
|
||||
width: f32,
|
||||
auto_collapse: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let title_bar =
|
||||
crate::ui::dock::title_bar(PanelId::PointCloudManager, crate::t!("Point Cloud Manager").into_owned(), auto_collapse);
|
||||
let query = state.search.trim().to_lowercase();
|
||||
let matches = |label: &str| query.is_empty() || label.to_lowercase().contains(&query);
|
||||
// Searching opens every node so the matches show.
|
||||
let open = |key: &str| !query.is_empty() || !state.collapsed.contains(key);
|
||||
let regions = crate::t!("Regions").into_owned();
|
||||
let unassigned = crate::t!("Unassigned Points").into_owned();
|
||||
let scans_label = crate::t!("Scans").into_owned();
|
||||
|
||||
let mut tree = column![].spacing(1);
|
||||
for cloud in clouds(document, selected) {
|
||||
let root = key(cloud.handle);
|
||||
let scans: Vec<&(String, String)> = cloud.scans.iter().filter(|(name, _)| matches(name)).collect();
|
||||
let show_scans = matches(&scans_label) || !scans.is_empty();
|
||||
let children = matches(®ions) || matches(&unassigned) || show_scans;
|
||||
if !matches(&cloud.name) && !children {
|
||||
continue;
|
||||
}
|
||||
let switch = |row: Row| {
|
||||
let on = switch_state(&cloud.hidden, &cloud.scans, &row);
|
||||
Some((on, msg(PcManagerMsg::Toggle(cloud.handle, row))))
|
||||
};
|
||||
let cloud_icon = || crate::ui::icons::semantic(CLOUD_ICON, 14.0);
|
||||
tree = tree.push(tree_row(
|
||||
state,
|
||||
root.clone(),
|
||||
0,
|
||||
Some(open(&root)),
|
||||
cloud_icon(),
|
||||
cloud.name.clone(),
|
||||
switch(Row::Cloud),
|
||||
));
|
||||
if !open(&root) {
|
||||
continue;
|
||||
}
|
||||
if matches(®ions) {
|
||||
// Region data is not read: the row is there, off and disabled.
|
||||
tree = tree.push(tree_row(
|
||||
state,
|
||||
format!("{root}/regions"),
|
||||
1,
|
||||
None,
|
||||
crate::ui::icons::semantic_disabled(REGION_ICON, 14.0),
|
||||
regions.clone(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
if matches(&unassigned) {
|
||||
tree = tree.push(tree_row(
|
||||
state,
|
||||
format!("{root}/unassigned"),
|
||||
1,
|
||||
None,
|
||||
crate::ui::icons::semantic(UNASSIGNED_ICON, 14.0),
|
||||
unassigned.clone(),
|
||||
switch(Row::Unassigned),
|
||||
));
|
||||
}
|
||||
if show_scans {
|
||||
let scans_key = format!("{root}/scans");
|
||||
let scans_open = open(&scans_key);
|
||||
tree = tree.push(tree_row(
|
||||
state,
|
||||
scans_key.clone(),
|
||||
1,
|
||||
Some(scans_open),
|
||||
cloud_icon(),
|
||||
scans_label.clone(),
|
||||
switch(Row::Scans),
|
||||
));
|
||||
if scans_open {
|
||||
for (name, id) in scans {
|
||||
tree = tree.push(tree_row(
|
||||
state,
|
||||
format!("{scans_key}/{id}"),
|
||||
2,
|
||||
None,
|
||||
cloud_icon(),
|
||||
name.clone(),
|
||||
switch(Row::Scan(id.clone())),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let heading = text(crate::t!("REGIONS AND SCANS").into_owned())
|
||||
.size(11)
|
||||
.font(iced::Font {
|
||||
weight: iced::font::Weight::Bold,
|
||||
style: iced::font::Style::Italic,
|
||||
..iced::Font::DEFAULT
|
||||
})
|
||||
.style(|theme: &Theme| text::Style { color: Some(theme.palette().primary.base.color) })
|
||||
.width(Fill);
|
||||
let header = row![
|
||||
heading,
|
||||
fold_button(crate::ui::icons::MINUS, crate::t!("Collapse all").into_owned(), PcManagerMsg::CollapseAll),
|
||||
fold_button(crate::ui::icons::PLUS, crate::t!("Expand all").into_owned(), PcManagerMsg::ExpandAll),
|
||||
]
|
||||
.spacing(2)
|
||||
.align_y(iced::Center);
|
||||
let card = container(column![header, scrollable(tree).height(Fill)].spacing(6))
|
||||
.padding(6)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(theme.palette().background.weak.color)),
|
||||
border: Border { radius: 4.0.into(), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let search = text_input(&crate::t!("Search"), &state.search)
|
||||
.on_input(|v| msg(PcManagerMsg::Search(v)))
|
||||
.padding([4, 8])
|
||||
.size(12);
|
||||
|
||||
crate::ui::dock::frame(column![title_bar, card, search].spacing(6), width)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 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.
|
||||
//! from cards, segmented choices and toggle chips; the point cloud Attach
|
||||
//! and Color Map dialogs share them.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
@@ -35,6 +36,54 @@ pub enum PdfDialogMsg {
|
||||
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),
|
||||
@@ -665,6 +714,140 @@ pub fn view_attach<'a>(
|
||||
.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
|
||||
@@ -962,3 +1145,528 @@ pub fn view_import_file<'a>(
|
||||
.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()
|
||||
}
|
||||
@@ -1103,6 +1103,11 @@ fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> {
|
||||
ObjectType::UnderlayDefinition(def) => {
|
||||
ids.insert((handle.value(), def.file_path.clone()));
|
||||
}
|
||||
ObjectType::ClassObject(object) => {
|
||||
if let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &object.data {
|
||||
ids.insert((handle.value(), def.source_filename.clone()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1193,6 +1198,14 @@ fn type_text(entry: &ReferenceEntry) -> std::borrow::Cow<'static, str> {
|
||||
_ => crate::t!("PDF"),
|
||||
}
|
||||
}
|
||||
// The file's own kind: RCP (project) or RCS (scan).
|
||||
RefKind::PointCloud => {
|
||||
let ext = std::path::Path::new(&entry.saved_path.replace('\\', "/"))
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_ascii_uppercase())
|
||||
.unwrap_or_default();
|
||||
if ext == "RCS" { "RCS".into() } else { "RCP".into() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2125,7 +2138,7 @@ fn reference_preview(entry: &ReferenceEntry) -> Option<image::RgbaImage> {
|
||||
let img = image::open(found).ok()?;
|
||||
Some(img.thumbnail(PREVIEW_MAX, PREVIEW_MAX).to_rgba8())
|
||||
}
|
||||
RefKind::Underlay => None,
|
||||
RefKind::Underlay | RefKind::PointCloud => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user