Point clouds: drawn from their scans, placed, cropped and attached as the reference does

Scans (.rcs) and projects (.rcp) are read and their points drawn as
screen-sized dots, coloured by the cloud's stylization. The cloud's
placement carries its scale and rotation; its extents and crops stay in
the scan's coordinates. A missing scan shows the extents with the saved
path. -POINTCLOUDATTACH / POINTCLOUDATTACH, POINTCLOUDCROP and the
POINTCLOUD* settings follow the reference's prompts and messages, the
Properties list is the reference's, and a locked cloud is left alone by
edits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ramox81andClaude Opus 5.5 committed 2026-09-29 17:08:46 +03:00
1 parent 07f25f4bca
commit e9c4e501db
58 files changed
+2711 -237

No files matched your search

+299 -207
View File
@@ -470,12 +470,64 @@ fn point_cloud_clip_lines(data: &PointCloudData) -> Vec<[f64; 3]> {
points
}
fn point_cloud_ex_lines(data: &PointCloudExData) -> Vec<[f64; 3]> {
/// The cloud's extents box, placed in the drawing. Its points are drawn from
/// the scan file; the box only picks the cloud, unless the file cannot be
/// found: then the box shows, with the saved path written across it.
fn point_cloud_ex_lines(data: &PointCloudExData, document: &codec::CadDocument) -> Vec<[f64; 3]> {
let corners = codec::entities::point_cloud_ex_corners(data).map(|c| [c.x, c.y, c.z]);
let mut points = Vec::new();
push_box(&mut points, data.extents_min, data.extents_max);
for a in 0..8 {
for bit in [1, 2, 4] {
if a & bit == 0 {
push_segment(&mut points, corners[a], corners[a | bit]);
}
}
}
if crate::scene::model::point_cloud::resolve_source(document, data).is_none() {
let saved = crate::scene::model::point_cloud::definition(document, data)
.map(|definition| definition.source_filename.clone())
.unwrap_or_default();
append_missing_cloud_label(&mut points, data, &saved);
}
points
}
/// The saved path, centred on the extents' base and as wide as most of it.
fn append_missing_cloud_label(points: &mut Vec<[f64; 3]>, data: &PointCloudExData, text: &str) {
if text.is_empty() {
return;
}
let (strokes, _) =
crate::scene::text::lff::tessellate_text_ex([0.0, 0.0], 1.0, 0.0, 1.0, 0.0, "standard", text);
let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]);
for [x, y] in strokes.iter().flatten() {
lo = [lo[0].min(*x as f64), lo[1].min(*y as f64)];
hi = [hi[0].max(*x as f64), hi[1].max(*y as f64)];
}
let (min, max) = (data.extents_min, data.extents_max);
let (width, height) = (hi[0] - lo[0], hi[1] - lo[1]);
if !(width > 0.0 && height > 0.0) {
return;
}
let scale = (0.8 * (max.x - min.x) / width).min(0.5 * (max.y - min.y) / height);
let center = [(min.x + max.x) * 0.5, (min.y + max.y) * 0.5];
let mid = [(lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5];
for stroke in strokes {
push_chain(
points,
stroke.into_iter().map(|[x, y]| {
let local = Vector3::new(
center[0] + (x as f64 - mid[0]) * scale,
center[1] + (y as f64 - mid[1]) * scale,
min.z,
);
let p = codec::entities::point_cloud_ex_to_world(data, local);
[p.x, p.y, p.z]
}),
);
}
}
fn point_cloud_ex_clip_lines(data: &PointCloudExData) -> Vec<[f64; 3]> {
let mut points = Vec::new();
if data.show_cropping {
@@ -483,9 +535,29 @@ fn point_cloud_ex_clip_lines(data: &PointCloudExData) -> Vec<[f64; 3]> {
if crop.points.len() < 2 {
continue;
}
let mut chain: Vec<[f64; 3]> =
crop.points.iter().map(|p| [p.x, p.y, p.z]).collect();
if crop.points.len() > 2 {
// Crop boundaries are kept in the cloud's own coordinates, on
// the crop plane through `plane` spanned by its two directions.
let normal = crop.x_direction.cross(&crop.y_direction);
let place = |p: Vector3| {
let local = crop.plane + crop.x_direction * p.x + crop.y_direction * p.y + normal * p.z;
let w = codec::entities::point_cloud_ex_to_world(data, local);
[w.x, w.y, w.z]
};
// A circle is stored as its centre and a point on it.
let outline: Vec<Vector3> = if crop.crop_type == 3 {
let (c, e) = (crop.points[0], crop.points[1]);
let r = ((e.x - c.x).powi(2) + (e.y - c.y).powi(2)).sqrt();
(0..64)
.map(|k| {
let a = k as f64 / 64.0 * std::f64::consts::TAU;
Vector3::new(c.x + r * a.cos(), c.y + r * a.sin(), c.z)
})
.collect()
} else {
crop.points.clone()
};
let mut chain: Vec<[f64; 3]> = outline.into_iter().map(place).collect();
if chain.len() > 2 {
chain.push(chain[0]);
}
push_chain(&mut points, chain);
@@ -605,14 +677,14 @@ fn to_render(entity: &ExtendedEntity, document: &codec::CadDocument) -> Option<R
[data.extents_max.x, data.extents_max.y, data.extents_max.z],
],
),
ExtendedEntityData::PointCloudEx(data) => (
point_cloud_ex_lines(data),
Vec::new(),
vec![
[data.extents_min.x, data.extents_min.y, data.extents_min.z],
[data.extents_max.x, data.extents_max.y, data.extents_max.z],
],
),
ExtendedEntityData::PointCloudEx(data) => {
let origin = data.ucs_origin;
(
point_cloud_ex_lines(data, document),
vec![(glam::DVec3::new(origin.x, origin.y, origin.z), SnapHint::Insertion)],
vec![[origin.x, origin.y, origin.z]],
)
}
_ => return None,
};
if points.len() < 2 {
@@ -1048,122 +1120,220 @@ fn point_cloud_properties(data: &PointCloudData) -> Vec<PropSection> {
]
}
fn point_cloud_ex_properties(data: &PointCloudExData) -> Vec<PropSection> {
let crops = data
.croppings
/// The colour ramps every point cloud colour map carries, by their fixed
/// identifiers; a cloud names its schemes by identifier.
pub(crate) const POINT_CLOUD_RAMPS: [(&str, &str); 7] = [
("Blues", "9B45EA40-176A-46B5-9470-1FA6A7880EE3"),
("Earth", "6DFAB669-B096-4842-9F90-F23CBDD9E410"),
("Grayscale", "655FCCB9-2DB2-40BC-86BB-A513D3DF3FEE"),
("Greens", "B5E88F37-A921-45DF-9987-B979CE516EF3"),
("Hydro", "46B3A5C6-4029-4D50-90D3-C49ADB0A2B45"),
("Reds", "E247D320-B4DE-471F-8E5F-B848819BF6F6"),
("Spectrum", "C0A3838B-81E3-4506-B908-13ECF523DB68"),
];
/// Stylization choices in the order the reference lists them, with the
/// stored values: scan colours 1, object colour 2, normals 3, intensity 5,
/// elevation 4.
const POINT_CLOUD_STYLIZATIONS: [(&str, i16); 5] = [
("Scan Colors", 1),
("Object Color", 2),
("Normals", 3),
("Intensity", 5),
("Elevation", 4),
];
/// The scheme slot a stylization colours by: intensity and elevation each
/// keep their own; the other stylizations show (and set) the intensity one.
fn point_cloud_scheme_slot(data: &mut PointCloudExData) -> &mut String {
match data.stylization_type {
4 => &mut data.current_color_scheme,
6 => &mut data.classification_color_scheme,
_ => &mut data.intensity_color_scheme,
}
}
/// The name of the scheme a cloud shows; an unset scheme reads as the first
/// of the list, as the reference shows it.
pub(crate) fn point_cloud_scheme_name(data: &PointCloudExData) -> &'static str {
let id = match data.stylization_type {
4 => &data.current_color_scheme,
6 => &data.classification_color_scheme,
_ => &data.intensity_color_scheme,
};
POINT_CLOUD_RAMPS
.iter()
.enumerate()
.map(|(index, crop)| {
format!(
"{}: type {}; inside {}; inverted {}; points {}; plane [{}]",
index + 1,
crop.crop_type,
crop.inside,
crop.inverted,
crop.points.len(),
vector_text(crop.plane)
)
})
.collect::<Vec<_>>()
.join("\n");
.find(|(_, guid)| guid.eq_ignore_ascii_case(id))
.unwrap_or(&POINT_CLOUD_RAMPS[0])
.0
}
/// A placement's rotation about its Z axis and its scale.
pub(crate) fn point_cloud_rotation_scale(data: &PointCloudExData) -> (f64, f64) {
let x = data.ucs_x_direction;
(x.y.atan2(x.x), x.length())
}
fn plain_row(label: &str, field: &'static str, value: f64) -> Property {
Property {
label: label.into(),
field,
value: PropValue::EditText(crate::entities::underlay::plain_number(value)),
}
}
fn yes_no_row(label: &str, field: &'static str, flag: bool) -> Property {
Property {
label: label.into(),
field,
value: PropValue::Choice {
selected: if flag { t!("Yes") } else { t!("No") }.into_owned(),
options: vec![t!("Yes").into_owned(), t!("No").into_owned()],
},
}
}
fn is_yes(value: &str) -> bool {
let value = value.trim();
value == t!("Yes").as_ref() || value.eq_ignore_ascii_case("yes") || value.eq_ignore_ascii_case("true")
}
// Props: General → 3D Visualization → Geometry → Misc, as the reference
// lists a point cloud. Saved path, Unit and Unit factor are filled by the
// Properties panel, where the definition is reachable.
fn point_cloud_ex_properties(data: &PointCloudExData) -> Vec<PropSection> {
let stylization = POINT_CLOUD_STYLIZATIONS
.iter()
.find(|(_, value)| *value == data.stylization_type)
.map_or("Scan Colors", |(name, _)| name);
let (rotation, scale) = point_cloud_rotation_scale(data);
let origin = data.ucs_origin;
vec![
PropSection {
title: t!("Point Cloud Ex").into_owned(),
title: t!("3D Visualization").into_owned(),
props: vec![
ro_prop(t!("Class Version").as_ref(),
"ext_pcx_version",
data.class_version.to_string(),
),
text_prop(t!("Name").as_ref(), "ext_pcx_name", &data.name),
ro_prop(t!("Extents Min").as_ref(), "ext_pcx_min", vector_text(data.extents_min)),
ro_prop(t!("Extents Max").as_ref(), "ext_pcx_max", vector_text(data.extents_max)),
ro_prop(t!("UCS Origin").as_ref(),
"ext_pcx_ucs_origin",
vector_text(data.ucs_origin),
),
ro_prop(t!("UCS X").as_ref(),
"ext_pcx_ucs_x",
vector_text(data.ucs_x_direction),
),
ro_prop(t!("UCS Y").as_ref(),
"ext_pcx_ucs_y",
vector_text(data.ucs_y_direction),
),
ro_prop(t!("UCS Z").as_ref(),
"ext_pcx_ucs_z",
vector_text(data.ucs_z_direction),
),
bool_prop(t!("Locked").as_ref(), "ext_pcx_locked", data.locked),
ro_prop(t!("Definition").as_ref(),
"ext_pcx_definition",
handle_text(data.definition_handle),
),
ro_prop(t!("Reactor").as_ref(),
"ext_pcx_reactor",
handle_text(data.reactor_handle),
Property {
label: t!("Stylization").into_owned(),
field: "ext_pcx_stylization",
value: PropValue::Choice {
selected: t!(stylization).into_owned(),
options: POINT_CLOUD_STYLIZATIONS
.iter()
.map(|(name, _)| t!(*name).into_owned())
.collect(),
},
},
choice_prop(
t!("Color scheme").as_ref(),
"ext_pcx_color_scheme",
point_cloud_scheme_name(data),
&POINT_CLOUD_RAMPS.map(|(name, _)| name),
),
],
},
PropSection {
title: t!("Point Cloud Ex Display").into_owned(),
title: t!("Geometry").into_owned(),
props: vec![
bool_prop(t!("Show Intensity").as_ref(),
"ext_pcx_show_intensity",
data.show_intensity,
),
bool_prop(t!("Show Cropping").as_ref(), "ext_pcx_show_cropping", data.show_cropping),
ro_prop(t!("Unknown Flags").as_ref(),
"ext_pcx_unknown",
format!("{}, {}", data.unknown_bl0, data.unknown_bl1),
),
ro_prop(t!("Stylization Type").as_ref(),
"ext_pcx_stylization",
data.stylization_type.to_string(),
),
text_prop(t!("Intensity Color Scheme").as_ref(),
"ext_pcx_intensity_scheme",
&data.intensity_color_scheme,
),
text_prop(t!("Current Color Scheme").as_ref(),
"ext_pcx_current_scheme",
&data.current_color_scheme,
),
text_prop(t!("Classification Scheme").as_ref(),
"ext_pcx_class_scheme",
&data.classification_color_scheme,
),
edit_prop(t!("Elevation Min").as_ref(), "ext_pcx_elevation_min", data.elevation_min),
edit_prop(t!("Elevation Max").as_ref(), "ext_pcx_elevation_max", data.elevation_max),
ro_prop(t!("Intensity Range").as_ref(),
"ext_pcx_intensity_range",
format!("{}..{}", data.intensity_min, data.intensity_max),
),
ro_prop(t!("Out Of Range Behavior").as_ref(),
"ext_pcx_out_of_range",
format!(
"intensity {}; elevation {}",
data.intensity_out_of_range_behavior,
data.elevation_out_of_range_behavior
),
),
bool_prop(t!("Fixed Elevation Range").as_ref(),
"ext_pcx_fixed_range",
data.elevation_apply_to_fixed_range,
),
bool_prop(t!("Intensity Gradient").as_ref(),
"ext_pcx_intensity_gradient",
data.intensity_as_gradient,
),
bool_prop(t!("Elevation Gradient").as_ref(),
"ext_pcx_elevation_gradient",
data.elevation_as_gradient,
),
ro_prop(t!("Croppings").as_ref(), "ext_pcx_croppings", crops),
plain_row(t!("Insertion point X").as_ref(), "ext_pcx_ix", origin.x),
plain_row(t!("Insertion point Y").as_ref(), "ext_pcx_iy", origin.y),
plain_row(t!("Insertion point Z").as_ref(), "ext_pcx_iz", origin.z),
plain_row(t!("Rotation").as_ref(), "ext_pcx_rotation", rotation.to_degrees()),
plain_row(t!("Scale").as_ref(), "ext_pcx_scale", scale),
],
},
PropSection {
title: t!("Misc").into_owned(),
props: vec![
text_prop(t!("Name").as_ref(), "ext_pcx_name", &data.name),
ro_prop(t!("Saved path").as_ref(), "ext_pcx_path", String::new()),
yes_no_row(t!("Show cropped").as_ref(), "ext_pcx_show_cropping", data.show_cropping),
yes_no_row(t!("Locked").as_ref(), "ext_pcx_locked", data.locked),
yes_no_row(t!("Geolocate").as_ref(), "ext_pcx_geolocate", data.show_intensity),
ro_prop(t!("Segmentation").as_ref(), "ext_pcx_segmentation", t!("No").into_owned()),
ro_prop(t!("Unit").as_ref(), "ext_pcx_unit", String::new()),
ro_prop(t!("Unit factor").as_ref(), "ext_pcx_unit_factor", String::new()),
],
},
]
}
/// Writes a point cloud row, after `validate_point_cloud_property` passed
/// it; a locked cloud takes only its Locked row (the caller sees to that).
fn apply_point_cloud_ex_prop(data: &mut PointCloudExData, field: &str, value: &str) {
let number = parse_f64(value);
match field {
"ext_pcx_name" => data.name = value.to_string(),
"ext_pcx_locked" => data.locked = is_yes(value),
"ext_pcx_show_cropping" => data.show_cropping = is_yes(value),
// Geolocation needs the drawing's geographic location, which a cloud
// attached without one never gets: the row stays No.
"ext_pcx_geolocate" => {}
"ext_pcx_stylization" => {
if let Some((_, stylization)) = POINT_CLOUD_STYLIZATIONS
.iter()
.find(|(name, _)| value == t!(*name).as_ref() || value.eq_ignore_ascii_case(name))
{
data.stylization_type = *stylization;
// Intensity and elevation start from their default schemes,
// Spectrum and Earth.
let default = match stylization {
5 => Some(POINT_CLOUD_RAMPS[6].1),
4 => Some(POINT_CLOUD_RAMPS[1].1),
_ => None,
};
if let Some(default) = default {
let slot = point_cloud_scheme_slot(data);
if slot.is_empty() {
*slot = default.to_string();
}
}
}
}
"ext_pcx_color_scheme" => {
if let Some((_, guid)) = POINT_CLOUD_RAMPS.iter().find(|(name, _)| *name == value) {
*point_cloud_scheme_slot(data) = guid.to_string();
}
}
"ext_pcx_ix" | "ext_pcx_iy" | "ext_pcx_iz" => {
if let Some(number) = number {
match field {
"ext_pcx_ix" => data.ucs_origin.x = number,
"ext_pcx_iy" => data.ucs_origin.y = number,
_ => data.ucs_origin.z = number,
}
}
}
// Turns the placement about its own Z axis to the angle given.
"ext_pcx_rotation" => {
if let Some(degrees) = number {
let (current, _) = point_cloud_rotation_scale(data);
let axis = normalized(data.ucs_z_direction, Vector3::UNIT_Z);
let turn = Transform::from_rotation(axis, degrees.to_radians() - current);
data.ucs_x_direction = turn.apply_rotation(data.ucs_x_direction);
data.ucs_y_direction = turn.apply_rotation(data.ucs_y_direction);
}
}
"ext_pcx_scale" => {
let (_, current) = point_cloud_rotation_scale(data);
if let Some(scale) = number.filter(|scale| *scale > 0.0 && current > 0.0) {
let factor = scale / current;
data.ucs_x_direction = data.ucs_x_direction * factor;
data.ucs_y_direction = data.ucs_y_direction * factor;
data.ucs_z_direction = data.ucs_z_direction * factor;
}
}
_ => {}
}
}
/// Rejects what the reference rejects before a point cloud row is written.
pub(crate) fn validate_point_cloud_property(field: &str, value: &str) -> Result<(), &'static str> {
if field == "ext_pcx_scale" && !parse_f64(value).is_some_and(|scale| scale > 0.0) {
return Err("Value must be positive");
}
Ok(())
}
fn semantic_properties(
properties: &[codec::objects::SemanticProperty],
) -> String {
@@ -1504,27 +1674,7 @@ fn apply_geom_prop(entity: &mut ExtendedEntity, field: &str, value: &str) {
"ext_pc_high_threshold" => set_f64(value, &mut data.high_intensity_threshold),
_ => {}
},
ExtendedEntityData::PointCloudEx(data) => match field {
"ext_pcx_name" => data.name = value.to_string(),
"ext_pcx_locked" => data.locked = !data.locked,
"ext_pcx_show_intensity" => data.show_intensity = !data.show_intensity,
"ext_pcx_show_cropping" => data.show_cropping = !data.show_cropping,
"ext_pcx_intensity_scheme" => data.intensity_color_scheme = value.to_string(),
"ext_pcx_current_scheme" => data.current_color_scheme = value.to_string(),
"ext_pcx_class_scheme" => data.classification_color_scheme = value.to_string(),
"ext_pcx_elevation_min" => set_f64(value, &mut data.elevation_min),
"ext_pcx_elevation_max" => set_f64(value, &mut data.elevation_max),
"ext_pcx_fixed_range" => {
data.elevation_apply_to_fixed_range = !data.elevation_apply_to_fixed_range
}
"ext_pcx_intensity_gradient" => {
data.intensity_as_gradient = !data.intensity_as_gradient
}
"ext_pcx_elevation_gradient" => {
data.elevation_as_gradient = !data.elevation_as_gradient
}
_ => {}
},
ExtendedEntityData::PointCloudEx(data) => apply_point_cloud_ex_prop(data, field, value),
_ => {}
}
}
@@ -1657,32 +1807,11 @@ fn grips(entity: &ExtendedEntity) -> Vec<GripDef> {
),
),
],
ExtendedEntityData::PointCloudEx(data) => vec![
square_grip(
0,
glam::DVec3::new(
data.extents_min.x,
data.extents_min.y,
data.extents_min.z,
),
),
square_grip(
1,
glam::DVec3::new(
data.extents_max.x,
data.extents_max.y,
data.extents_max.z,
),
),
center_grip(
2,
glam::DVec3::new(
(data.extents_min.x + data.extents_max.x) * 0.5,
(data.extents_min.y + data.extents_max.y) * 0.5,
(data.extents_min.z + data.extents_max.z) * 0.5,
),
),
],
// One grip, at the insertion point: it moves the cloud.
ExtendedEntityData::PointCloudEx(data) => vec![square_grip(
0,
glam::DVec3::new(data.ucs_origin.x, data.ucs_origin.y, data.ucs_origin.z),
)],
_ => Vec::new(),
}
}
@@ -1763,22 +1892,8 @@ fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) {
}
}
ExtendedEntityData::PointCloudEx(data) => {
let before = (data.extents_min + data.extents_max) * 0.5;
move_extents(
&mut data.extents_min,
&mut data.extents_max,
grip_id,
apply,
);
if grip_id == 2 {
let after = (data.extents_min + data.extents_max) * 0.5;
let delta = after - before;
data.ucs_origin = data.ucs_origin + delta;
for crop in &mut data.croppings {
for point in &mut crop.points {
*point = *point + delta;
}
}
if grip_id == 0 {
apply_point(&mut data.ucs_origin, apply);
}
}
_ => {}
@@ -2080,37 +2195,14 @@ fn apply_transform(entity: &mut ExtendedEntity, requested: &EntityTransform) {
}
}
}
// The scan keeps its own coordinates (extents and crop boundaries
// stay as they are); the placement takes the transform, its axes
// carrying the scale.
ExtendedEntityData::PointCloudEx(data) => {
data.ucs_origin = transform.apply(data.ucs_origin);
data.ucs_x_direction = normalized(
transform.apply_rotation(data.ucs_x_direction),
Vector3::UNIT_X,
);
data.ucs_y_direction = normalized(
transform.apply_rotation(data.ucs_y_direction),
Vector3::UNIT_Y,
);
data.ucs_z_direction = normalized(
transform.apply_rotation(data.ucs_z_direction),
Vector3::UNIT_Z,
);
(data.extents_min, data.extents_max) =
transform_extents(data.extents_min, data.extents_max, &transform);
for crop in &mut data.croppings {
crop.plane =
normalized(transform.apply_rotation(crop.plane), Vector3::UNIT_Z);
crop.x_direction = normalized(
transform.apply_rotation(crop.x_direction),
Vector3::UNIT_X,
);
crop.y_direction = normalized(
transform.apply_rotation(crop.y_direction),
Vector3::UNIT_Y,
);
for point in &mut crop.points {
*point = transform.apply(*point);
}
}
data.ucs_x_direction = transform.apply_rotation(data.ucs_x_direction);
data.ucs_y_direction = transform.apply_rotation(data.ucs_y_direction);
data.ucs_z_direction = transform.apply_rotation(data.ucs_z_direction);
}
_ => {}
}
+6
View File
@@ -90,6 +90,12 @@ pub fn ui_name_or_class(e: &EntityType) -> String {
) {
return "Section Plane".to_string();
}
if matches!(
entity.data,
codec::entities::ExtendedEntityData::PointCloudEx(_)
) {
return "Point Cloud".to_string();
}
return entity.class_name().to_string();
}
if let EntityType::Unknown(u) = e {
+2 -10
View File
@@ -1432,11 +1432,7 @@ fn class_object_section(
"Ramps",
bounded_join(
value.color_ramps.iter().map(|ramp| {
format!(
"v{} [{}]",
ramp.class_version,
ramp.color_schemes.join(", ")
)
format!("{} v{} ({} colours)", ramp.name, ramp.class_version, ramp.colors.len())
}),
32,
),
@@ -1445,11 +1441,7 @@ fn class_object_section(
"Classification Ramps",
bounded_join(
value.classification_color_ramps.iter().map(|ramp| {
format!(
"v{} [{}]",
ramp.class_version,
ramp.color_schemes.join(", ")
)
format!("{} v{} ({} colours)", ramp.name, ramp.class_version, ramp.colors.len())
}),
32,
),