Point clouds: POINTCLOUDCOLORMAP dialog, out-of-range colouring

POINTCLOUDCOLORMAP asks for a cloud (or None for the drawing's default
schemes) and opens the Point Cloud Color Map dialog: intensity and
elevation tabs with the scheme list, New / Delete / Rename, number of
colours (2-25, resampled along the scheme), blend between the ends,
reverse, gradient or bands, the colorized range, the out-of-range
choice and, for elevation, the cloud's extents or a fixed range with its
band height. OK / Apply write the drawing's colour map (created under
ACAD_POINTCLOUD_COLORMAP_DICT when missing) and the cloud's colouring in
one undo step; Make stylization current sets the tab's stylization.
Values outside a scheme's range take the end colours, the scan colours
or are hidden. Automation action pc_colormap edits the dialog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ramox81andClaude Opus 5.5 committed 2026-09-30 13:10:45 +03:00
1 parent 7761652a9f
commit 2eaae1ecab
32 files changed
+1449 -21

No files matched your search

+379 -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;
@@ -47,6 +48,28 @@ pub enum PdfDialogMsg {
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,
// Underlay Layers
LayersUnderlay(String),
LayersSearch(String),
@@ -1108,3 +1131,358 @@ 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),
);
let names: Vec<String> = state.ramps.iter().map(|r| r.name.clone()).collect();
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!("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()
}