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

+7
View File
@@ -196,6 +196,13 @@ impl CadCommand for SelectObjectsCommand {
self.associative_dimensions_only
}
fn selection_drops_locked_point_clouds(&self) -> bool {
matches!(
self.pending_cmd.as_str(),
"MOVE" | "3DMOVE" | "ROTATE" | "3DROTATE" | "SCALE" | "MIRROR" | "MIRROR3D" | "STRETCH"
)
}
// Clickable selection keywords (#426, #596). Window and Crossing fix the
// sense of the next box, which dragging would otherwise take from the
// direction the corner travels; All takes the whole space; Add and Remove
+2 -1
View File
@@ -16,7 +16,8 @@ pub(crate) mod picker;
pub mod minsert;
mod mview_block;
mod open_obj;
mod pc_attach;
pub(crate) mod pc_attach;
pub(crate) mod pc_crop;
pub(crate) mod pdf_attach;
pub(crate) mod pdf_clip;
pub(crate) mod pdf_import;
+205
View File
@@ -1,4 +1,23 @@
// -POINTCLOUDATTACH — attach a point cloud: a scan (.rcs) or a project of
// scans (.rcp).
//
// Path to point cloud file to attach:
// Specify insertion point <0,0>: Current insertion point: X = 5.5, Y = 2, Z = 3
// Specify scale factor <1>: Current scale factor: 2.500000
// Specify rotation angle <0>: Current rotate angle: 30
// 1 point cloud attached
//
// The definition (one per file, in ACAD_POINTCLOUD_EX_DICT) is created with
// the cloud when it is placed, so a cancelled attach leaves nothing and undo
// removes both.
use std::path::PathBuf;
use glam::DVec3;
use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/pc_attach.svg"));
pub fn tool() -> ToolDef {
ToolDef {
@@ -8,3 +27,189 @@ pub fn tool() -> ToolDef {
event: ModuleEvent::Command("POINTCLOUDATTACH".to_string()),
}
}
/// A point cloud to attach: the file, where and how it is placed.
#[derive(Clone, Debug)]
pub struct PointCloudPlacement {
pub path: PathBuf,
pub insertion: DVec3,
/// The placement's axes in the drawing, each as long as the scale.
pub axes: [DVec3; 3],
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Step {
Path,
Insertion,
Scale,
Rotation,
}
pub struct PointCloudAttachCommand {
step: Step,
path: PathBuf,
insertion: DVec3,
scale: f64,
plane: WorkingPlane,
}
/// A number as the reference echoes a coordinate or an angle: no trailing
/// zeros.
fn plain(value: f64) -> String {
crate::entities::underlay::plain_number(value)
}
impl PointCloudAttachCommand {
pub fn new() -> Self {
Self {
step: Step::Path,
path: PathBuf::new(),
insertion: DVec3::ZERO,
scale: 1.0,
plane: WorkingPlane::default(),
}
}
/// Starts at the insertion point, the file already chosen.
pub fn with_file(path: PathBuf) -> Self {
let mut command = Self::new();
command.path = path;
command.step = Step::Insertion;
command
}
fn accept_path(&mut self, text: &str) -> CmdResult {
let text = text.trim().trim_matches('"');
if text.is_empty() {
return CmdResult::ReportError("Filename cannot be blank".to_string());
}
let path = PathBuf::from(text);
// A file that is not there is asked for again.
if !path.is_file() {
return CmdResult::NeedPoint;
}
if crate::scene::model::point_cloud::load(&path).is_none() {
// Said, and the command ends.
return CmdResult::Measurement(
"All scans are not found or invalid.\nAttach point cloud failed".to_string(),
);
}
self.path = path;
self.step = Step::Insertion;
CmdResult::NeedPoint
}
fn accept_insertion(&mut self, point: DVec3) -> CmdResult {
self.insertion = point;
self.step = Step::Scale;
let local = self.plane.to_local(point);
CmdResult::ReportMeasurement(format!(
"Current insertion point: X = {}, Y = {}, Z = {}",
plain(local.x),
plain(local.y),
plain(local.z)
))
}
fn accept_scale(&mut self, text: &str) -> CmdResult {
let scale = if text.trim().is_empty() {
1.0
} else {
match crate::entities::common::parse_f64(text) {
Some(scale) => scale,
None => {
return CmdResult::ReportError(
"Requires numeric distance or second point.".to_string(),
)
}
}
};
if scale <= 0.0 {
return CmdResult::ReportError("Value must be positive and nonzero.".to_string());
}
self.scale = scale;
self.step = Step::Rotation;
CmdResult::ReportMeasurement(format!("Current scale factor: {scale:.6}"))
}
fn accept_rotation(&mut self, degrees: f64) -> CmdResult {
let (sin, cos) = degrees.to_radians().sin_cos();
let x = (self.plane.x * cos + self.plane.y * sin) * self.scale;
let y = (self.plane.y * cos - self.plane.x * sin) * self.scale;
CmdResult::AttachPointCloud {
placement: PointCloudPlacement {
path: self.path.clone(),
insertion: self.insertion,
axes: [x, y, self.plane.z * self.scale],
},
message: format!("Current rotate angle: {}", plain(degrees)),
}
}
}
impl CadCommand for PointCloudAttachCommand {
fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane;
}
fn name(&self) -> &'static str {
"POINTCLOUDATTACH"
}
fn prompt(&self) -> String {
match self.step {
Step::Path => "Path to point cloud file to attach:",
Step::Insertion => "Specify insertion point <0,0>:",
Step::Scale => "Specify scale factor <1>:",
Step::Rotation => "Specify rotation angle <0>:",
}
.to_string()
}
fn input_kind(&self) -> InputKind {
match self.step {
Step::Path => InputKind::FreeText,
Step::Insertion => InputKind::Point,
_ => InputKind::SingleToken,
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match self.step {
Step::Insertion => self.accept_insertion(pt),
// A second point gives the angle from the insertion point.
Step::Rotation => {
let d = self.plane.vector_to_local(pt - self.insertion);
self.accept_rotation(d.y.atan2(d.x).to_degrees())
}
_ => CmdResult::NeedPoint,
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
Some(match self.step {
Step::Path => self.accept_path(text),
Step::Insertion => CmdResult::ReportError("Invalid point.".to_string()),
Step::Scale => self.accept_scale(text),
Step::Rotation => match crate::entities::common::parse_f64(text) {
Some(degrees) => self.accept_rotation(degrees),
None => CmdResult::ReportError(
"Requires valid numeric angle or second point.".to_string(),
),
},
})
}
fn on_enter(&mut self) -> CmdResult {
match self.step {
Step::Path => self.accept_path(""),
Step::Insertion => self.accept_insertion(self.plane.origin),
Step::Scale => self.accept_scale(""),
Step::Rotation => self.accept_rotation(0.0),
}
}
}
inventory::submit!(crate::command::CommandRegistration {
names: &["POINTCLOUDATTACH", "-POINTCLOUDATTACH"]
});
+356
View File
@@ -0,0 +1,356 @@
// POINTCLOUDCROP — crop a point cloud to a rectangle, polygon or circle.
//
// Select point cloud:
// Specify first corner point or [Polygon/Circular]:
// (with crops: [Polygon/Circular/Invert/Remove last/ON/OFF])
// Specify other corner point:
// Specify first point: / Specify next point or [Undo]: (Polygon)
// Specify center point: / Specify radius: (Circular)
// Keep points inside or outside? [Inside/Outside] <Inside>:
//
// Crops are kept in the cloud's own coordinates, on the plane of its X and
// Y axes: a rectangle as its four corners, a polygon as its points, a
// circle as its centre and a point on it. They add up; Invert flips them,
// Remove last drops the newest, ON / OFF shows or hides them.
use std::sync::Mutex;
use codec::entities::{ExtendedEntity, ExtendedEntityData, PointCloudExCrop, PointCloudExData};
use codec::types::{Handle, Vector3};
use codec::EntityType;
use glam::DVec3;
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind};
use crate::scene::model::wire_model::WireModel;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Step {
Select,
First,
RectSecond,
PolyFirst,
PolyNext,
Center,
Radius,
Keep,
}
/// The crop being drawn: its type and points in the drawing.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Shape {
Rectangle = 1,
Polygon = 2,
Circle = 3,
}
/// Inside or outside, as last chosen: the next crop offers it.
static LAST_INSIDE: Mutex<bool> = Mutex::new(true);
fn last_inside() -> bool {
LAST_INSIDE.lock().map(|v| *v).unwrap_or(true)
}
const REQUIRED: &str = "Point or option keyword required.";
pub struct PointCloudCropCommand {
step: Step,
handle: Handle,
cloud: Option<ExtendedEntity>,
picked: Option<EntityType>,
shape: Shape,
points: Vec<DVec3>,
}
impl PointCloudCropCommand {
pub fn new() -> Self {
Self {
step: Step::Select,
handle: Handle::NULL,
cloud: None,
picked: None,
shape: Shape::Rectangle,
points: Vec::new(),
}
}
fn data(&self) -> Option<&PointCloudExData> {
match &self.cloud.as_ref()?.data {
ExtendedEntityData::PointCloudEx(data) => Some(data),
_ => None,
}
}
fn has_crops(&self) -> bool {
self.data().is_some_and(|data| !data.croppings.is_empty())
}
/// The cloud with `change` made, and the command done.
fn finish(&self, change: impl FnOnce(&mut PointCloudExData)) -> CmdResult {
let Some(mut cloud) = self.cloud.clone() else {
return CmdResult::Cancel;
};
if let ExtendedEntityData::PointCloudEx(data) = &mut cloud.data {
change(data);
}
CmdResult::UpdateEntityAndFinish { handle: self.handle, entity: EntityType::Extended(cloud) }
}
/// A point of the drawing in the cloud's own coordinates.
fn to_local(data: &PointCloudExData, p: DVec3) -> Vector3 {
let v = |a: Vector3| DVec3::new(a.x, a.y, a.z);
let axes = glam::DMat3::from_cols(v(data.ucs_x_direction), v(data.ucs_y_direction), v(data.ucs_z_direction));
let local = axes.inverse() * (p - v(data.ucs_origin));
// On the plane of the cloud's X and Y axes.
Vector3::new(local.x, local.y, 0.0)
}
fn option(&mut self, text: &str) -> CmdResult {
let crops = self.has_crops();
match text.trim().to_ascii_uppercase().as_str() {
"P" | "POLYGON" => {
self.shape = Shape::Polygon;
self.step = Step::PolyFirst;
CmdResult::NeedPoint
}
"C" | "CIRCULAR" => {
self.shape = Shape::Circle;
self.step = Step::Center;
CmdResult::NeedPoint
}
"I" | "INVERT" if crops => self.finish(|data| {
for crop in &mut data.croppings {
crop.inverted = !crop.inverted;
}
}),
"R" | "REMOVE" | "REMOVE LAST" if crops => self.finish(|data| {
data.croppings.pop();
}),
"ON" if crops => self.finish(|data| data.show_cropping = true),
"OFF" if crops => self.finish(|data| data.show_cropping = false),
_ => CmdResult::ReportError(REQUIRED.to_string()),
}
}
fn keep(&mut self, text: &str) -> CmdResult {
let inside = match text.trim().to_ascii_uppercase().as_str() {
"" => last_inside(),
"I" | "INSIDE" => true,
"O" | "OUTSIDE" => false,
_ => return CmdResult::ReportError("Invalid option keyword.".to_string()),
};
if let Ok(mut last) = LAST_INSIDE.lock() {
*last = inside;
}
let Some(data) = self.data() else {
return CmdResult::Cancel;
};
let points: Vec<Vector3> = match self.shape {
Shape::Rectangle => {
let (a, b) = (self.points[0], self.points[1]);
[DVec3::new(a.x, a.y, a.z), DVec3::new(b.x, a.y, a.z), DVec3::new(b.x, b.y, a.z), DVec3::new(a.x, b.y, a.z)]
.into_iter()
.map(|p| Self::to_local(data, p))
.collect()
}
_ => self.points.iter().map(|p| Self::to_local(data, *p)).collect(),
};
let crop = PointCloudExCrop {
crop_type: self.shape as i16,
inside,
inverted: false,
plane: Vector3::ZERO,
x_direction: Vector3::UNIT_X,
y_direction: Vector3::UNIT_Y,
points,
};
self.finish(move |data| {
data.croppings.push(crop);
data.show_cropping = true;
})
}
fn to_keep(&mut self) -> CmdResult {
self.step = Step::Keep;
CmdResult::NeedPoint
}
}
impl CadCommand for PointCloudCropCommand {
fn name(&self) -> &'static str {
"POINTCLOUDCROP"
}
fn prompt(&self) -> String {
match self.step {
Step::Select => "Select point cloud:".to_string(),
Step::First if self.has_crops() => {
"Specify first corner point or [Polygon/Circular/Invert/Remove last/ON/OFF]:".to_string()
}
Step::First => "Specify first corner point or [Polygon/Circular]:".to_string(),
Step::RectSecond => "Specify other corner point:".to_string(),
Step::PolyFirst => "Specify first point:".to_string(),
Step::PolyNext => "Specify next point or [Undo]:".to_string(),
Step::Center => "Specify center point:".to_string(),
Step::Radius => "Specify radius:".to_string(),
Step::Keep => format!(
"Keep points inside or outside? [Inside/Outside] <{}>:",
if last_inside() { "Inside" } else { "Outside" }
),
}
}
fn options(&self) -> Vec<CmdOption> {
match self.step {
Step::First => {
let mut options = vec![CmdOption::new("Polygon", "P"), CmdOption::new("Circular", "C")];
if self.has_crops() {
options.extend([
CmdOption::new("Invert", "I"),
CmdOption::new("Remove last", "R"),
CmdOption::new("ON", "ON"),
CmdOption::new("OFF", "OFF"),
]);
}
options
}
Step::PolyNext => vec![CmdOption::new("Undo", "U")],
Step::Keep => vec![CmdOption::new("Inside", "I"), CmdOption::new("Outside", "O")],
_ => Vec::new(),
}
}
fn input_kind(&self) -> InputKind {
match self.step {
Step::Keep => InputKind::SingleToken,
_ => InputKind::Point,
}
}
fn point_step_accepts_keywords(&self) -> bool {
matches!(self.step, Step::First | Step::PolyNext | Step::Radius)
}
fn needs_entity_pick(&self) -> bool {
self.step == Step::Select
}
fn inject_before_entity_pick(&self) -> bool {
true
}
fn inject_picked_entity(&mut self, entity: EntityType) {
self.picked = Some(entity);
}
fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult {
match self.picked.take() {
Some(EntityType::Extended(cloud)) if matches!(cloud.data, ExtendedEntityData::PointCloudEx(_)) => {
self.handle = handle;
self.cloud = Some(cloud);
self.step = Step::First;
CmdResult::NeedPoint
}
// Anything else: the prompt again.
_ => CmdResult::NeedPoint,
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match self.step {
Step::First => {
self.points = vec![pt];
self.step = Step::RectSecond;
CmdResult::NeedPoint
}
Step::RectSecond => {
self.points.push(pt);
self.to_keep()
}
Step::PolyFirst => {
self.points = vec![pt];
self.step = Step::PolyNext;
CmdResult::NeedPoint
}
Step::PolyNext => {
self.points.push(pt);
CmdResult::NeedPoint
}
Step::Center => {
self.points = vec![pt];
self.step = Step::Radius;
CmdResult::NeedPoint
}
Step::Radius => {
self.points.push(pt);
self.to_keep()
}
_ => CmdResult::NeedPoint,
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
Some(match self.step {
Step::First => self.option(text),
Step::PolyNext => match text.trim().to_ascii_uppercase().as_str() {
"U" | "UNDO" if self.points.len() > 1 => {
self.points.pop();
CmdResult::NeedPoint
}
_ => CmdResult::ReportError(REQUIRED.to_string()),
},
// A radius typed: a point that far along the X axis.
Step::Radius => match crate::entities::common::parse_f64(text).filter(|r| *r > 0.0) {
Some(radius) => {
let center = self.points[0];
self.points.push(center + DVec3::new(radius, 0.0, 0.0));
self.to_keep()
}
None => CmdResult::ReportError(REQUIRED.to_string()),
},
Step::Keep => self.keep(text),
_ => return None,
})
}
fn on_enter(&mut self) -> CmdResult {
match self.step {
// A polygon of three points or more is closed by Enter.
Step::PolyNext if self.points.len() >= 3 => self.to_keep(),
Step::Keep => self.keep(""),
Step::Select => CmdResult::Cancel,
_ => CmdResult::ReportError(REQUIRED.to_string()),
}
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
let outline: Vec<[f64; 3]> = match self.step {
Step::RectSecond => {
let a = self.points[0];
[a, DVec3::new(pt.x, a.y, a.z), DVec3::new(pt.x, pt.y, a.z), DVec3::new(a.x, pt.y, a.z), a]
.map(|p| p.to_array())
.to_vec()
}
Step::PolyNext => self
.points
.iter()
.copied()
.chain([pt, self.points[0]])
.map(|p| p.to_array())
.collect(),
Step::Radius => {
let c = self.points[0];
let r = (pt - c).truncate().length();
(0..=64)
.map(|k| {
let a = k as f64 / 64.0 * std::f64::consts::TAU;
(c + DVec3::new(r * a.cos(), r * a.sin(), 0.0)).to_array()
})
.collect()
}
_ => return None,
};
Some(WireModel::solid_f64("pc_crop_boundary".into(), outline, WireModel::CYAN, false))
}
}
inventory::submit!(crate::command::CommandRegistration { names: &["POINTCLOUDCROP"] });