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:
Hakan SevenandClaude Opus 5.5 committed 2026-10-01 01:49:25 +03:00
commit 3dcebf0dc6
81 files changed
+9162 -305

No files matched your search

+5
View File
@@ -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
View File
@@ -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);
}
}
});
}
+27
View File
@@ -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) {
+4 -1
View File
@@ -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,
}
}
+1
View File
@@ -24,6 +24,7 @@ pub mod annotation_data;
pub mod alias_editor;
pub mod find_replace;
pub mod named_parameters;
pub mod pc_manager;
pub mod pdf_dialogs;
pub mod xref_attach;
pub mod xref_help;
+358
View File
@@ -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(&regions) || 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(&regions) {
// 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)
}
+709 -1
View File
@@ -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()
}
+14 -1
View File
@@ -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,
}
}