From 09e9e44c19470769837d3a7d60d39609832c10d0 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:38:45 +0300 Subject: [PATCH] Point clouds: scan and region visibility in the cloud record The manager's switches write the cloud's hidden scans (by the scan identifier its project and scan file carry) and hidden regions (the unassigned points are region 0), as the reference stores them, instead of extended data. A parent switch shows mixed while some of its rows are on. A cloud that shows none of its points draws its extents box. The codec reads and writes both lists. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/commands/pc_extract.rs | 2 +- src/app/commands/pdf_underlay.rs | 33 +++++----- src/app/commands/point_cloud.rs | 4 +- src/scene/convert/tessellate.rs | 3 +- src/scene/mod.rs | 2 +- src/scene/model/point_cloud/mod.rs | 70 ++++++++++++--------- src/scene/model/point_cloud/rcp.rs | 3 + src/scene/model/point_cloud/rcs.rs | 14 +++++ src/ui/window/pc_manager.rs | 98 ++++++++++++++++++++---------- 11 files changed, 147 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b23557d69..ff7490848 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4205,7 +4205,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/ramox81/cadcodec.git?rev=380e105#380e1057426ebd621f9967a06df97d54dce4c983" +source = "git+https://github.com/ramox81/cadcodec.git?rev=43f83ed#43f83ed5876684b906e01339d8aef617905845c8" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 43f45e7f6..5629a28a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -164,4 +164,4 @@ name = "command_benchmarks" harness = false [patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "380e105" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "43f83ed" } diff --git a/src/app/commands/pc_extract.rs b/src/app/commands/pc_extract.rs index 53f9b6ab0..3dbfe5a14 100644 --- a/src/app/commands/pc_extract.rs +++ b/src/app/commands/pc_extract.rs @@ -39,7 +39,7 @@ impl OpenCADStudio { data, color, None, - &crate::scene::model::point_cloud::hidden(common), + &crate::scene::model::point_cloud::hidden(data), ) { clouds.push((common.handle, placed)); } diff --git a/src/app/commands/pdf_underlay.rs b/src/app/commands/pdf_underlay.rs index dcd5f839c..5d1a98bd6 100644 --- a/src/app/commands/pdf_underlay.rs +++ b/src/app/commands/pdf_underlay.rs @@ -162,11 +162,11 @@ impl OpenCADStudio { self.refresh_properties(); } - /// A Point Cloud Manager click: a switch writes the cloud's visibility - /// extended data as one undo step; the rest is palette state. + /// A Point Cloud Manager click: a switch writes the cloud's hidden scans + /// and regions as one undo step; the rest is palette state. pub(in crate::app) fn update_pc_manager(&mut self, message: crate::ui::window::pc_manager::PcManagerMsg) -> Task { - use crate::scene::model::point_cloud::VISIBILITY_APP; - use crate::ui::window::pc_manager::{self, PcManagerMsg}; + use crate::scene::model::point_cloud::UNASSIGNED_OFF; + use crate::ui::window::pc_manager::{self, PcManagerMsg, Row}; let i = self.active_tab; match message { PcManagerMsg::Toggle(handle, row) => { @@ -178,22 +178,19 @@ impl OpenCADStudio { if self.tabs[i].scene.is_layer_locked(handle) { return Task::none(); } + // A scan named by its file name (automation) is the scan with that name. + let row = match row { + Row::Scan(key) => Row::Scan( + cloud.scans.iter().find(|(name, _)| *name == key).map_or(key, |(_, id)| id.clone()), + ), + row => row, + }; let hidden = pc_manager::toggled(&cloud.hidden, &cloud.scans, &row); self.push_undo_snapshot(i, "POINTCLOUDMANAGER"); - self.tabs[i].scene.ensure_app_id(VISIBILITY_APP); - let app_handle = self.tabs[i].scene.document.app_ids.get(VISIBILITY_APP).map(|a| a.handle.value()); - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { - let data = &mut entity.common_mut().extended_data; - if let Some(app) = app_handle { - data.raw_dwg_eed.retain(|(a, _)| *a != app); - } - data.remove_record(VISIBILITY_APP); - if !hidden.is_empty() { - let mut record = codec::xdata::ExtendedDataRecord::new(VISIBILITY_APP); - for name in hidden { - record.add_value(codec::xdata::XDataValue::String(name)); - } - data.add_record(record); + 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 { + data.hidden_regions = if hidden.iter().any(|h| h == UNASSIGNED_OFF) { vec![0] } else { Vec::new() }; + data.hidden_scans = hidden.into_iter().filter(|h| h != UNASSIGNED_OFF).collect(); } } self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); diff --git a/src/app/commands/point_cloud.rs b/src/app/commands/point_cloud.rs index e7440c4cb..6f1ef62a0 100644 --- a/src/app/commands/point_cloud.rs +++ b/src/app/commands/point_cloud.rs @@ -146,8 +146,8 @@ impl OpenCADStudio { name, show_intensity: false, show_cropping: true, - unknown_bl0: 0, - unknown_bl1: 0, + hidden_scans: Vec::new(), + hidden_regions: Vec::new(), stylization_type: 1, intensity_color_scheme: String::new(), current_color_scheme: String::new(), diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index cc15ad6c4..a954c665c 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -570,7 +570,8 @@ fn point_cloud_wires( let mut shown = true; let (mut pick_tris, mut pick_tris_low) = (Vec::new(), Vec::new()); if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data { - shown = crate::scene::model::point_cloud::resolve_source(document, data).is_none(); + // Its box shows too with every point hidden (the reference does). + shown = crate::scene::model::point_cloud::shows_no_points(document, data); let c = codec::entities::point_cloud_ex_corners(data).map(|c| [c.x, c.y, c.z]); let mut tris = Vec::with_capacity(36); for [a, b, d, e] in [[0, 1, 3, 2], [4, 5, 7, 6], [0, 1, 4, 5], [2, 3, 6, 7], [0, 2, 4, 6], [1, 3, 5, 7]] { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d5ae9db30..203bd8a55 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -8408,7 +8408,7 @@ impl Scene { data, object_color, transform, - &point_cloud::hidden(common), + &point_cloud::hidden(data), )); }, ); diff --git a/src/scene/model/point_cloud/mod.rs b/src/scene/model/point_cloud/mod.rs index 6da5f4b7c..1fc84634f 100644 --- a/src/scene/model/point_cloud/mod.rs +++ b/src/scene/model/point_cloud/mod.rs @@ -32,7 +32,7 @@ pub struct CloudPoints { /// Scans read (one for a scan file). pub scans: usize, /// Each scan's name (its file stem) and its points' index range. - pub scan_ranges: Vec<(String, std::ops::Range)>, + pub scan_ranges: Vec, /// A project's preview picture (JPEG). pub preview: Option>, /// Bounds from the scans' headers, placed like their points: the @@ -73,18 +73,21 @@ fn decode(path: &Path) -> Option { // A project lists its scans with their own transforms; a scan file // carries the same transform in its header. let stem = |p: &Path| p.file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned()); - let scans: Vec<(rcs::Scan, [[f64; 3]; 3], String)> = if is_project { + let scans: Vec<(rcs::Scan, [[f64; 3]; 3], String, String)> = if is_project { rcp::scans(&bytes, path)? .into_iter() .filter_map(|entry| { let scan = rcs::decode(&std::fs::read(&entry.path).ok()?)?; - Some((scan, [entry.translation, entry.rotation, entry.scale], stem(&entry.path))) + // The project's identifier, else the scan file's own. + let id = if entry.id.is_empty() { scan.id.clone() } else { entry.id.clone() }; + Some((scan, [entry.translation, entry.rotation, entry.scale], stem(&entry.path), id)) }) .collect() } else { let scan = rcs::decode(&bytes)?; let transform = [scan.translation, scan.rotation, scan.scale]; - vec![(scan, transform, stem(path))] + let id = scan.id.clone(); + vec![(scan, transform, stem(path), id)] }; let mut cloud = CloudPoints { id: NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), @@ -100,7 +103,7 @@ fn decode(path: &Path) -> Option { preview: is_project.then(|| rcp::preview(&bytes)).flatten(), bounds: None, }; - for (scan, [translation, rotation, scale], name) in scans { + for (scan, [translation, rotation, scale], name, id) in scans { let rotate = euler(rotation.map(f64::to_radians)); let place = |p: &[f64; 3]| { let s = [p[0] * scale[0], p[1] * scale[1], p[2] * scale[2]]; @@ -109,7 +112,8 @@ fn decode(path: &Path) -> Option { }; let first = cloud.positions.len(); cloud.positions.extend(scan.local.iter().map(place)); - cloud.scan_ranges.push((name, first..cloud.positions.len())); + let id = if id.is_empty() { name.clone() } else { id }; + cloud.scan_ranges.push(ScanRange { name, id, range: first..cloud.positions.len() }); let [lo, hi] = scan.bounds; for corner in 0..8 { let p = place(&[ @@ -257,27 +261,37 @@ pub(crate) fn any_definition(document: &CadDocument) -> bool { }) } -/// What the Point Cloud Manager turned off on a cloud: extended data under -/// this application, one string per hidden scan plus the markers below. -pub const VISIBILITY_APP: &str = "OCS_POINTCLOUD_VISIBILITY"; -/// The whole cloud is off. -pub const CLOUD_OFF: &str = "*CLOUD_OFF"; -/// Its unassigned points (every point of a cloud without regions) are off. -pub const UNASSIGNED_OFF: &str = "*UNASSIGNED_OFF"; +/// One scan's points in a cloud: its name (the file's), its identifier and +/// its index range. +#[derive(Debug, Clone)] +pub struct ScanRange { + pub name: String, + pub id: String, + pub range: std::ops::Range, +} -/// The scans and markers a cloud entity hides. -pub(crate) fn hidden(common: &codec::entities::EntityCommon) -> Vec { - common - .extended_data - .records() - .iter() - .filter(|r| r.application_name.eq_ignore_ascii_case(VISIBILITY_APP)) - .flat_map(|r| r.values.iter()) - .filter_map(|v| match v { - codec::xdata::XDataValue::String(s) => Some(s.clone()), - _ => None, - }) - .collect() +/// Stands in a hidden list for the cloud's unassigned points (region 0), +/// which are every point of a cloud without regions. +pub const UNASSIGNED_OFF: &str = "*UNASSIGNED"; + +/// What a cloud turns off: the identifiers of its hidden scans, and +/// [`UNASSIGNED_OFF`] when its unassigned points are hidden. +pub(crate) fn hidden(data: &PointCloudExData) -> Vec { + let mut hidden = data.hidden_scans.clone(); + if data.hidden_regions.contains(&0) { + hidden.push(UNASSIGNED_OFF.to_string()); + } + hidden +} + +/// Whether a cloud draws none of its points: its file is not found, or its +/// unassigned points or every scan are turned off. +pub(crate) fn shows_no_points(document: &CadDocument, data: &PointCloudExData) -> bool { + let Some(cloud) = resolve_source(document, data).and_then(|path| load(&path)) else { + return true; + }; + data.hidden_regions.contains(&0) + || (!cloud.scan_ranges.is_empty() && cloud.scan_ranges.iter().all(|s| data.hidden_scans.contains(&s.id))) } /// The points `data` shows, placed through its origin and axes and then @@ -292,10 +306,10 @@ pub(crate) fn placed( ) -> Option> { let cloud = load(&resolve_source(document, data)?)?; let skipped: Vec> = - if hidden.iter().any(|h| h == CLOUD_OFF || h == UNASSIGNED_OFF) { + if hidden.iter().any(|h| h == UNASSIGNED_OFF) { vec![0..cloud.positions.len()] } else { - cloud.scan_ranges.iter().filter(|(name, _)| hidden.contains(name)).map(|(_, r)| r.clone()).collect() + cloud.scan_ranges.iter().filter(|s| hidden.contains(&s.id)).map(|s| s.range.clone()).collect() }; // The points shown: POINTCLOUDDENSITY per cent of POINTCLOUDPOINTMAX, // in tenths by POINTCLOUDLOD, every n-th point of a larger cloud. diff --git a/src/scene/model/point_cloud/rcp.rs b/src/scene/model/point_cloud/rcp.rs index 89f1982c5..74ec187e3 100644 --- a/src/scene/model/point_cloud/rcp.rs +++ b/src/scene/model/point_cloud/rcp.rs @@ -9,6 +9,8 @@ use std::path::{Path, PathBuf}; /// One visible scan of the project. pub struct ScanEntry { pub path: PathBuf, + /// The scan's identifier ("{…}"). + pub id: String, pub translation: [f64; 3], pub rotation: [f64; 3], pub scale: [f64; 3], @@ -90,6 +92,7 @@ fn entry(node: roxmltree::Node, folder: &Path) -> Option { })?; Some(ScanEntry { path, + id: node.attribute("Id").unwrap_or_default().to_string(), translation: xyz(node, "Translation", 0.0), rotation: xyz(node, "Rotation", 0.0), scale: xyz(node, "Scale", 1.0), diff --git a/src/scene/model/point_cloud/rcs.rs b/src/scene/model/point_cloud/rcs.rs index a49f10a5c..bbd77bbaf 100644 --- a/src/scene/model/point_cloud/rcs.rs +++ b/src/scene/model/point_cloud/rcs.rs @@ -32,6 +32,9 @@ pub struct Scan { pub scale: [f64; 3], /// The scan's bounds as its header records them (local frame). pub bounds: [[f64; 3]; 2], + /// The scan's identifier ("{…}"), as its project and a cloud's hidden + /// scans name it. + pub id: String, } const NODE_RECORD: usize = 368; @@ -77,6 +80,16 @@ fn normal(code: u16) -> [f32; 3] { n.map(|v| (v / length) as f32) } +/// The identifier the header carries after its flags, NUL-terminated. +fn header_id(b: &[u8]) -> String { + let tail = b.get(0xE3..b.len().min(0xE3 + 64)).unwrap_or_default(); + let text = &tail[..tail.iter().position(|c| *c == 0).unwrap_or(tail.len())]; + match std::str::from_utf8(text) { + Ok(id) if id.starts_with('{') && id.ends_with('}') => id.to_string(), + _ => String::new(), + } +} + pub fn decode(b: &[u8]) -> Option { if b.get(0..5)? != b"ADOCT" { return None; @@ -119,6 +132,7 @@ pub fn decode(b: &[u8]) -> Option { rotation, scale, bounds, + id: header_id(b), }; for node in 0..nodes { let record = directory + 8 + node * NODE_RECORD; diff --git a/src/ui/window/pc_manager.rs b/src/ui/window/pc_manager.rs index 75f1bf6fa..4d7e4ae74 100644 --- a/src/ui/window/pc_manager.rs +++ b/src/ui/window/pc_manager.rs @@ -1,10 +1,10 @@ //! 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 visibility extended data, so +//! 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, CLOUD_OFF, UNASSIGNED_OFF}; +use crate::scene::model::point_cloud::{self, UNASSIGNED_OFF}; use crate::ui::dock::PanelId; use codec::entities::ExtendedEntityData; use codec::{CadDocument, EntityType, Handle}; @@ -32,11 +32,21 @@ 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:` (automation). + /// `cloud`, `unassigned`, `scans` or `scan:` + /// (automation). pub fn parse(text: &str) -> Option { match text { "cloud" => Some(Self::Cloud), @@ -62,7 +72,8 @@ pub struct Cloud { pub handle: Handle, pub name: String, pub hidden: Vec, - pub scans: Vec, + /// Each scan's name and identifier. + pub scans: Vec<(String, String)>, } /// The drawing's point clouds; only the selected ones when any is selected. @@ -79,12 +90,12 @@ pub fn clouds(document: &CadDocument, selected: &[Handle]) -> Vec { 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(|(name, _)| name.clone()).collect()) + .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(common), + hidden: point_cloud::hidden(data), scans, }) }) @@ -96,35 +107,48 @@ pub fn clouds(document: &CadDocument, selected: &[Handle]) -> Vec { } } -/// Whether a row's switch is on. A parent is on while any child is. -pub fn is_on(hidden: &[String], scans: &[String], row: &Row) -> bool { - let off = |name: &str| hidden.iter().any(|h| h == name); - let any_scan = scans.is_empty() || scans.iter().any(|s| !off(s)); +/// 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 = scans.iter().map(|(_, id)| on(id)).collect(); match row { - Row::Scan(name) => !off(name), - Row::Scans => any_scan, - Row::Unassigned => !off(UNASSIGNED_OFF), - Row::Cloud => !off(CLOUD_OFF) && (!off(UNASSIGNED_OFF) || any_scan), + 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 flipping `row`. Switching the cloud back on shows -/// everything under it. -pub fn toggled(hidden: &[String], scans: &[String], row: &Row) -> Vec { - fn set(out: &mut Vec, name: &str, off: bool) { - out.retain(|h| h != name); +/// 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 { + fn set(out: &mut Vec, id: &str, off: bool) { + out.retain(|h| h != id); if off { - out.push(name.to_string()); + out.push(id.to_string()); } } - let on = is_on(hidden, scans, row); + let off = switch_state(hidden, scans, row) == Switch::On; let mut out = hidden.to_vec(); match row { - Row::Cloud if on => set(&mut out, CLOUD_OFF, true), - Row::Cloud => out.clear(), - Row::Unassigned => set(&mut out, UNASSIGNED_OFF, on), - Row::Scans => scans.iter().for_each(|s| set(&mut out, s, on)), - Row::Scan(name) => set(&mut out, name, on), + 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 } @@ -151,7 +175,7 @@ fn tree_row<'a>( fold: Option, icon: Element<'a, Message>, label: String, - switch: Option<(bool, Message)>, + switch: Option<(Switch, Message)>, ) -> Element<'a, Message> { let arrow: Element<'a, Message> = match fold { Some(open) => button(if open { @@ -169,7 +193,15 @@ fn tree_row<'a>( 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 { - Some((on, message)) => checkbox(on).on_toggle(move |_| message.clone()).size(14).into(), + // 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()); @@ -218,14 +250,14 @@ pub fn view<'a>( let mut tree = column![].spacing(1); for cloud in clouds(document, selected) { let root = key(cloud.handle); - let scans: Vec<&String> = cloud.scans.iter().filter(|s| matches(s)).collect(); + 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 = is_on(&cloud.hidden, &cloud.scans, &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); @@ -277,15 +309,15 @@ pub fn view<'a>( switch(Row::Scans), )); if scans_open { - for name in scans { + for (name, id) in scans { tree = tree.push(tree_row( state, - format!("{scans_key}/{name}"), + format!("{scans_key}/{id}"), 2, None, cloud_icon(), name.clone(), - switch(Row::Scan(name.clone())), + switch(Row::Scan(id.clone())), )); } }