Point clouds: attach dialog, crop toolbar, stylize, palette rows

POINTCLOUDATTACH opens the Attach Point Cloud dialog after the file is
chosen; its Attach button lives in the Attach Underlay list instead of a
separate Insert panel. A selected cloud brings the crop tools to the
right-edge toolbar. POINTCLOUDSTYLIZE sets the stylization with the
reference's prompts, the External References palette lists point clouds
(unload, reload, detach, path), and definitions keep the header extents.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ramox81andClaude Opus 5.5 committed 2026-09-30 10:14:44 +03:00
1 parent 3afd547e28
commit c2d9ac5ae2
48 files changed
+1711 -83

No files matched your search

+186 -1
View File
@@ -6,7 +6,7 @@ use crate::io::xref_model::Pathtype;
use crate::modules::insert::pdf_import::{self, PdfFileImport};
use crate::ui::window::pdf_dialogs::{
click_page, item_thumbs, page_thumbs, LayerTarget, PdfAttachState, PdfDialogMsg, PdfImportFileState,
RotationChoice, UnderlayLayersState,
PointCloudAttachState, RotationChoice, UnderlayLayersState,
};
/// "7.8740 × 3.9369" (inches) for a page.
@@ -102,6 +102,139 @@ impl OpenCADStudio {
crate::entities::underlay::display_path(&self.pdf_stored_path(path, state.path_type.0));
}
/// POINTCLOUDATTACH after the file is chosen: the Attach Point Cloud
/// dialog, placed on screen by default.
pub(in crate::app) fn open_point_cloud_attach_dialog(&mut self, path: &std::path::Path) {
let Some(cloud) = crate::scene::model::point_cloud::load(path) else {
return;
};
let yes_no = |on: bool| if on { crate::t!("Yes") } else { crate::t!("No") }.into_owned();
let data = format!(
"RGB: {} · {}: {} · {}: {}",
yes_no(cloud.has_rgb),
crate::t!("Intensity"),
yes_no(cloud.has_intensity),
crate::t!("Normals"),
yes_no(cloud.has_normals),
);
let data_more = format!(
"{}: {} · {}: {}",
crate::t!("Classification"),
yes_no(false),
crate::t!("Segmentation"),
yes_no(false),
);
let factor = crate::app::properties::insert_unit_scale(self.tabs[self.active_tab].scene.document.header.insertion_units, 6)
.unwrap_or(1.0);
let full = path.to_string_lossy().into_owned();
let memory = crate::ui::window::pdf_dialogs::ATTACH_MEMORY.lock().ok().and_then(|m| m.clone()).unwrap_or_default();
let mut state = PointCloudAttachState {
path: full.clone(),
name: file_stem(&full),
preview: cloud.preview.clone().map(iced::widget::image::Handle::from_bytes),
summary: format!(
"{} · {}",
crate::tf!("{count} scans", count = cloud.scans),
crate::tf!("{count} points", count = cloud.positions.len())
),
data,
data_more,
size: cloud
.bounds
.map(|[lo, hi]| format!("{:.4} × {:.4} × {:.4}", hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]))
.unwrap_or_default(),
unit: format!("{} · {} {}", crate::t!("Meters"), crate::t!("Unit factor"), crate::app::properties::format_unit_factor(factor)),
found_in: crate::entities::underlay::display_path(&full),
saved_path: String::new(),
path_type: memory.path_type,
insert_on_screen: true,
insert: ["0.0000".into(), "0.0000".into(), "0.0000".into()],
scale_on_screen: true,
scale: "1.0000".into(),
rotation_on_screen: true,
rotation: "0".into(),
lock: false,
zoom: true,
};
state.saved_path = crate::entities::underlay::display_path(&self.pdf_stored_path(&full, state.path_type.0));
self.point_cloud_attach = Some(state);
self.active_modal = Some(crate::app::ModalKind::PointCloudAttach);
}
/// The dialog's Attach: what it set goes to the command, which asks for
/// the rest.
fn point_cloud_attach_ok(&mut self, i: usize) -> Task<Message> {
use crate::command::CadCommand;
let Some(state) = self.point_cloud_attach.as_ref() else {
return Task::none();
};
let number = |s: &str| s.trim().parse::<f64>().ok();
let insertion = if state.insert_on_screen {
None
} else {
match (number(&state.insert[0]), number(&state.insert[1]), number(&state.insert[2])) {
(Some(x), Some(y), Some(z)) => Some(glam::DVec3::new(x, y, z)),
_ => {
self.command_line.push_error("Requires numeric value.");
return Task::none();
}
}
};
let scale = if state.scale_on_screen {
None
} else {
match number(&state.scale).filter(|v| *v > 0.0) {
Some(v) => Some(v),
None => {
self.command_line.push_error("Value must be positive and nonzero.");
return Task::none();
}
}
};
let rotation = if state.rotation_on_screen {
None
} else {
match number(&state.rotation) {
Some(v) => Some(v),
None => {
self.command_line.push_error("Requires numeric value.");
return Task::none();
}
}
};
let state = self.point_cloud_attach.take().expect("checked above");
self.close_active_modal();
let options = crate::modules::insert::pc_attach::AttachOptions {
stored: (state.path_type.0 == Pathtype::None).then(|| {
std::path::Path::new(&state.path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}),
relative: state.path_type.0 == Pathtype::Relative,
locked: Some(state.lock),
zoom: state.zoom,
scale,
rotation,
};
let mut command = crate::modules::insert::pc_attach::PointCloudAttachCommand::from_dialog(
std::path::PathBuf::from(&state.path),
options,
);
match insertion {
Some(point) => {
let result = command.on_point(point);
self.tabs[i].active_cmd = Some(Box::new(command));
self.apply_cmd_result(result)
}
None => {
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
self.focus_cmd_input()
}
}
}
/// ULAYERS: every underlay of the drawing, the selected one first shown.
pub(in crate::app) fn open_underlay_layers_dialog(&mut self, i: usize) {
let document = &self.tabs[i].scene.document;
@@ -244,6 +377,7 @@ impl OpenCADStudio {
crate::t!("Attaches a DGN file as an underlay.")
}
"attach" => crate::t!("Attaches a PDF file as an underlay."),
"pointcloud" => crate::t!("Attaches a point cloud scan or project."),
"layers" => crate::t!("Turns the layers of a PDF underlay on or off."),
_ => crate::t!("Imports the geometry, fills, raster images and text of a PDF file as drawing objects."),
};
@@ -343,6 +477,57 @@ impl OpenCADStudio {
}
PdfDialogMsg::AttachOk => return self.pdf_attach_ok(i),
// ── Attach Point Cloud ────────────────────────────────────────
PdfDialogMsg::CloudBrowse => return Task::done(Message::PointCloudAttachPick),
PdfDialogMsg::CloudOk => return self.point_cloud_attach_ok(i),
PdfDialogMsg::CloudPathType(choice) => {
let stored = self.point_cloud_attach.as_ref().map(|s| self.pdf_stored_path(&s.path, choice.0));
if let (Some(s), Some(stored)) = (self.point_cloud_attach.as_mut(), stored) {
s.path_type = choice;
s.saved_path = crate::entities::underlay::display_path(&stored);
}
}
PdfDialogMsg::CloudInsertOnScreen(on) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.insert_on_screen = on;
}
}
PdfDialogMsg::CloudInsert(k, value) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.insert[k.min(2)] = value;
}
}
PdfDialogMsg::CloudScaleOnScreen(on) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.scale_on_screen = on;
}
}
PdfDialogMsg::CloudScale(value) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.scale = value;
}
}
PdfDialogMsg::CloudRotationOnScreen(on) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.rotation_on_screen = on;
}
}
PdfDialogMsg::CloudRotation(value) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.rotation = value;
}
}
PdfDialogMsg::CloudLock(on) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.lock = on;
}
}
PdfDialogMsg::CloudZoom(on) => {
if let Some(s) = self.point_cloud_attach.as_mut() {
s.zoom = on;
}
}
// ── Underlay Layers ───────────────────────────────────────────
PdfDialogMsg::LayersUnderlay(name) => {
if let Some(s) = self.underlay_layers.as_mut() {
+164
View File
@@ -62,6 +62,104 @@ impl OpenCADStudio {
})
};
self.ribbon.set_underlay_context(context, xref);
let clouds = if self.tabs[i].is_start { Vec::new() } else { self.selected_point_clouds(i) };
self.ribbon.set_point_cloud_context(clouds.first().map(|(_, data)| data.show_cropping));
}
/// The selected point clouds, or nothing when anything else is selected.
fn selected_point_clouds(&self, i: usize) -> Vec<(codec::Handle, codec::entities::PointCloudExData)> {
let mut out = Vec::new();
for (handle, entity) in self.tabs[i].scene.selected_entities() {
match entity {
codec::EntityType::Extended(extended) => match &extended.data {
codec::entities::ExtendedEntityData::PointCloudEx(data) => out.push((handle, data.clone())),
_ => return Vec::new(),
},
_ => return Vec::new(),
}
}
out
}
/// POINTCLOUDSTYLIZE's result: the clouds that carry the data take the
/// stylization (one undo step); the rest are named.
fn stylize_point_clouds(&mut self, i: usize, handles: &[codec::Handle], stylization: i16) {
let mut done = Vec::new();
for &handle in handles {
if self.tabs[i].scene.is_layer_locked(handle) {
continue;
}
let document = &self.tabs[i].scene.document;
let Some(codec::EntityType::Extended(extended)) = document.get_entity(handle) else {
continue;
};
let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data else {
continue;
};
let cloud = crate::scene::model::point_cloud::resolve_source(document, data)
.and_then(|path| crate::scene::model::point_cloud::load(&path));
let supported = match (stylization, &cloud) {
(6, _) => false,
(_, None) => true,
(1, Some(c)) => c.has_rgb,
(5, Some(c)) => c.has_intensity,
(3, Some(c)) => c.has_normals,
_ => true,
};
if supported {
done.push(handle);
} else {
self.command_line.push_info(&format!(
"The selected point cloud({}) does not support this stylization type.",
data.name
));
}
}
if !done.is_empty() {
self.push_undo_snapshot(i, "POINTCLOUDSTYLIZE");
for handle in &done {
if let Some(codec::EntityType::Extended(extended)) = self.tabs[i].scene.document.get_entity_mut(*handle) {
if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &mut extended.data {
crate::entities::extended::set_point_cloud_stylization(data, stylization);
}
}
}
let changes: Vec<_> = done.iter().map(|h| (*h, crate::scene::ChangeKind::Modified)).collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
self.refresh_properties();
}
self.command_line.push_output(&format!("{} point cloud(s) stylized", done.len()));
}
/// One undo step that edits every selected point cloud not locked.
fn edit_selected_point_clouds(
&mut self,
i: usize,
label: &str,
edit: impl Fn(&mut codec::entities::PointCloudExData),
) {
let handles: Vec<_> = self
.selected_point_clouds(i)
.into_iter()
.map(|(h, _)| h)
.filter(|h| !self.tabs[i].scene.is_layer_locked(*h))
.collect();
if handles.is_empty() {
return;
}
self.push_undo_snapshot(i, label);
for handle in &handles {
if let Some(codec::EntityType::Extended(extended)) = self.tabs[i].scene.document.get_entity_mut(*handle) {
if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &mut extended.data {
edit(data);
}
}
}
let changes: Vec<_> = handles.iter().map(|h| (*h, crate::scene::ChangeKind::Modified)).collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
self.refresh_properties();
}
/// One undo step that edits every selected PDF underlay.
@@ -156,6 +254,72 @@ impl OpenCADStudio {
self.apply_xclip(i, inserts, crate::modules::insert::xclip::XclipAction::Delete);
}
}
// Point cloud tools: a crop of the first selected cloud, or the
// crops of every selected one.
"_PCCROPRECT" | "_PCCROPPOLY" | "_PCCROPCIRC" => {
use crate::command::CadCommand;
let Some(&(handle, _)) = self.selected_point_clouds(i).first() else {
return Some(Task::none());
};
if self.tabs[i].scene.is_layer_locked(handle) {
return Some(Task::none());
}
let Some(codec::EntityType::Extended(cloud)) = self.tabs[i].scene.document.get_entity(handle).cloned() else {
return Some(Task::none());
};
let option = match cmd {
"_PCCROPPOLY" => "P",
"_PCCROPCIRC" => "C",
_ => "",
};
let command = crate::modules::insert::pc_crop::PointCloudCropCommand::for_cloud(handle, cloud, option);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"POINTCLOUDSTYLIZE" => {
use crate::command::CadCommand;
let command = crate::modules::draw::select::SelectObjectsCommand::with_prompt(
"POINTCLOUDSTYLIZE",
"_PCSTYLIZE",
"Select point cloud objects:",
);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
// The chosen objects' point clouds, then the option.
"_PCSTYLIZE" => {
use crate::command::CadCommand;
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.filter(|(_, entity)| crate::scene::is_point_cloud(entity))
.map(|(h, _)| h)
.collect();
if handles.is_empty() {
return Some(self.finish_dispatch(cmd));
}
let command = crate::modules::insert::pc_stylize::PointCloudStylizeCommand::new(handles);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
c if c.starts_with("_PCSTYLIZEAPPLY ") => {
let mut parts = c.split_whitespace().skip(1);
let stylization: i16 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(1);
let handles: Vec<codec::Handle> =
parts.filter_map(|h| u64::from_str_radix(h, 16).ok()).map(codec::Handle::new).collect();
self.stylize_point_clouds(i, &handles, stylization);
}
"_PCCROPSHOW" => {
let on = !self.selected_point_clouds(i).first().is_some_and(|(_, data)| data.show_cropping);
self.edit_selected_point_clouds(i, "POINTCLOUDCROP", |data| data.show_cropping = on);
}
"_PCCROPINVERT" => self.edit_selected_point_clouds(i, "POINTCLOUDCROP", |data| {
for crop in &mut data.croppings {
crop.inverted = !crop.inverted;
}
}),
"_PCUNCROP" => self.edit_selected_point_clouds(i, "POINTCLOUDUNCROP", |data| data.croppings.clear()),
"_PDFULUNCLIP" => {
self.edit_selected_underlays(i, "PDFCLIP", |u| {
u.clip_boundary_vertices.clear();
+32 -15
View File
@@ -49,18 +49,8 @@ fn ensure_definition(
name: &str,
points: &crate::scene::model::point_cloud::CloudPoints,
) -> (Handle, Vector3, Vector3) {
let (mut lo, mut hi) = ([f64::MAX; 3], [f64::MIN; 3]);
for p in &points.positions {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
let (min, max) = if points.positions.is_empty() {
(Vector3::ZERO, Vector3::ZERO)
} else {
(Vector3::new(lo[0], lo[1], lo[2]), Vector3::new(hi[0], hi[1], hi[2]))
};
let [lo, hi] = points.bounds.unwrap_or_default();
let (min, max) = (Vector3::new(lo[0], lo[1], lo[2]), Vector3::new(hi[0], hi[1], hi[2]));
let same = |stored: &str| stored.replace('/', "\\").eq_ignore_ascii_case(&path.replace('/', "\\"));
let existing = document.objects.iter().find_map(|(handle, object)| match object {
ObjectType::ClassObject(object) => match &object.data {
@@ -107,6 +97,25 @@ impl OpenCADStudio {
return;
};
let path = placement.path.to_string_lossy().into_owned();
let options = placement.options.clone();
// The path stored: as chosen; relative to the drawing once it has a
// file (until then the full path, made relative on the first save).
let host = self.tabs[i].current_path.clone();
let stored = match &options.stored {
Some(stored) => stored.clone(),
None if options.relative => host
.as_deref()
.and_then(|host| {
crate::io::xref_model::to_pathtype_result(&path, host, crate::io::xref_model::Pathtype::Relative).ok()
})
.map(|relative| relative.replace('/', "\\"))
.unwrap_or_else(|| path.clone()),
None => path.clone(),
};
if stored != path {
crate::scene::model::point_cloud::register_source(&stored, placement.path.clone());
}
let relative_later = options.relative && options.stored.is_none() && host.is_none();
let name = placement
.path
.file_stem()
@@ -115,9 +124,11 @@ impl OpenCADStudio {
let pending = self.begin_undo(i, label, 1, false);
let document = &mut self.tabs[i].scene.document;
// POINTCLOUDLOCK 1 attaches clouds locked.
let locked = crate::scene::model::point_cloud::setting("POINTCLOUDLOCK")
.is_some_and(|setting| crate::scene::model::point_cloud::setting_value(document, setting) == 1);
let (definition, extents_min, extents_max) = ensure_definition(document, &path, &name, &points);
let locked = options.locked.unwrap_or_else(|| {
crate::scene::model::point_cloud::setting("POINTCLOUDLOCK")
.is_some_and(|setting| crate::scene::model::point_cloud::setting_value(document, setting) == 1)
});
let (definition, extents_min, extents_max) = ensure_definition(document, &stored, &name, &points);
let [x, y, z] = placement.axes;
// As the reference creates a cloud: scan colours, the intensity
// range 0–100 as a gradient, crops shown.
@@ -175,6 +186,12 @@ impl OpenCADStudio {
}
}
self.command_line.push_output("1 point cloud attached");
if relative_later {
self.tabs[i].xref_relative_on_save.insert(format!("{}{:X}", crate::io::xref::POINT_CLOUD_KEY, definition.value()));
}
if options.zoom {
self.tabs[i].scene.zoom_to_entities(&[cloud]);
}
}
self.tabs[i].dirty = true;
if let Some(pd) = pending {
+2 -7
View File
@@ -452,19 +452,14 @@ impl OpenCADStudio {
}
Task::none()
}
// A point cloud goes on to its placement prompts.
// A point cloud goes on to the Attach Point Cloud dialog.
"rcp" | "rcs" => {
use crate::command::CadCommand;
let i = self.active_tab;
if crate::scene::model::point_cloud::load(&path).is_none() {
self.command_line.push_error("All scans are not found or invalid.");
self.command_line.push_error("Attach point cloud failed");
return Task::none();
}
let command =
crate::modules::insert::pc_attach::PointCloudAttachCommand::with_file(path);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
self.open_point_cloud_attach_dialog(&path);
Task::none()
}
"png" | "jpg" | "jpeg" | "bmp" | "tif" | "tiff" => {