forked from 1b-agent/opencadstudio-upstream
Merge pull request #1553 from ramox81/codex/pointcloud-attach
Point clouds: drawn from their scans, attached, cropped and stylized. The fork's codec patch is resolved to the upstream pins: the point cloud records landed in opencadcodec 5ef9376, carried through opencadkernel 68eeda1 and opencadgraph e204966. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
3dcebf0dc6
81 files changed
+9162
-305
No files matched your search
Vendored
+6
-1
@@ -105,11 +105,16 @@ pub fn visualization_section(entity: &EntityType) -> Option<PropSection> {
|
||||
| EntityType::Seqend(_)
|
||||
| EntityType::Leader(_)
|
||||
| EntityType::Wipeout(_)
|
||||
// The reference lists no 3D group for an underlay.
|
||||
// The reference lists no 3D group for an underlay; a point
|
||||
// cloud's is its own (stylization and colour scheme).
|
||||
| EntityType::Underlay(_)
|
||||
| EntityType::Unknown(_)
|
||||
// Non-plotting drawing-view border: never rendered, no properties.
|
||||
| EntityType::ViewBorder(_)
|
||||
) || matches!(
|
||||
entity,
|
||||
EntityType::Extended(extended)
|
||||
if matches!(extended.data, codec::entities::ExtendedEntityData::PointCloudEx(_))
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -564,6 +564,21 @@ fn point_cloud_wires(
|
||||
let RenderObject::Lines(body_points) = rendered.object else {
|
||||
return None;
|
||||
};
|
||||
// A cloud whose scan file is found draws its points instead of its
|
||||
// extents box; the box stays behind, unseen, to pick the cloud, with its
|
||||
// faces so a click among the points selects it too.
|
||||
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 {
|
||||
// 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]] {
|
||||
tris.extend([c[a], c[b], c[e], c[a], c[e], c[d]]);
|
||||
}
|
||||
(pick_tris, pick_tris_low) = points_to_ds(tris);
|
||||
}
|
||||
let (points, points_low) = points_to_ds(body_points);
|
||||
let mut wires = vec![WireModel {
|
||||
bg_adapt: None,
|
||||
@@ -572,14 +587,14 @@ fn point_cloud_wires(
|
||||
pattern_stations: Vec::new(),
|
||||
world_width: 0.0,
|
||||
depth_override: None,
|
||||
display_visible: true,
|
||||
display_visible: shown,
|
||||
snap_only: false,
|
||||
plot_visible: true,
|
||||
plot_visible: shown,
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
render_instance: None,
|
||||
pick_tris: Vec::new(),
|
||||
pick_tris_low: Vec::new(),
|
||||
pick_tris,
|
||||
pick_tris_low,
|
||||
dash_from_start: false,
|
||||
dash_align_end: None,
|
||||
text_verts: Vec::new(),
|
||||
|
||||
+164
-7
@@ -2186,6 +2186,11 @@ pub struct Scene {
|
||||
frozen_hatch_cache: RefCell<HashMap<(Handle, String, u64), (u64, u64, Arc<Vec<HatchModel>>)>>,
|
||||
frozen_wipeout_cache: RefCell<HashMap<(Handle, String, u64), (u64, Arc<Vec<HatchModel>>)>>,
|
||||
frozen_image_cache: RefCell<HashMap<(Handle, u64), (u64, Arc<Vec<ImageModel>>)>>,
|
||||
/// Point clouds shown by the model content, keyed by target block and
|
||||
/// frozen-layer signature, per geometry epoch.
|
||||
point_cloud_cache: RefCell<
|
||||
HashMap<(Handle, u64), (u64, Arc<crate::scene::model::point_cloud::PointCloudSet>)>,
|
||||
>,
|
||||
frozen_mesh_cache: RefCell<HashMap<(Handle, String, u64), (u64, Arc<Vec<MeshLodSet>>)>>,
|
||||
/// Viewports that carry layer color/alpha/linetype/lineweight overrides.
|
||||
/// Cached per geometry epoch so ordinary viewports can share render data.
|
||||
@@ -2648,6 +2653,7 @@ impl Scene {
|
||||
frozen_hatch_cache: RefCell::new(HashMap::default()),
|
||||
frozen_wipeout_cache: RefCell::new(HashMap::default()),
|
||||
frozen_image_cache: RefCell::new(HashMap::default()),
|
||||
point_cloud_cache: RefCell::new(HashMap::default()),
|
||||
frozen_mesh_cache: RefCell::new(HashMap::default()),
|
||||
viewport_style_override_cache: RefCell::new(None),
|
||||
insert_hatch_cache: RefCell::new(None),
|
||||
@@ -8338,6 +8344,135 @@ impl Scene {
|
||||
arc
|
||||
}
|
||||
|
||||
/// Point clouds of the model content, with a content viewport's frozen
|
||||
/// layers removed. The paper sheet shows none. The set is rebuilt per
|
||||
/// geometry epoch; unchanged clouds keep their placed points (and GPU
|
||||
/// buffers) by key.
|
||||
pub(super) fn point_clouds_for_viewport(
|
||||
&self,
|
||||
frozen: &HashSet<Handle>,
|
||||
paper_sheet: bool,
|
||||
) -> Arc<crate::scene::model::point_cloud::PointCloudSet> {
|
||||
use crate::scene::model::point_cloud;
|
||||
static EMPTY: std::sync::OnceLock<Arc<point_cloud::PointCloudSet>> =
|
||||
std::sync::OnceLock::new();
|
||||
let empty = || Arc::clone(EMPTY.get_or_init(Default::default));
|
||||
if paper_sheet {
|
||||
return empty();
|
||||
}
|
||||
let target_block = self.content_render_block_handle();
|
||||
let key = (target_block, Self::frozen_layers_sig(frozen));
|
||||
if let Some((epoch, arc)) = self.point_cloud_cache.borrow().get(&key) {
|
||||
if *epoch == self.geometry_epoch {
|
||||
return Arc::clone(arc);
|
||||
}
|
||||
}
|
||||
let arc = if point_cloud::any_definition(&self.document) {
|
||||
let depths = rustc_hash::FxHashMap::default();
|
||||
let graph = render_graph::RenderSceneGraph::new(
|
||||
&self.document,
|
||||
(!frozen.is_empty()).then_some(frozen),
|
||||
None,
|
||||
true,
|
||||
&depths,
|
||||
);
|
||||
let mut clouds = Vec::new();
|
||||
graph.walk_root(
|
||||
self.render_scene_root(target_block),
|
||||
|entity, context| {
|
||||
context.is_instanced() || !self.entity_temporarily_hidden(entity.common().handle)
|
||||
},
|
||||
|entity, context| {
|
||||
let EntityType::Extended(extended) = entity else {
|
||||
return;
|
||||
};
|
||||
let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let transform = context.is_instanced().then_some(&context.transform);
|
||||
// Object Color stylization draws the cloud in its own colour.
|
||||
let common = entity.common();
|
||||
let color = if matches!(common.color, codec::types::Color::ByLayer) {
|
||||
self.document
|
||||
.layers
|
||||
.get(&common.layer)
|
||||
.map(|layer| layer.color.clone())
|
||||
.unwrap_or(common.color.clone())
|
||||
} else {
|
||||
common.color.clone()
|
||||
};
|
||||
let object_color = color.rgb().map_or([255; 3], |(r, g, b)| [r, g, b]);
|
||||
clouds.extend(point_cloud::placed(
|
||||
&self.document,
|
||||
data,
|
||||
object_color,
|
||||
transform,
|
||||
&point_cloud::hidden(data),
|
||||
));
|
||||
},
|
||||
);
|
||||
if clouds.is_empty() {
|
||||
empty()
|
||||
} else {
|
||||
Arc::new(point_cloud::PointCloudSet {
|
||||
point_size: point_cloud::point_size(&self.document),
|
||||
clouds,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
empty()
|
||||
};
|
||||
self.point_cloud_cache
|
||||
.borrow_mut()
|
||||
.insert(key, (self.geometry_epoch, Arc::clone(&arc)));
|
||||
arc
|
||||
}
|
||||
|
||||
/// Wireframe 2D draws no cloud points: each shown cloud whose file is
|
||||
/// found draws its extents box and a message instead, unless
|
||||
/// POINTCLOUD2DVSDISPLAY is 1.
|
||||
// ponytail: model-space clouds only, rebuilt per frame; cache by epoch if
|
||||
// drawings with many clouds make it show.
|
||||
pub(super) fn point_cloud_2d_wires(&self) -> Vec<WireModel> {
|
||||
use crate::scene::model::point_cloud::{resolve_source, setting, setting_value};
|
||||
if !crate::scene::model::point_cloud::any_definition(&self.document)
|
||||
|| setting("POINTCLOUD2DVSDISPLAY").is_some_and(|s| setting_value(&self.document, s) == 1)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let mut wires = Vec::new();
|
||||
for entity in self.document.entities() {
|
||||
let EntityType::Extended(extended) = entity else {
|
||||
continue;
|
||||
};
|
||||
let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data else {
|
||||
continue;
|
||||
};
|
||||
let common = entity.common();
|
||||
if common.invisible
|
||||
|| self.entity_temporarily_hidden(common.handle)
|
||||
|| self.document.layers.get(&common.layer).is_some_and(|layer| layer.is_off() || layer.is_frozen())
|
||||
|| resolve_source(&self.document, data).is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let color = if matches!(common.color, codec::types::Color::ByLayer) {
|
||||
self.document.layers.get(&common.layer).map(|layer| layer.color.clone()).unwrap_or(common.color.clone())
|
||||
} else {
|
||||
common.color.clone()
|
||||
};
|
||||
let (r, g, b) = color.rgb().unwrap_or((255, 255, 255));
|
||||
wires.push(WireModel::solid_f64(
|
||||
common.handle.value().to_string(),
|
||||
crate::entities::extended::point_cloud_2d_style_lines(data),
|
||||
[f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, 1.0],
|
||||
false,
|
||||
));
|
||||
}
|
||||
wires
|
||||
}
|
||||
|
||||
/// Image / OLE models for a content viewport, with its frozen layers removed.
|
||||
pub(super) fn images_for_viewport(
|
||||
&self,
|
||||
@@ -8408,14 +8543,19 @@ impl Scene {
|
||||
arc
|
||||
}
|
||||
|
||||
/// True when `handle`'s entity sits on a locked layer. Locked objects stay
|
||||
/// visible, snappable and selectable, but mutation paths must skip them.
|
||||
/// True when `handle`'s entity sits on a locked layer, or is a point cloud
|
||||
/// locked on its own. Locked objects stay visible, snappable and
|
||||
/// selectable, but mutation paths must skip them.
|
||||
pub fn is_layer_locked(&self, handle: Handle) -> bool {
|
||||
self.document
|
||||
.get_entity(handle)
|
||||
.map(|e| e.common().layer.clone())
|
||||
.and_then(|name| self.document.layers.get(&name).map(|l| l.is_locked()))
|
||||
.unwrap_or(false)
|
||||
let Some(entity) = self.document.get_entity(handle) else {
|
||||
return false;
|
||||
};
|
||||
is_locked_point_cloud(entity)
|
||||
|| self
|
||||
.document
|
||||
.layers
|
||||
.get(&entity.common().layer)
|
||||
.is_some_and(|l| l.is_locked())
|
||||
}
|
||||
|
||||
/// The name of the locked layer `handle` sits on, if any (for messages).
|
||||
@@ -13833,3 +13973,20 @@ mod update_entities_batch_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_point_cloud(entity: &EntityType) -> bool {
|
||||
matches!(
|
||||
entity,
|
||||
EntityType::Extended(extended)
|
||||
if matches!(extended.data, codec::entities::ExtendedEntityData::PointCloudEx(_))
|
||||
)
|
||||
}
|
||||
|
||||
/// A point cloud whose Locked property is set: edits leave it alone.
|
||||
pub(crate) fn is_locked_point_cloud(entity: &EntityType) -> bool {
|
||||
matches!(
|
||||
entity,
|
||||
EntityType::Extended(extended)
|
||||
if matches!(&extended.data, codec::entities::ExtendedEntityData::PointCloudEx(data) if data.locked)
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod ole_pres;
|
||||
pub mod pdf_layers;
|
||||
pub mod pdf_raster;
|
||||
pub mod pdf_vector;
|
||||
pub mod point_cloud;
|
||||
pub mod underlay_vector;
|
||||
pub mod mesh_model;
|
||||
pub mod solid_model;
|
||||
|
||||
@@ -135,6 +135,11 @@ pub enum GripShape {
|
||||
Dropdown,
|
||||
/// Menu selector placed immediately beside its anchor grip.
|
||||
DropdownAdjacent,
|
||||
/// Move-gizmo arrow along world X (0), Y (1) or Z (2); the grip's world
|
||||
/// point is the gizmo centre, the marker sits at the arrow tip.
|
||||
GizmoAxis(u8),
|
||||
/// Move-gizmo square between two axes: XY (0), YZ (1) or ZX (2).
|
||||
GizmoPlane(u8),
|
||||
}
|
||||
|
||||
/// Describes one grip point for an entity.
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
// Point cloud scans drawn as points: the scan files a PointCloudEx entity
|
||||
// names are decoded once per file version, and the points it shows (placed
|
||||
// in the drawing, crops applied, coloured) are kept per placement so a
|
||||
// re-tessellate or a new render set reuses them.
|
||||
|
||||
mod rcp;
|
||||
mod rcs;
|
||||
|
||||
use codec::entities::{PointCloudExCrop, PointCloudExData};
|
||||
use codec::objects::{ClassObjectData, ObjectType, PointCloudDefinition};
|
||||
use codec::types::{Transform, Vector3};
|
||||
use codec::CadDocument;
|
||||
use rustc_hash::FxHashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// A scan file's points, in the file's own coordinates (the frame a
|
||||
/// PointCloudEx places with its origin and axes).
|
||||
pub struct CloudPoints {
|
||||
/// Unique per decoded file version: keys the placed point sets.
|
||||
id: u64,
|
||||
pub positions: Vec<[f64; 3]>,
|
||||
pub colors: Vec<[u8; 3]>,
|
||||
pub intensity: Vec<u8>,
|
||||
/// Unit normals in the file's frame (empty without normals).
|
||||
pub normals: Vec<[f32; 3]>,
|
||||
pub has_rgb: bool,
|
||||
pub has_intensity: bool,
|
||||
pub has_normals: bool,
|
||||
/// 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<ScanRange>,
|
||||
/// A project's preview picture (JPEG).
|
||||
pub preview: Option<Vec<u8>>,
|
||||
/// Bounds from the scans' headers, placed like their points: the
|
||||
/// extents a definition records.
|
||||
pub bounds: Option<[[f64; 3]; 2]>,
|
||||
}
|
||||
|
||||
/// Scan points by file path and modification time. A file that fails to
|
||||
/// decode is remembered too, so it is not re-read on every tessellate.
|
||||
pub fn load(path: &Path) -> Option<Arc<CloudPoints>> {
|
||||
type Cache = Mutex<FxHashMap<PathBuf, (SystemTime, Option<Arc<CloudPoints>>)>>;
|
||||
static CACHE: OnceLock<Cache> = OnceLock::new();
|
||||
let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
|
||||
let mut cache = CACHE.get_or_init(Default::default).lock().ok()?;
|
||||
if let Some((stamp, cloud)) = cache.get(path) {
|
||||
if *stamp == modified {
|
||||
return cloud.clone();
|
||||
}
|
||||
}
|
||||
let started = crate::perf::enabled().then(iced::time::Instant::now);
|
||||
let cloud = decode(path).map(Arc::new);
|
||||
crate::perf::record(format_args!(
|
||||
"[perf] point-cloud-load {:>7.1}ms points={} {}",
|
||||
crate::perf::elapsed_ms(started),
|
||||
cloud.as_ref().map_or(0, |cloud| cloud.positions.len()),
|
||||
path.display(),
|
||||
));
|
||||
cache.insert(path.to_path_buf(), (modified, cloud.clone()));
|
||||
cloud
|
||||
}
|
||||
|
||||
fn decode(path: &Path) -> Option<CloudPoints> {
|
||||
static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let is_project = path
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("rcp"));
|
||||
// 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, String)> = if is_project {
|
||||
rcp::scans(&bytes, path)?
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let scan = rcs::decode(&std::fs::read(&entry.path).ok()?)?;
|
||||
// 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];
|
||||
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),
|
||||
positions: Vec::new(),
|
||||
colors: Vec::new(),
|
||||
intensity: Vec::new(),
|
||||
normals: Vec::new(),
|
||||
has_rgb: scans.iter().any(|(scan, ..)| scan.has_rgb),
|
||||
has_intensity: scans.iter().any(|(scan, ..)| scan.has_intensity),
|
||||
has_normals: scans.iter().any(|(scan, ..)| scan.has_normals),
|
||||
scans: scans.len(),
|
||||
scan_ranges: Vec::new(),
|
||||
preview: is_project.then(|| rcp::preview(&bytes)).flatten(),
|
||||
bounds: None,
|
||||
};
|
||||
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]];
|
||||
let r = rotate.map(|row| row[0] * s[0] + row[1] * s[1] + row[2] * s[2]);
|
||||
[r[0] + translation[0], r[1] + translation[1], r[2] + translation[2]]
|
||||
};
|
||||
let first = cloud.positions.len();
|
||||
cloud.positions.extend(scan.local.iter().map(place));
|
||||
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(&[
|
||||
if corner & 1 == 0 { lo[0] } else { hi[0] },
|
||||
if corner & 2 == 0 { lo[1] } else { hi[1] },
|
||||
if corner & 4 == 0 { lo[2] } else { hi[2] },
|
||||
]);
|
||||
let [min, max] = cloud.bounds.get_or_insert([p, p]);
|
||||
for k in 0..3 {
|
||||
min[k] = min[k].min(p[k]);
|
||||
max[k] = max[k].max(p[k]);
|
||||
}
|
||||
}
|
||||
if scan.has_rgb || !cloud.has_rgb {
|
||||
cloud.colors.extend_from_slice(&scan.colors);
|
||||
} else {
|
||||
// A scan without colours among coloured ones shows its intensity.
|
||||
cloud.colors.extend(scan.intensity.iter().map(|&i| [i; 3]));
|
||||
}
|
||||
cloud.intensity.extend_from_slice(&scan.intensity);
|
||||
cloud.normals.extend(scan.normals.iter().map(|n| {
|
||||
let r = rotate.map(|row| row[0] * f64::from(n[0]) + row[1] * f64::from(n[1]) + row[2] * f64::from(n[2]));
|
||||
r.map(|v| v as f32)
|
||||
}));
|
||||
}
|
||||
Some(cloud)
|
||||
}
|
||||
|
||||
/// Rotation matrix of a scan's rotation angles (radians here; projects and
|
||||
/// scan headers store degrees), applied about X, then Y, then Z — the
|
||||
/// reference's extents of rotated scans match this order.
|
||||
fn euler([x, y, z]: [f64; 3]) -> [[f64; 3]; 3] {
|
||||
let (sx, cx) = x.sin_cos();
|
||||
let (sy, cy) = y.sin_cos();
|
||||
let (sz, cz) = z.sin_cos();
|
||||
[
|
||||
[cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz],
|
||||
[cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz],
|
||||
[-sy, sx * cy, cx * cy],
|
||||
]
|
||||
}
|
||||
|
||||
/// The point cloud definition a PointCloudEx names.
|
||||
pub(crate) fn definition<'a>(
|
||||
document: &'a CadDocument,
|
||||
data: &PointCloudExData,
|
||||
) -> Option<&'a PointCloudDefinition> {
|
||||
match document.objects.get(&data.definition_handle)? {
|
||||
ObjectType::ClassObject(object) => match &object.data {
|
||||
ClassObjectData::PointCloudDefinitionEx(definition)
|
||||
| ClassObjectData::PointCloudDefinition(definition) => Some(definition),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
type Sources = Mutex<FxHashMap<String, PathBuf>>;
|
||||
|
||||
fn sources() -> &'static Sources {
|
||||
static SOURCES: OnceLock<Sources> = OnceLock::new();
|
||||
SOURCES.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Where a stored (relative or moved) scan path was found for an opened
|
||||
/// drawing — the definition names the stored path.
|
||||
// ponytail: keyed by the stored path alone, like underlay sources; two open
|
||||
// drawings naming the same relative path in different folders share one.
|
||||
pub(crate) fn register_source(stored: &str, resolved: PathBuf) {
|
||||
if let Ok(mut map) = sources().lock() {
|
||||
map.insert(stored.to_string(), resolved);
|
||||
}
|
||||
}
|
||||
|
||||
/// The scan file a PointCloudEx shows, when it exists: the stored path as
|
||||
/// is, else where it was found next to the drawing, else relative to the
|
||||
/// working folder.
|
||||
pub(crate) fn resolve_source(document: &CadDocument, data: &PointCloudExData) -> Option<PathBuf> {
|
||||
// An unloaded cloud draws as a missing one: its box and saved path.
|
||||
if definition(document, data).is_some_and(|def| !def.is_loaded) {
|
||||
return None;
|
||||
}
|
||||
let stored = definition(document, data)?.source_filename.trim();
|
||||
if stored.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = PathBuf::from(stored.replace('\\', "/"));
|
||||
if path.is_absolute() && path.is_file() {
|
||||
return Some(path);
|
||||
}
|
||||
if let Some(found) = sources().lock().ok().and_then(|map| map.get(stored).cloned()) {
|
||||
if found.is_file() {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
path.is_file().then_some(path)
|
||||
}
|
||||
|
||||
/// One drawn point: position as two f32 (high + low, like every other
|
||||
/// renderer vertex), its colour and its world normal (zero without normals;
|
||||
/// lit only then).
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct PointInstance {
|
||||
pub pos: [f32; 3],
|
||||
pub pos_low: [f32; 3],
|
||||
pub color: [u8; 4],
|
||||
pub normal: [i8; 4],
|
||||
}
|
||||
|
||||
/// The points one cloud shows, placed in the drawing.
|
||||
#[derive(Debug)]
|
||||
pub struct PlacedCloud {
|
||||
/// Identity of the source file version, placement and crops: equal keys
|
||||
/// hold equal points, so GPU buffers are reused by key.
|
||||
pub key: u64,
|
||||
pub instances: Vec<PointInstance>,
|
||||
}
|
||||
|
||||
/// Every cloud a view shows, with the POINTCLOUDPOINTSIZE they draw at.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PointCloudSet {
|
||||
pub point_size: f32,
|
||||
pub clouds: Vec<Arc<PlacedCloud>>,
|
||||
}
|
||||
|
||||
/// POINTCLOUDPOINTSIZE in pixels (1–10, default 2).
|
||||
pub(crate) fn point_size(document: &CadDocument) -> f32 {
|
||||
crate::io::drawing_variable(document, "POINTCLOUDPOINTSIZE")
|
||||
.and_then(|v| v.trim().parse::<i16>().ok())
|
||||
.unwrap_or(2)
|
||||
.clamp(1, 10) as f32
|
||||
}
|
||||
|
||||
/// Whether the drawing defines any point cloud, so drawings without one
|
||||
/// skip the render walk.
|
||||
pub(crate) fn any_definition(document: &CadDocument) -> bool {
|
||||
document.objects.values().any(|object| {
|
||||
matches!(
|
||||
object,
|
||||
ObjectType::ClassObject(object)
|
||||
if matches!(object.data, ClassObjectData::PointCloudDefinitionEx(_)
|
||||
| ClassObjectData::PointCloudDefinition(_))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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
|
||||
/// `transform` (a block instance), with its crops applied and the `hidden`
|
||||
/// scans left out. `None` when the scan file is missing or unreadable.
|
||||
pub(crate) fn placed(
|
||||
document: &CadDocument,
|
||||
data: &PointCloudExData,
|
||||
object_color: [u8; 3],
|
||||
transform: Option<&Transform>,
|
||||
hidden: &[String],
|
||||
) -> Option<Arc<PlacedCloud>> {
|
||||
let cloud = load(&resolve_source(document, data)?)?;
|
||||
let skipped: Vec<std::ops::Range<usize>> =
|
||||
if hidden.iter().any(|h| h == UNASSIGNED_OFF) {
|
||||
vec![0..cloud.positions.len()]
|
||||
} else {
|
||||
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.
|
||||
let shown = |name| setting(name).map_or(0, |s| setting_value(document, s));
|
||||
let limit =
|
||||
(shown("POINTCLOUDPOINTMAX") * shown("POINTCLOUDDENSITY") / 100 * shown("POINTCLOUDLOD") / 10).max(1) as usize;
|
||||
let step = cloud.positions.len().div_ceil(limit).max(1);
|
||||
let style = Stylization::of(document, data, object_color);
|
||||
let mut origin = data.ucs_origin;
|
||||
let mut axes = [data.ucs_x_direction, data.ucs_y_direction, data.ucs_z_direction];
|
||||
if let Some(transform) = transform {
|
||||
let placed = transform.apply(origin);
|
||||
axes = axes.map(|axis| transform.apply(origin + axis) - placed);
|
||||
origin = placed;
|
||||
}
|
||||
let crops: Vec<&PointCloudExCrop> = if data.show_cropping {
|
||||
data.croppings
|
||||
.iter()
|
||||
.filter(|crop| match crop.crop_type {
|
||||
1 | 2 => crop.points.len() >= 3,
|
||||
3 => crop.points.len() >= 2,
|
||||
_ => false,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let mut h = rustc_hash::FxHasher::default();
|
||||
cloud.id.hash(&mut h);
|
||||
let bits = |v: &Vector3| [v.x, v.y, v.z].map(f64::to_bits);
|
||||
bits(&origin).hash(&mut h);
|
||||
axes.iter().map(bits).for_each(|v| v.hash(&mut h));
|
||||
for crop in &crops {
|
||||
let frame = [crop.plane, crop.x_direction, crop.y_direction];
|
||||
frame.iter().chain(&crop.points).map(bits).for_each(|v| v.hash(&mut h));
|
||||
(crop.inside, crop.inverted).hash(&mut h);
|
||||
}
|
||||
step.hash(&mut h);
|
||||
style.hash(&mut h);
|
||||
skipped.hash(&mut h);
|
||||
let key = h.finish();
|
||||
|
||||
type Placed = Mutex<FxHashMap<u64, Weak<PlacedCloud>>>;
|
||||
static PLACED: OnceLock<Placed> = OnceLock::new();
|
||||
let placed_cache = PLACED.get_or_init(Default::default);
|
||||
if let Some(hit) = placed_cache.lock().ok()?.get(&key).and_then(Weak::upgrade) {
|
||||
return Some(hit);
|
||||
}
|
||||
|
||||
let started = crate::perf::enabled().then(iced::time::Instant::now);
|
||||
let world = |p: &[f64; 3]| origin + axes[0] * p[0] + axes[1] * p[1] + axes[2] * p[2];
|
||||
// Elevation runs over the cloud's own height unless a fixed range is set.
|
||||
let (low, high) = if data.elevation_apply_to_fixed_range {
|
||||
(data.elevation_min, data.elevation_max)
|
||||
} else {
|
||||
cloud.positions.iter().fold((f64::MAX, f64::MIN), |(lo, hi), p| {
|
||||
let z = world(p).z;
|
||||
(lo.min(z), hi.max(z))
|
||||
})
|
||||
};
|
||||
let scan_color = |i: usize| {
|
||||
if cloud.has_rgb {
|
||||
cloud.colors[i]
|
||||
} else if cloud.has_intensity {
|
||||
[cloud.intensity[i]; 3]
|
||||
} else {
|
||||
object_color
|
||||
}
|
||||
};
|
||||
// A value outside a scheme's range takes the end colour, the scan
|
||||
// colour, or is hidden (`None`), as the cloud's out-of-range choice says.
|
||||
let along = |colors: &[[u8; 3]], t: f64, gradient: bool, out_of_range: i16, fixed: bool, i: usize| {
|
||||
if fixed && !(0.0..=1.0).contains(&t) {
|
||||
return match out_of_range {
|
||||
0 => Some(ramp(colors, t, gradient)),
|
||||
2 => None,
|
||||
_ => Some(scan_color(i)),
|
||||
};
|
||||
}
|
||||
Some(ramp(colors, t, gradient))
|
||||
};
|
||||
let color = |i: usize, z: f64| -> Option<[u8; 4]> {
|
||||
let [r, g, b] = match &style {
|
||||
Stylization::Object(c) => *c,
|
||||
Stylization::Normal => match cloud.normals.get(i) {
|
||||
Some(n) => {
|
||||
let w = axes[0] * f64::from(n[0]) + axes[1] * f64::from(n[1]) + axes[2] * f64::from(n[2]);
|
||||
let w = w / w.length().max(1e-12);
|
||||
[w.x, w.y, w.z].map(|v| ((v + 1.0) * 127.5).round().clamp(0.0, 255.0) as u8)
|
||||
}
|
||||
None => object_color,
|
||||
},
|
||||
Stylization::Ramp { colors, gradient, intensity: true, range, out_of_range } => {
|
||||
let percent = f64::from(cloud.intensity.get(i).copied().unwrap_or(0)) * 100.0 / 255.0;
|
||||
let (from, to) = (range.0 as f64, range.1 as f64);
|
||||
along(colors, (percent - from) / (to - from).max(1e-9), *gradient, *out_of_range, true, i)?
|
||||
}
|
||||
Stylization::Ramp { colors, gradient, out_of_range, .. } => along(
|
||||
colors,
|
||||
(z - low) / (high - low).max(1e-9),
|
||||
*gradient,
|
||||
*out_of_range,
|
||||
data.elevation_apply_to_fixed_range,
|
||||
i,
|
||||
)?,
|
||||
Stylization::Scan => scan_color(i),
|
||||
};
|
||||
Some([r, g, b, 255])
|
||||
};
|
||||
let mut instances = Vec::with_capacity(cloud.positions.len() / step + 1);
|
||||
for (i, p) in cloud.positions.iter().enumerate().step_by(step) {
|
||||
if skipped.iter().any(|r| r.contains(&i)) || !crops.iter().all(|crop| keeps(crop, p)) {
|
||||
continue;
|
||||
}
|
||||
let w = world(p);
|
||||
let Some(color) = color(i, w.z) else {
|
||||
continue;
|
||||
};
|
||||
let pos = [w.x as f32, w.y as f32, w.z as f32];
|
||||
let normal = cloud.normals.get(i).map_or([0; 4], |n| {
|
||||
let v = axes[0] * f64::from(n[0]) + axes[1] * f64::from(n[1]) + axes[2] * f64::from(n[2]);
|
||||
let v = v / v.length().max(1e-12);
|
||||
[v.x, v.y, v.z, 0.0].map(|c| (c * 127.0).round() as i8)
|
||||
});
|
||||
instances.push(PointInstance {
|
||||
pos,
|
||||
pos_low: [
|
||||
(w.x - pos[0] as f64) as f32,
|
||||
(w.y - pos[1] as f64) as f32,
|
||||
(w.z - pos[2] as f64) as f32,
|
||||
],
|
||||
color,
|
||||
normal,
|
||||
});
|
||||
}
|
||||
// Spread the points so any leading part of the list is an even sample:
|
||||
// a real-time density draws only that part.
|
||||
let instances = spread(instances);
|
||||
crate::perf::record(format_args!(
|
||||
"[perf] point-cloud-place {:>7.1}ms shown={}",
|
||||
crate::perf::elapsed_ms(started),
|
||||
instances.len(),
|
||||
));
|
||||
let placed = Arc::new(PlacedCloud { key, instances });
|
||||
let mut cache = placed_cache.lock().ok()?;
|
||||
cache.retain(|_, weak| weak.strong_count() > 0);
|
||||
cache.insert(key, Arc::downgrade(&placed));
|
||||
Some(placed)
|
||||
}
|
||||
|
||||
/// The items in a stride order coprime to their count: every prefix samples
|
||||
/// the whole list evenly.
|
||||
fn spread<T: Copy>(items: Vec<T>) -> Vec<T> {
|
||||
let n = items.len();
|
||||
if n < 3 {
|
||||
return items;
|
||||
}
|
||||
let gcd = |mut a: usize, mut b: usize| {
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
a
|
||||
};
|
||||
// Near the golden section, so consecutive picks land far apart.
|
||||
let mut stride = (n as f64 * 0.618_033_988_75) as usize | 1;
|
||||
while gcd(stride, n) != 1 {
|
||||
stride += 2;
|
||||
}
|
||||
(0..n).map(|k| items[k * stride % n]).collect()
|
||||
}
|
||||
|
||||
/// Whether a crop keeps point `p` (file coordinates). The crop is a prism
|
||||
/// through the plane at `plane` spanned by its two directions; its polygon
|
||||
/// is in that plane's coordinates. `inside` keeps the prism's interior and
|
||||
/// `inverted` flips it.
|
||||
fn keeps(crop: &PointCloudExCrop, p: &[f64; 3]) -> bool {
|
||||
// Points and crop outline on the crop plane, along its normal.
|
||||
let on_plane = |v: Vector3| {
|
||||
let d = v - crop.plane;
|
||||
Vector3::new(d.dot(&crop.x_direction), d.dot(&crop.y_direction), 0.0)
|
||||
};
|
||||
let Vector3 { x, y, .. } = on_plane(Vector3::new(p[0], p[1], p[2]));
|
||||
let points: Vec<Vector3> = crop.points.iter().copied().map(on_plane).collect();
|
||||
// A circle: its centre and a point on it.
|
||||
if crop.crop_type == 3 {
|
||||
let (c, e) = (points[0], points[1]);
|
||||
let r2 = (e.x - c.x).powi(2) + (e.y - c.y).powi(2);
|
||||
let inside = (x - c.x).powi(2) + (y - c.y).powi(2) <= r2;
|
||||
return (inside == crop.inside) != crop.inverted;
|
||||
}
|
||||
let mut inside = false;
|
||||
let mut j = points.len() - 1;
|
||||
for i in 0..points.len() {
|
||||
let (a, b) = (points[i], points[j]);
|
||||
if (a.y > y) != (b.y > y) && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x {
|
||||
inside = !inside;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
(inside == crop.inside) != crop.inverted
|
||||
}
|
||||
|
||||
/// How a cloud's points are coloured (its stylization).
|
||||
#[derive(Hash)]
|
||||
enum Stylization {
|
||||
/// The scan's own colours (intensity as grey when it has none).
|
||||
Scan,
|
||||
/// The cloud's object colour.
|
||||
Object([u8; 3]),
|
||||
/// Each point's world normal as a colour, (n + 1) / 2 per component.
|
||||
Normal,
|
||||
/// A colour scheme over intensity (per cent, in the range given) or
|
||||
/// elevation, blended or in bands; values out of range per
|
||||
/// `out_of_range` (0 end colours, 1 scan colours, 2 hidden).
|
||||
Ramp { colors: Vec<[u8; 3]>, gradient: bool, intensity: bool, range: (i64, i64), out_of_range: i16 },
|
||||
}
|
||||
|
||||
impl Stylization {
|
||||
fn of(document: &CadDocument, data: &PointCloudExData, object_color: [u8; 3]) -> Self {
|
||||
let (scheme, intensity, gradient) = match data.stylization_type {
|
||||
2 => return Self::Object(object_color),
|
||||
3 => return Self::Normal,
|
||||
5 => (&data.intensity_color_scheme, true, data.intensity_as_gradient),
|
||||
4 => (&data.current_color_scheme, false, data.elevation_as_gradient),
|
||||
// Classification has no data here: scan colours.
|
||||
_ => return Self::Scan,
|
||||
};
|
||||
let colors = ramp_colors(document, scheme, if intensity { "Spectrum" } else { "Earth" });
|
||||
let range = if intensity && data.intensity_max > data.intensity_min {
|
||||
(i64::from(data.intensity_min), i64::from(data.intensity_max))
|
||||
} else {
|
||||
(0, 100)
|
||||
};
|
||||
let out_of_range =
|
||||
if intensity { data.intensity_out_of_range_behavior } else { data.elevation_out_of_range_behavior };
|
||||
Self::Ramp { colors, gradient, intensity, range: (range.0, range.1), out_of_range }
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour schemes a new drawing's colour map carries, high end first.
|
||||
const BUILTIN_RAMPS: [(&str, &[u32]); 7] = [
|
||||
("Hydro", &[0x00bfff, 0x007ca5, 0x005f7f, 0x00394c, 0x00264c, 0x00134c, 0x00004c, 0x000026]),
|
||||
("Grayscale", &[0xffffff, 0xcccccc, 0x999999, 0x666666, 0x333333, 0x000000]),
|
||||
("Earth", &[0xffffc8, 0xf0d796, 0xe1af64, 0xd28732, 0xc35f00]),
|
||||
("Blues", &[0x0000ff, 0x0000a5, 0x00007f, 0x00004c, 0x000026]),
|
||||
("Greens", &[0x00ff00, 0x00a500, 0x007f00, 0x004c00, 0x002600]),
|
||||
("Spectrum", &[0xff00ff, 0x0000ff, 0x00ffff, 0x00ff00, 0xffff00, 0xff0000]),
|
||||
("Reds", &[0xff0000, 0xa50000, 0x7f0000, 0x4c0000, 0x260000]),
|
||||
];
|
||||
|
||||
fn rgb(color: u32) -> [u8; 3] {
|
||||
[(color >> 16) as u8, (color >> 8) as u8, color as u8]
|
||||
}
|
||||
|
||||
/// The colour schemes the drawing offers (identifier, name, colours high end
|
||||
/// first): its colour map's, or the built-in ones.
|
||||
pub(crate) fn schemes(document: &CadDocument) -> Vec<(String, String, Vec<[u8; 3]>)> {
|
||||
for object in document.objects.values() {
|
||||
let ObjectType::ClassObject(object) = object else {
|
||||
continue;
|
||||
};
|
||||
let ClassObjectData::PointCloudColorMap(map) = &object.data else {
|
||||
continue;
|
||||
};
|
||||
if !map.color_ramps.is_empty() {
|
||||
return map
|
||||
.color_ramps
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let colors = r.colors.iter().filter(|c| c.visible).map(|c| rgb(c.color as u32)).collect();
|
||||
(r.id.clone(), r.name.clone(), colors)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
BUILTIN_RAMPS
|
||||
.iter()
|
||||
.map(|(name, colors)| {
|
||||
let id = crate::entities::extended::point_cloud_ramp_id(name).to_string();
|
||||
(id, name.to_string(), colors.iter().map(|c| rgb(*c)).collect())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The schemes new intensity and elevation colouring starts from: the
|
||||
/// colour map's defaults, else Spectrum and Earth.
|
||||
pub(crate) fn default_schemes(document: &CadDocument) -> (String, String) {
|
||||
let map = document.objects.values().find_map(|object| match object {
|
||||
ObjectType::ClassObject(object) => match &object.data {
|
||||
ClassObjectData::PointCloudColorMap(map) => Some(map),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
});
|
||||
let pick = |stored: Option<&String>, name: &str| {
|
||||
stored
|
||||
.filter(|s| !s.is_empty())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| crate::entities::extended::point_cloud_ramp_id(name).to_string())
|
||||
};
|
||||
(
|
||||
pick(map.map(|m| &m.default_intensity_scheme), "Spectrum"),
|
||||
pick(map.map(|m| &m.default_elevation_scheme), "Earth"),
|
||||
)
|
||||
}
|
||||
|
||||
/// A scheme's colours: from the drawing's colour map when it has the scheme,
|
||||
/// else the built-in one of that name (or `fallback` for an unset scheme).
|
||||
fn ramp_colors(document: &CadDocument, id: &str, fallback: &str) -> Vec<[u8; 3]> {
|
||||
for object in document.objects.values() {
|
||||
let ObjectType::ClassObject(object) = object else {
|
||||
continue;
|
||||
};
|
||||
let ClassObjectData::PointCloudColorMap(map) = &object.data else {
|
||||
continue;
|
||||
};
|
||||
if let Some(found) = map.color_ramps.iter().find(|r| !id.is_empty() && r.id.eq_ignore_ascii_case(id)) {
|
||||
let colors: Vec<[u8; 3]> =
|
||||
found.colors.iter().filter(|c| c.visible).map(|c| rgb(c.color as u32)).collect();
|
||||
if !colors.is_empty() {
|
||||
return colors;
|
||||
}
|
||||
}
|
||||
}
|
||||
let name = crate::entities::extended::POINT_CLOUD_RAMPS
|
||||
.iter()
|
||||
.find(|(_, guid)| guid.eq_ignore_ascii_case(id))
|
||||
.map_or(fallback, |(name, _)| name);
|
||||
BUILTIN_RAMPS
|
||||
.iter()
|
||||
.find(|(ramp, _)| *ramp == name)
|
||||
.map_or_else(|| vec![[255; 3]], |(_, colors)| colors.iter().map(|c| rgb(*c)).collect())
|
||||
}
|
||||
|
||||
/// The colour at `t` (0–1, clamped) along a scheme: blended between its
|
||||
/// colours, or the band `t` falls in. A scheme lists its high end first
|
||||
/// (Spectrum: magenta at the highest value, red at the lowest).
|
||||
fn ramp(colors: &[[u8; 3]], t: f64, gradient: bool) -> [u8; 3] {
|
||||
let t = 1.0 - if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
if colors.len() < 2 {
|
||||
return colors.first().copied().unwrap_or([255; 3]);
|
||||
}
|
||||
if !gradient {
|
||||
return colors[((t * colors.len() as f64) as usize).min(colors.len() - 1)];
|
||||
}
|
||||
let x = t * (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).round() as u8)
|
||||
}
|
||||
|
||||
/// A point cloud system variable: its default and the integers it takes.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Setting {
|
||||
pub name: &'static str,
|
||||
pub default: i64,
|
||||
min: i64,
|
||||
max: i64,
|
||||
}
|
||||
|
||||
/// The reference's defaults and ranges.
|
||||
const SETTINGS: [Setting; 15] = [
|
||||
Setting { name: "POINTCLOUDDENSITY", default: 15, min: 1, max: 100 },
|
||||
Setting { name: "POINTCLOUDRTDENSITY", default: 5, min: 1, max: 100 },
|
||||
Setting { name: "POINTCLOUDPOINTSIZE", default: 2, min: 1, max: 10 },
|
||||
Setting { name: "POINTCLOUDLOD", default: 10, min: 1, max: 10 },
|
||||
Setting { name: "POINTCLOUDLOCK", default: 0, min: 0, max: 1 },
|
||||
Setting { name: "POINTCLOUDAUTOUPDATE", default: 0, min: 0, max: 1 },
|
||||
Setting { name: "POINTCLOUDVISRETAIN", default: 1, min: 0, max: 1 },
|
||||
Setting { name: "POINTCLOUDSHADING", default: 0, min: 0, max: 1 },
|
||||
Setting { name: "POINTCLOUD2DVSDISPLAY", default: 0, min: 0, max: 1 },
|
||||
Setting { name: "POINTCLOUDLIGHTSOURCE", default: 0, min: 0, max: 1 },
|
||||
Setting { name: "POINTCLOUDLIGHTING", default: 0, min: 0, max: 2 },
|
||||
Setting { name: "POINTCLOUDBOUNDARY", default: 1, min: 0, max: 2 },
|
||||
Setting { name: "POINTCLOUDCACHESIZE", default: 512, min: 0, max: 32000 },
|
||||
Setting { name: "POINTCLOUDPOINTMAX", default: 10_000_000, min: 1_000_000, max: 25_000_000 },
|
||||
Setting { name: "POINTCLOUDPOINTMAXLEGACY", default: 1_500_000, min: 1_500_000, max: 10_000_000 },
|
||||
];
|
||||
|
||||
/// How a value was refused: the lines to show, and whether the prompt asks
|
||||
/// again (a small range) or the command ends (the point and cache limits).
|
||||
pub(crate) struct Refusal {
|
||||
pub lines: Vec<String>,
|
||||
pub ask_again: bool,
|
||||
}
|
||||
|
||||
impl Setting {
|
||||
pub(crate) fn check(&self, text: &str) -> Result<i64, Refusal> {
|
||||
let Ok(value) = text.trim().parse::<i64>() else {
|
||||
return Err(Refusal { lines: vec!["Requires an integer value.".to_string()], ask_again: true });
|
||||
};
|
||||
if (self.min..=self.max).contains(&value) {
|
||||
return Ok(value);
|
||||
}
|
||||
Err(if self.max > 100 {
|
||||
Refusal {
|
||||
lines: vec![format!("Cannot set {} to that value.", self.name), "*Invalid*".to_string()],
|
||||
ask_again: false,
|
||||
}
|
||||
} else if self.min == 0 && self.max == 1 {
|
||||
Refusal { lines: vec!["Requires 0 or 1 only.".to_string()], ask_again: true }
|
||||
} else {
|
||||
Refusal {
|
||||
lines: vec![format!("Requires an integer between {} and {}.", self.min, self.max)],
|
||||
ask_again: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn setting(name: &str) -> Option<Setting> {
|
||||
SETTINGS.iter().copied().find(|setting| setting.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// A setting's value in the drawing, or its default.
|
||||
pub(crate) fn setting_value(document: &CadDocument, setting: Setting) -> i64 {
|
||||
crate::io::drawing_variable(document, setting.name)
|
||||
.and_then(|value| value.trim().parse::<i64>().ok())
|
||||
.unwrap_or(setting.default)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Scan project (.rcp) reader: a zip holding one XML whose
|
||||
// `<VoxelTreeRunTime>` entries list the project's scans — each with its
|
||||
// transform (Translation / Rotation / Scale), visibility and the .rcs path
|
||||
// (`RelativePath` against the project's folder, `Path` absolute).
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
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],
|
||||
}
|
||||
|
||||
/// The project's preview picture (the JPEG it carries), for the attach
|
||||
/// dialog.
|
||||
pub fn preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).ok()?;
|
||||
for i in 0..archive.len() {
|
||||
let file = archive.by_index(i).ok()?;
|
||||
if file.name().to_ascii_lowercase().ends_with(".jpg") {
|
||||
let mut picture = Vec::new();
|
||||
file.take(32 << 20).read_to_end(&mut picture).ok()?;
|
||||
return Some(picture);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn scans(bytes: &[u8], project: &Path) -> Option<Vec<ScanEntry>> {
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).ok()?;
|
||||
let folder = project.parent().unwrap_or(Path::new(""));
|
||||
for i in 0..archive.len() {
|
||||
let file = archive.by_index(i).ok()?;
|
||||
if !file.name().to_ascii_lowercase().ends_with(".xml") {
|
||||
continue;
|
||||
}
|
||||
let mut text = String::new();
|
||||
// The project XML is small; the cap keeps a crafted archive in check.
|
||||
if file.take(64 << 20).read_to_string(&mut text).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Ok(doc) = roxmltree::Document::parse(text.trim_start_matches('\u{feff}')) else {
|
||||
continue;
|
||||
};
|
||||
let nodes: Vec<_> = doc
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("VoxelTreeRunTime"))
|
||||
.collect();
|
||||
if nodes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
return Some(nodes.into_iter().filter_map(|node| entry(node, folder)).collect());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn child<'a, 'i>(node: roxmltree::Node<'a, 'i>, tag: &str) -> Option<roxmltree::Node<'a, 'i>> {
|
||||
node.children().find(|n| n.has_tag_name(tag))
|
||||
}
|
||||
|
||||
fn value<'a>(node: roxmltree::Node<'a, '_>, tag: &str) -> Option<&'a str> {
|
||||
child(node, tag)?.attribute("Value")
|
||||
}
|
||||
|
||||
fn xyz(node: roxmltree::Node, tag: &str, default: f64) -> [f64; 3] {
|
||||
let n = child(node, tag);
|
||||
["x", "y", "z"].map(|a| {
|
||||
n.and_then(|n| n.attribute(a))
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.filter(|v| v.is_finite())
|
||||
.unwrap_or(default)
|
||||
})
|
||||
}
|
||||
|
||||
fn entry(node: roxmltree::Node, folder: &Path) -> Option<ScanEntry> {
|
||||
if value(node, "Visible") == Some("0") {
|
||||
return None;
|
||||
}
|
||||
let relative = value(node, "RelativePath")
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| folder.join(p.replace('\\', "/")))
|
||||
.filter(|p| p.is_file());
|
||||
let path = relative.or_else(|| {
|
||||
value(node, "Path")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_file())
|
||||
})?;
|
||||
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),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Indexed scan (.rcs, "ADOCT" version 3) point reader.
|
||||
//
|
||||
// Layout (little-endian):
|
||||
// 0x10 3 × f64 scan translation T
|
||||
// 0x28 3 × f64 scan rotation
|
||||
// 0x40 3 × f64 scan scale
|
||||
// 0xE0 3 × u8 has RGB, has normals, has intensity
|
||||
// 0x129 u32 section count, then 24-byte entries at 0x12D:
|
||||
// u64 type, u64 offset, u64 size
|
||||
// Section 2 is the node directory: u64 node count, then 368-byte records
|
||||
// (cube min at +0, levels +96, point count +100, block size +104, per-level
|
||||
// cell counts +108 and point counts +236). Section 1 holds the node blocks
|
||||
// back to back: the node's octree index (u32 entries), then 16-byte point
|
||||
// records — a u64 with three 18-bit millimetre offsets from the node cube's
|
||||
// min corner, the intensity byte, then blue, green, red.
|
||||
// The octree index is only needed for spatial queries: every record carries
|
||||
// its own position.
|
||||
|
||||
/// One scan, in its own (untransformed) frame.
|
||||
pub struct Scan {
|
||||
/// Point positions before the scan transform.
|
||||
pub local: Vec<[f64; 3]>,
|
||||
pub colors: Vec<[u8; 3]>,
|
||||
pub intensity: Vec<u8>,
|
||||
/// Unit normals (scan frame).
|
||||
pub normals: Vec<[f32; 3]>,
|
||||
pub has_rgb: bool,
|
||||
pub has_normals: bool,
|
||||
pub has_intensity: bool,
|
||||
pub translation: [f64; 3],
|
||||
pub rotation: [f64; 3],
|
||||
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;
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn u64_at(b: &[u8], at: usize) -> Option<u64> {
|
||||
Some(u64::from_le_bytes(b.get(at..at.checked_add(8)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn f64_at(b: &[u8], at: usize) -> Option<f64> {
|
||||
u64_at(b, at).map(f64::from_bits).filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
fn vec3_at(b: &[u8], at: usize) -> Option<[f64; 3]> {
|
||||
Some([f64_at(b, at)?, f64_at(b, at + 8)?, f64_at(b, at + 16)?])
|
||||
}
|
||||
|
||||
/// A normal from its cube-map code: six faces of 52 × 52 equal-angle
|
||||
/// cells. The face gives the main axis and its sign; the cell the other two
|
||||
/// components, which flip with the face.
|
||||
fn normal(code: u16) -> [f32; 3] {
|
||||
let code = usize::from(code);
|
||||
if code >= 6 * 52 * 52 {
|
||||
return [0.0, 0.0, 1.0];
|
||||
}
|
||||
let (face, cell) = (code / 2704, code % 2704);
|
||||
let tan = |i: usize| (((i as f64 + 0.5) / 26.0 - 1.0) * std::f64::consts::FRAC_PI_4).tan();
|
||||
let axis = face >> 1;
|
||||
let (b, c) = match axis {
|
||||
0 => (1, 2),
|
||||
1 => (0, 2),
|
||||
_ => (0, 1),
|
||||
};
|
||||
let sign = if face % 2 == 0 { 1.0 } else { -1.0 };
|
||||
let mut n = [0.0f64; 3];
|
||||
n[axis] = sign;
|
||||
n[b] = sign * tan(cell % 52);
|
||||
n[c] = sign * tan(cell / 52);
|
||||
let length = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||||
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<Scan> {
|
||||
if b.get(0..5)? != b"ADOCT" {
|
||||
return None;
|
||||
}
|
||||
let translation = vec3_at(b, 0x10)?;
|
||||
let rotation = vec3_at(b, 0x28)?;
|
||||
let scale = vec3_at(b, 0x40)?;
|
||||
let has_rgb = *b.get(0xE0)? != 0;
|
||||
let bounds = [vec3_at(b, 0xA0)?, vec3_at(b, 0xB8)?];
|
||||
let has_normals = *b.get(0xE1)? != 0;
|
||||
let has_intensity = *b.get(0xE2)? != 0;
|
||||
|
||||
let sections = u32_at(b, 0x129)? as usize;
|
||||
let (mut directory, mut data) = (None, None);
|
||||
for k in 0..sections.min(64) {
|
||||
let at = 0x12D + k * 24;
|
||||
let offset = usize::try_from(u64_at(b, at + 8)?).ok()?;
|
||||
match u64_at(b, at)? {
|
||||
1 => data = Some(offset),
|
||||
2 => directory = Some(offset),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let (directory, mut block) = (directory?, data?);
|
||||
let nodes = usize::try_from(u64_at(b, directory)?).ok()?;
|
||||
// Every node needs its directory record; a count past the file is corrupt.
|
||||
if nodes.checked_mul(NODE_RECORD)? > b.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut scan = Scan {
|
||||
local: Vec::new(),
|
||||
colors: Vec::new(),
|
||||
intensity: Vec::new(),
|
||||
normals: Vec::new(),
|
||||
has_rgb,
|
||||
has_normals,
|
||||
has_intensity,
|
||||
translation,
|
||||
rotation,
|
||||
scale,
|
||||
bounds,
|
||||
id: header_id(b),
|
||||
};
|
||||
for node in 0..nodes {
|
||||
let record = directory + 8 + node * NODE_RECORD;
|
||||
let cube_min = vec3_at(b, record)?;
|
||||
let points = u32_at(b, record + 100)? as usize;
|
||||
let size = u32_at(b, record + 104)? as usize;
|
||||
let mut entries = points;
|
||||
for level in 0..32 {
|
||||
entries = entries.checked_add(u32_at(b, record + 108 + level * 4)? as usize)?;
|
||||
}
|
||||
if entries.checked_mul(4)?.checked_add(points.checked_mul(16)?)? != size {
|
||||
return None;
|
||||
}
|
||||
let records = block + entries * 4;
|
||||
let end = block.checked_add(size)?;
|
||||
let bytes = b.get(records..end)?;
|
||||
scan.local.reserve(points);
|
||||
scan.colors.reserve(points);
|
||||
scan.intensity.reserve(points);
|
||||
for rec in bytes.chunks_exact(16) {
|
||||
let v = u64::from_le_bytes(rec[0..8].try_into().ok()?);
|
||||
let q = |shift: u32| ((v >> shift) & 0x3FFFF) as f64 * 1e-3;
|
||||
scan.local.push([cube_min[0] + q(0), cube_min[1] + q(18), cube_min[2] + q(36)]);
|
||||
scan.intensity.push(rec[8]);
|
||||
scan.colors.push([rec[11], rec[10], rec[9]]);
|
||||
scan.normals.push(normal(u16::from_le_bytes([rec[12], rec[13]])));
|
||||
}
|
||||
block = end;
|
||||
}
|
||||
Some(scan)
|
||||
}
|
||||
+88
-32
@@ -29,6 +29,71 @@ fn marker_screen_position(mut point: Point, shape: GripShape) -> Point {
|
||||
point
|
||||
}
|
||||
|
||||
/// Screen length of a move-gizmo arrow.
|
||||
pub const GIZMO_AXIS_PX: f32 = 72.0;
|
||||
|
||||
pub fn gizmo_axis(k: u8) -> DVec3 {
|
||||
[DVec3::X, DVec3::Y, DVec3::Z][k as usize % 3]
|
||||
}
|
||||
|
||||
pub fn gizmo_plane_axes(k: u8) -> (u8, u8) {
|
||||
[(0, 1), (1, 2), (2, 0)][k as usize % 3]
|
||||
}
|
||||
|
||||
/// Unit screen directions of the three gizmo axes from `center`; an axis
|
||||
/// seen nearly end-on is left out.
|
||||
pub fn gizmo_screen_axes(
|
||||
center: Vec2,
|
||||
world: DVec3,
|
||||
project: impl Fn(DVec3) -> Option<Vec2>,
|
||||
) -> [Option<Vec2>; 3] {
|
||||
let spans = [0, 1, 2].map(|k| project(world + gizmo_axis(k)).map(|p| p - center));
|
||||
let longest = spans.iter().flatten().map(|v| v.length()).fold(0.0, f32::max);
|
||||
spans.map(|span| span.filter(|v| longest > 0.0 && v.length() > 0.15 * longest).map(|v| v.normalize()))
|
||||
}
|
||||
|
||||
/// Marker position (and direction) of a grip whose centre projects to
|
||||
/// `projected`: gizmo grips sit off their centre, others keep their offset.
|
||||
fn placed_marker(
|
||||
g: &GripDef,
|
||||
projected: Option<Vec2>,
|
||||
project: impl Fn(DVec3) -> Option<Vec2>,
|
||||
) -> Option<(Vec2, Option<[f32; 2]>)> {
|
||||
let p = projected?;
|
||||
match g.shape {
|
||||
GripShape::GizmoAxis(k) => {
|
||||
let d = gizmo_screen_axes(p, g.world, project)[k as usize % 3]?;
|
||||
Some((p + d * GIZMO_AXIS_PX, Some([d.x, -d.y])))
|
||||
}
|
||||
GripShape::GizmoPlane(k) => {
|
||||
let axes = gizmo_screen_axes(p, g.world, project);
|
||||
let (a, b) = gizmo_plane_axes(k);
|
||||
let (a, b) = (axes[a as usize]?, axes[b as usize]?);
|
||||
Some((p + (a + b) * 0.3 * GIZMO_AXIS_PX, None))
|
||||
}
|
||||
_ => {
|
||||
let dir = g.dir.and_then(|dir| Some(marker_direction(p, project(g.world + dir)?)));
|
||||
let s = marker_screen_position(Point::new(p.x, p.y), g.shape);
|
||||
Some((Vec2::new(s.x, s.y), dir))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel distance from `cursor` to a marker; a gizmo arrow counts along its
|
||||
/// whole shaft, not only at its tip.
|
||||
fn marker_distance(shape: GripShape, screen: Vec2, dir: Option<[f32; 2]>, cursor: Point) -> f32 {
|
||||
let cursor = Vec2::new(cursor.x, cursor.y);
|
||||
match (shape, dir) {
|
||||
(GripShape::GizmoAxis(_), Some([dx, dy])) => {
|
||||
let d = Vec2::new(dx, -dy);
|
||||
let start = screen - d * (GIZMO_AXIS_PX - 14.0);
|
||||
let t = (cursor - start).dot(d).clamp(0.0, GIZMO_AXIS_PX - 14.0);
|
||||
cursor.distance(start + d * t)
|
||||
}
|
||||
_ => cursor.distance(screen),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Active drag state ─────────────────────────────────────────────────────
|
||||
|
||||
/// Stored on `OpenCADStudio` while a grip is being dragged.
|
||||
@@ -50,6 +115,12 @@ pub struct GripEdit {
|
||||
pub targets: Vec<GripTarget>,
|
||||
/// Opposite corner and local width/height axes for rectangle corner resize.
|
||||
pub rectangle_frame: Option<(DVec3, DVec3, DVec3)>,
|
||||
/// Normal of the plane a move-gizmo square drags in.
|
||||
pub plane: Option<DVec3>,
|
||||
/// Move gizmo: offset from the gizmo centre to where its part was
|
||||
/// grabbed, taken on the first move so the drag does not jump.
|
||||
pub grab: Option<DVec3>,
|
||||
pub gizmo: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -108,6 +179,9 @@ impl GripEdit {
|
||||
last_world: world,
|
||||
}],
|
||||
rectangle_frame: None,
|
||||
plane: None,
|
||||
grab: None,
|
||||
gizmo: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,18 +255,11 @@ pub fn grips_to_screen(
|
||||
// Project from the f64 grip position via the relative-to-eye path so
|
||||
// the grip stays glued to the wire at UTM-scale coordinates (an
|
||||
// `as_vec3` cast first would quantize it ~0.5 m off at high zoom).
|
||||
let projected = camera.project(g.world, bounds);
|
||||
let screen = match projected {
|
||||
Some(p) => Point::new(bounds.x + p.x, bounds.y + p.y),
|
||||
None => Point::new(f32::NAN, f32::NAN),
|
||||
let project = |world: DVec3| camera.project(world, bounds);
|
||||
let (screen, dir) = match placed_marker(g, project(g.world), project) {
|
||||
Some((p, dir)) => (Point::new(bounds.x + p.x, bounds.y + p.y), dir),
|
||||
None => (Point::new(f32::NAN, f32::NAN), None),
|
||||
};
|
||||
let dir = g.dir.and_then(|dir| {
|
||||
Some(marker_direction(
|
||||
projected?,
|
||||
camera.project(g.world + dir, bounds)?,
|
||||
))
|
||||
});
|
||||
let screen = marker_screen_position(screen, g.shape);
|
||||
(g.id, screen, g.is_midpoint, g.shape, dir)
|
||||
})
|
||||
.collect()
|
||||
@@ -274,13 +341,11 @@ pub fn find_hit_grip(
|
||||
let mut best: Option<(usize, usize, bool, DVec3)> = None;
|
||||
|
||||
for (index, g) in grips.iter().enumerate() {
|
||||
let Some(screen) = camera.project(g.world, bounds) else {
|
||||
let project = |world: DVec3| camera.project(world, bounds);
|
||||
let Some((screen, dir)) = placed_marker(g, project(g.world), project) else {
|
||||
continue;
|
||||
};
|
||||
let screen = marker_screen_position(Point::new(screen.x, screen.y), g.shape);
|
||||
let dx = screen.x - cursor.x;
|
||||
let dy = screen.y - cursor.y;
|
||||
let d = (dx * dx + dy * dy).sqrt();
|
||||
let d = marker_distance(g.shape, screen, dir, cursor);
|
||||
if d < best_dist {
|
||||
best_dist = d;
|
||||
best = Some((index, g.id, g.is_midpoint, g.world));
|
||||
@@ -320,18 +385,11 @@ pub fn grips_to_screen_rte(
|
||||
grips
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let projected = project_rte(g.world, view_rot, eye, bounds);
|
||||
let screen = match projected {
|
||||
Some(p) => Point::new(bounds.x + p.x, bounds.y + p.y),
|
||||
None => Point::new(f32::NAN, f32::NAN),
|
||||
let project = |world: DVec3| project_rte(world, view_rot, eye, bounds);
|
||||
let (screen, dir) = match placed_marker(g, project(g.world), project) {
|
||||
Some((p, dir)) => (Point::new(bounds.x + p.x, bounds.y + p.y), dir),
|
||||
None => (Point::new(f32::NAN, f32::NAN), None),
|
||||
};
|
||||
let dir = g.dir.and_then(|dir| {
|
||||
Some(marker_direction(
|
||||
projected?,
|
||||
project_rte(g.world + dir, view_rot, eye, bounds)?,
|
||||
))
|
||||
});
|
||||
let screen = marker_screen_position(screen, g.shape);
|
||||
(g.id, screen, g.is_midpoint, g.shape, dir)
|
||||
})
|
||||
.collect()
|
||||
@@ -350,13 +408,11 @@ pub fn find_hit_grip_rte(
|
||||
let mut best: Option<(usize, usize, bool, DVec3)> = None;
|
||||
|
||||
for (index, g) in grips.iter().enumerate() {
|
||||
let Some(screen) = project_rte(g.world, view_rot, eye, bounds) else {
|
||||
let project = |world: DVec3| project_rte(world, view_rot, eye, bounds);
|
||||
let Some((screen, dir)) = placed_marker(g, project(g.world), project) else {
|
||||
continue;
|
||||
};
|
||||
let screen = marker_screen_position(Point::new(screen.x, screen.y), g.shape);
|
||||
let dx = screen.x - cursor.x;
|
||||
let dy = screen.y - cursor.y;
|
||||
let d = (dx * dx + dy * dy).sqrt();
|
||||
let d = marker_distance(g.shape, screen, dir, cursor);
|
||||
if d < best_dist {
|
||||
best_dist = d;
|
||||
best = Some((index, g.id, g.is_midpoint, g.world));
|
||||
|
||||
+115
-2
@@ -26,6 +26,7 @@ pub mod gpu_upload;
|
||||
pub mod hatch_gpu;
|
||||
pub mod wipeout_gpu;
|
||||
pub mod image_gpu;
|
||||
pub mod point_cloud_gpu;
|
||||
pub mod mesh_gpu;
|
||||
pub mod text_gpu;
|
||||
pub mod uniforms;
|
||||
@@ -136,6 +137,7 @@ pub struct Pipeline {
|
||||
/// private backends behind one upload/LOD/draw lifecycle.
|
||||
hatch_gpu: hatch_gpu::HatchGpu,
|
||||
image_pipeline: wgpu::RenderPipeline,
|
||||
point_cloud_pipeline: wgpu::RenderPipeline,
|
||||
/// SDF text-quad pipeline (Phase 2b): draws per-glyph quads sampling the
|
||||
/// shared glyph atlas. Fed only when `OCS_TEXT_SDF` is set (else no verts).
|
||||
text_pipeline: wgpu::RenderPipeline,
|
||||
@@ -313,6 +315,7 @@ pub struct Pipeline {
|
||||
/// viewport rect. Recomputed by `compute_wipeout_lod`.
|
||||
wipeout_skip_flags: Vec<bool>,
|
||||
gpu_images: Vec<ImageGpu>,
|
||||
gpu_point_clouds: point_cloud_gpu::PointCloudGpu,
|
||||
/// Batched mesh geometry — every solid's LOD0 concatenated into a few large
|
||||
/// buffers so the whole set draws in a handful of calls instead of one per
|
||||
/// solid. Hover / selection never re-pack it.
|
||||
@@ -344,6 +347,8 @@ pub struct Pipeline {
|
||||
pub cached_preview_hatch_source: Option<std::sync::Arc<Vec<HatchModel>>>,
|
||||
pub cached_wipeout_source: Option<std::sync::Arc<Vec<HatchModel>>>,
|
||||
pub cached_image_source: Option<std::sync::Arc<Vec<ImageModel>>>,
|
||||
pub cached_point_cloud_source:
|
||||
Option<std::sync::Arc<crate::scene::model::point_cloud::PointCloudSet>>,
|
||||
pub cached_text_source: Option<std::sync::Arc<Vec<text_gpu::TextVertex>>>,
|
||||
pub cached_annotation_highlight_source: Option<std::sync::Arc<Vec<WireModel>>>,
|
||||
pub cached_mesh_source: Option<std::sync::Arc<Vec<MeshLodSet>>>,
|
||||
@@ -2109,6 +2114,61 @@ impl Pipeline {
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// ── Point cloud sprites ────────────────────────────────────────────
|
||||
let point_cloud_bgl1 = point_cloud_gpu::params_layout(device);
|
||||
let point_cloud_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("point_cloud.pipeline_layout"),
|
||||
bind_group_layouts: &[&frame_bgl, &point_cloud_bgl1].map(Some),
|
||||
immediate_size: 0,
|
||||
});
|
||||
let point_cloud_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("point_cloud.shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/src/shaders/point_cloud.wgsl"
|
||||
)))),
|
||||
});
|
||||
let point_cloud_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("point_cloud.pipeline"),
|
||||
layout: Some(&point_cloud_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &point_cloud_shader,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[point_cloud_gpu::instance_layout()],
|
||||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: content_stencil.clone(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
}),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: MSAA_SAMPLES,
|
||||
mask: !0,
|
||||
alpha_to_coverage_enabled: false,
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &point_cloud_shader,
|
||||
entry_point: Some("fs_main"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let gpu_point_clouds = point_cloud_gpu::PointCloudGpu::new(device, &point_cloud_bgl1);
|
||||
|
||||
// ── Text (SDF glyph quads) ─────────────────────────────────────────
|
||||
let text_atlas_bgl = text_gpu::TextAtlasGpu::bind_group_layout(device);
|
||||
let (
|
||||
@@ -2294,6 +2354,7 @@ impl Pipeline {
|
||||
wipeout_pipeline,
|
||||
hatch_gpu,
|
||||
image_pipeline,
|
||||
point_cloud_pipeline,
|
||||
text_pipeline,
|
||||
block_text_pipeline,
|
||||
text_highlight_pipeline,
|
||||
@@ -2385,6 +2446,7 @@ impl Pipeline {
|
||||
gpu_wipeouts: vec![],
|
||||
wipeout_skip_flags: vec![],
|
||||
gpu_images: vec![],
|
||||
gpu_point_clouds,
|
||||
gpu_mesh_batch: vec![],
|
||||
gpu_mesh_dynamic: vec![],
|
||||
mesh_disabled_chunks: rustc_hash::FxHashSet::default(),
|
||||
@@ -2400,6 +2462,7 @@ impl Pipeline {
|
||||
cached_preview_hatch_source: None,
|
||||
cached_wipeout_source: None,
|
||||
cached_image_source: None,
|
||||
cached_point_cloud_source: None,
|
||||
cached_text_source: None,
|
||||
cached_annotation_highlight_source: None,
|
||||
cached_mesh_source: None,
|
||||
@@ -2750,7 +2813,9 @@ impl Pipeline {
|
||||
);
|
||||
for &i in idxs {
|
||||
if let Some(w) = wires.get(i as usize) {
|
||||
if !w.display_visible {
|
||||
// An unseen pick hull (a point cloud's extents box) shows while
|
||||
// highlighted, as the reference does.
|
||||
if !w.display_visible && (w.pick_tris.is_empty() || w.snap_only) {
|
||||
continue;
|
||||
}
|
||||
selected_wires.push(w);
|
||||
@@ -2762,7 +2827,9 @@ impl Pipeline {
|
||||
if let Some(idxs) = self.wire_handle_index.get(&h.value()) {
|
||||
for &i in idxs {
|
||||
if let Some(w) = wires.get(i as usize) {
|
||||
if !w.display_visible {
|
||||
// An unseen pick hull (a point cloud's extents box) shows while
|
||||
// highlighted, as the reference does.
|
||||
if !w.display_visible && (w.pick_tris.is_empty() || w.snap_only) {
|
||||
continue;
|
||||
}
|
||||
hover_wires.push(w);
|
||||
@@ -3776,6 +3843,19 @@ analytic={:.1} regular={:.1} blocks={:.1}",
|
||||
self.gpu_images = ImageGpu::from_models(device, queue, images, &self.image_bgl1);
|
||||
}
|
||||
|
||||
pub fn upload_point_clouds(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
set: &crate::scene::model::point_cloud::PointCloudSet,
|
||||
) {
|
||||
self.gpu_point_clouds.upload(device, queue, set);
|
||||
}
|
||||
|
||||
pub fn set_point_cloud_view(&mut self, queue: &wgpu::Queue, light: [f32; 12]) {
|
||||
self.gpu_point_clouds.set_view(queue, light);
|
||||
}
|
||||
|
||||
/// Upload the frame's SDF text-quad vertices, and (re)build the GPU glyph
|
||||
/// atlas from the shared CPU atlas when it grew (new glyphs baked by the
|
||||
/// text collector). `verts` empty (flag off) leaves nothing to draw.
|
||||
@@ -4189,6 +4269,38 @@ analytic={:.1} regular={:.1} blocks={:.1}",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 3: point clouds ──────────────────────────────────────────
|
||||
if !self.gpu_point_clouds.is_empty() {
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("point_cloud.render_pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: msaa,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.depth_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }),
|
||||
}),
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0);
|
||||
pass.set_pipeline(&self.point_cloud_pipeline);
|
||||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||||
pass.set_stencil_reference(stencil_ref);
|
||||
self.gpu_point_clouds.draw(&mut pass);
|
||||
}
|
||||
|
||||
// ── Pass 4: solid meshes (batched) ────────────────────────────────
|
||||
if !self.gpu_mesh_batch.is_empty() || !self.gpu_mesh_dynamic.is_empty() {
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
@@ -5043,6 +5155,7 @@ analytic={:.1} regular={:.1} blocks={:.1}",
|
||||
self.cached_preview_hatch_source = None;
|
||||
self.cached_wipeout_source = None;
|
||||
self.cached_image_source = None;
|
||||
self.cached_point_cloud_source = None;
|
||||
self.cached_text_source = None;
|
||||
self.cached_mesh_source = None;
|
||||
self.cached_face3d_source = None;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Point cloud GPU buffers — one instance buffer per placed cloud, drawn as
|
||||
// screen-space square sprites (6 vertices per instance, no vertex buffer).
|
||||
//
|
||||
// Group 1 binding 0 — PointParams (sprite size in pixels, lighting mode and
|
||||
// light source, the two light directions; 48 bytes).
|
||||
//
|
||||
// Buffers are keyed by `PlacedCloud::key`: a new render set re-uploads only
|
||||
// the clouds whose points changed; the rest keep their buffers.
|
||||
|
||||
use crate::scene::model::point_cloud::{PointCloudSet, PointInstance};
|
||||
use iced::wgpu;
|
||||
|
||||
pub fn instance_layout<'a>() -> wgpu::VertexBufferLayout<'a> {
|
||||
const ATTRS: &[wgpu::VertexAttribute] = &[
|
||||
wgpu::VertexAttribute {
|
||||
offset: std::mem::offset_of!(PointInstance, pos) as u64,
|
||||
shader_location: 0,
|
||||
format: wgpu::VertexFormat::Float32x3,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: std::mem::offset_of!(PointInstance, pos_low) as u64,
|
||||
shader_location: 1,
|
||||
format: wgpu::VertexFormat::Float32x3,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: std::mem::offset_of!(PointInstance, color) as u64,
|
||||
shader_location: 2,
|
||||
format: wgpu::VertexFormat::Unorm8x4,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: std::mem::offset_of!(PointInstance, normal) as u64,
|
||||
shader_location: 3,
|
||||
format: wgpu::VertexFormat::Snorm8x4,
|
||||
},
|
||||
];
|
||||
wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<PointInstance>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: ATTRS,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn params_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("point_cloud.bgl1"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
struct CloudBuffer {
|
||||
key: u64,
|
||||
buffer: wgpu::Buffer,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
/// The resident point clouds of one viewport pipeline.
|
||||
pub struct PointCloudGpu {
|
||||
clouds: Vec<CloudBuffer>,
|
||||
params: wgpu::Buffer,
|
||||
bind_group: wgpu::BindGroup,
|
||||
point_size: f32,
|
||||
params_written: [f32; 12],
|
||||
/// The share of each cloud's points drawn (real-time density).
|
||||
fraction: f32,
|
||||
}
|
||||
|
||||
impl PointCloudGpu {
|
||||
pub fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout) -> Self {
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("point_cloud.params"),
|
||||
size: 48,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("point_cloud.bind_group1"),
|
||||
layout,
|
||||
entries: &[wgpu::BindGroupEntry { binding: 0, resource: params.as_entire_binding() }],
|
||||
});
|
||||
Self {
|
||||
clouds: Vec::new(),
|
||||
params,
|
||||
bind_group,
|
||||
point_size: f32::NAN,
|
||||
params_written: [f32::NAN; 12],
|
||||
fraction: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.clouds.is_empty()
|
||||
}
|
||||
|
||||
/// Make `set` resident, keeping the buffers of clouds it already had.
|
||||
pub fn upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, set: &PointCloudSet) {
|
||||
let mut old = std::mem::take(&mut self.clouds);
|
||||
for cloud in set.clouds.iter().filter(|cloud| !cloud.instances.is_empty()) {
|
||||
if let Some(at) = old.iter().position(|resident| resident.key == cloud.key) {
|
||||
self.clouds.push(old.swap_remove(at));
|
||||
continue;
|
||||
}
|
||||
self.clouds.push(CloudBuffer {
|
||||
key: cloud.key,
|
||||
buffer: super::gpu_upload::upload_buffer(
|
||||
device,
|
||||
queue,
|
||||
"point_cloud.instances",
|
||||
&cloud.instances,
|
||||
wgpu::BufferUsages::VERTEX,
|
||||
),
|
||||
count: cloud.instances.len() as u32,
|
||||
});
|
||||
}
|
||||
self.point_size = set.point_size;
|
||||
}
|
||||
|
||||
/// The view's lighting (`[mode, source, fraction, _, light x, y, z, _]`)
|
||||
/// with the sprite size, written when they change.
|
||||
pub fn set_view(&mut self, queue: &wgpu::Queue, light: [f32; 12]) {
|
||||
self.fraction = light[2].clamp(0.0, 1.0);
|
||||
let mut params = light;
|
||||
params[0] = self.point_size;
|
||||
params[1] = light[0];
|
||||
params[2] = light[1];
|
||||
params[3] = 0.0;
|
||||
if params != self.params_written {
|
||||
self.params_written = params;
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
|
||||
pass.set_bind_group(1, &self.bind_group, &[]);
|
||||
for cloud in &self.clouds {
|
||||
pass.set_vertex_buffer(0, cloud.buffer.slice(..));
|
||||
let count = ((cloud.count as f32 * self.fraction).ceil() as u32).clamp(1, cloud.count);
|
||||
pass.draw(0..6, 0..count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,11 @@ pub struct ViewportData {
|
||||
/// Wipeout fills — rendered in a separate pass AFTER wires.
|
||||
pub(in crate::scene) wipeout_hatches: Arc<Vec<HatchModel>>,
|
||||
pub(in crate::scene) images: Arc<Vec<ImageModel>>,
|
||||
/// Point cloud sprites; empty on the paper sheet and in 2D wireframe.
|
||||
pub(in crate::scene) point_clouds: Arc<crate::scene::model::point_cloud::PointCloudSet>,
|
||||
/// Point cloud lighting and density for this view: `[mode, source,
|
||||
/// drawn fraction, _, light direction x, y, z, _]`.
|
||||
pub(in crate::scene) point_cloud_light: [f32; 12],
|
||||
pub(in crate::scene) meshes: Arc<Vec<MeshLodSet>>,
|
||||
pub(in crate::scene) background_image:
|
||||
Option<crate::scene::model::image_model::DecodedImage>,
|
||||
@@ -554,6 +559,15 @@ impl shader::Primitive for Primitive {
|
||||
inner.upload_images(device, queue, &vp.images[..]);
|
||||
inner.cached_image_source = Some(Arc::clone(&vp.images));
|
||||
}
|
||||
if inner
|
||||
.cached_point_cloud_source
|
||||
.as_ref()
|
||||
.map_or(true, |source| !Arc::ptr_eq(source, &vp.point_clouds))
|
||||
{
|
||||
inner.upload_point_clouds(device, queue, &vp.point_clouds);
|
||||
inner.cached_point_cloud_source = Some(Arc::clone(&vp.point_clouds));
|
||||
}
|
||||
inner.set_point_cloud_view(queue, vp.point_cloud_light);
|
||||
if inner
|
||||
.cached_text_source
|
||||
.as_ref()
|
||||
@@ -1411,6 +1425,13 @@ fn render_signature(vp: &ViewportData, placement: &PhysicalViewport) -> u64 {
|
||||
std::sync::Arc::as_ptr(&vp.meshes) as usize
|
||||
}
|
||||
.hash(&mut h);
|
||||
if vp.point_clouds.clouds.is_empty() {
|
||||
std::ptr::null()
|
||||
} else {
|
||||
std::sync::Arc::as_ptr(&vp.point_clouds)
|
||||
}
|
||||
.hash(&mut h);
|
||||
vp.point_cloud_light.map(f32::to_bits).hash(&mut h);
|
||||
vp.geometry_epoch.hash(&mut h);
|
||||
vp.selection_generation.hash(&mut h);
|
||||
vp.selected_sig.hash(&mut h);
|
||||
@@ -4510,21 +4531,31 @@ impl Scene {
|
||||
} else {
|
||||
Arc::new(Vec::new())
|
||||
};
|
||||
// 2D wireframe draws no cloud points; the clouds show their extents
|
||||
// and a message there instead (drawn with the live overlays).
|
||||
let wireframe_2d = inst.render_mode == codec::entities::ViewportRenderMode::Wireframe2D;
|
||||
let cloud_2d_wires = if wireframe_2d && show_live_overlay && !inst.paper_sheet {
|
||||
self.point_cloud_2d_wires()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let preview_wires = if !show_live_overlay
|
||||
|| (self.interim_wire.is_none()
|
||||
&& self.preview_wires.is_empty()
|
||||
&& self.constraint_hover_wires.is_empty())
|
||||
&& self.constraint_hover_wires.is_empty()
|
||||
&& cloud_2d_wires.is_empty())
|
||||
{
|
||||
Arc::new(Vec::new())
|
||||
} else {
|
||||
let mut v: Vec<WireModel> = Vec::with_capacity(
|
||||
self.preview_wires.len() + self.constraint_hover_wires.len() + 1,
|
||||
self.preview_wires.len() + self.constraint_hover_wires.len() + cloud_2d_wires.len() + 1,
|
||||
);
|
||||
if let Some(iw) = &self.interim_wire {
|
||||
v.push(iw.clone());
|
||||
}
|
||||
v.extend(self.preview_wires.iter().cloned());
|
||||
v.extend(self.constraint_hover_wires.iter().cloned());
|
||||
v.extend(cloud_2d_wires);
|
||||
Arc::new(v)
|
||||
};
|
||||
let preview_hatches = if show_live_overlay {
|
||||
@@ -4720,6 +4751,7 @@ impl Scene {
|
||||
} else {
|
||||
self.meshes_for_viewport(inst.handle, &vp_frozen)
|
||||
};
|
||||
let point_clouds = self.point_clouds_for_viewport(&vp_frozen, inst.paper_sheet || wireframe_2d);
|
||||
|
||||
// SDF text quads (behind OCS_TEXT_SDF). The glyph quads ride on each
|
||||
// entity's own wire (produced by the tessellator, transformed for
|
||||
@@ -4803,6 +4835,28 @@ impl Scene {
|
||||
Arc::new(vec![])
|
||||
};
|
||||
let navigating = !inst.paper_sheet && self.navigating_lod();
|
||||
let point_cloud_light = {
|
||||
use crate::scene::model::point_cloud::{setting, setting_value};
|
||||
let value = |name| setting(name).map_or(0, |s| setting_value(&self.document, s)) as f32;
|
||||
// Lit only in the shaded styles, as the reference.
|
||||
let lighting = if flags.mesh_fill && !flags.hidden_line { value("POINTCLOUDLIGHTING") } else { 0.0 };
|
||||
let source = value("POINTCLOUDLIGHTSOURCE");
|
||||
// The lights follow the view (measured from the reference in
|
||||
// view coordinates: right, up, toward the viewer): the default
|
||||
// lighting is a key light from the upper left and a fill light
|
||||
// from the lower right, the other source one light from above.
|
||||
let view = |x: f32, y: f32, z: f32| {
|
||||
(inst.camera.rotation * glam::Vec3::new(x, y, z)).normalize_or(glam::Vec3::Z)
|
||||
};
|
||||
let (first, second) = if source > 0.5 {
|
||||
(view(-0.034, 0.987, 0.158), glam::Vec3::ZERO)
|
||||
} else {
|
||||
(view(-0.512, 0.500, 0.698), view(0.834, -0.451, 0.318))
|
||||
};
|
||||
// While the view moves, POINTCLOUDRTDENSITY per cent of the points.
|
||||
let fraction = if navigating { value("POINTCLOUDRTDENSITY") / 100.0 } else { 1.0 };
|
||||
[lighting, source, fraction, 0.0, first.x, first.y, first.z, 0.0, second.x, second.y, second.z, 0.0]
|
||||
};
|
||||
Some(ViewportData {
|
||||
instance_id,
|
||||
force_rasterize,
|
||||
@@ -4819,6 +4873,8 @@ impl Scene {
|
||||
hatches,
|
||||
wipeout_hatches,
|
||||
images,
|
||||
point_clouds,
|
||||
point_cloud_light,
|
||||
meshes,
|
||||
background_image: display.background.image.clone(),
|
||||
environment_image: display.background.environment.clone(),
|
||||
|
||||
Reference in new issue
Block a user