Point clouds: lighting, 2D visual style, real-time density

Points carry their world normal: POINTCLOUDLIGHTING lights them in the
shaded visual styles (1 lit from both sides, 2 with back faces dark),
from the view (POINTCLOUDLIGHTSOURCE 0) or the default lights (1).
The 2D wireframe style draws no cloud points: a found cloud shows its
extents and "Point clouds are not displayed in 2D visual style.",
or nothing with POINTCLOUD2DVSDISPLAY 1. While the view moves only
POINTCLOUDRTDENSITY per cent of the points are drawn; the points are
stored in an order where every leading part samples the whole cloud.
The colour map dialog lists its schemes by name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ramox81andClaude Opus 5.5 committed 2026-09-30 14:54:03 +03:00
1 parent ee0d4aedb5
commit b69ae291bb
10 files changed
+202 -22

No files matched your search

+5 -1
View File
@@ -216,7 +216,11 @@ impl OpenCADStudio {
}
PdfDialogMsg::MapGradient(on) => state.slot_mut().gradient = on,
PdfDialogMsg::MapNew => {
let name = state.ramp().map(|r| format!("{} (2)", r.name)).unwrap_or_default();
// "Color Scheme 0", or the first number not taken.
let name = (0..)
.map(|n| format!("Color Scheme {n}"))
.find(|name| !state.ramps.iter().any(|r| r.name.eq_ignore_ascii_case(name)))
.unwrap_or_default();
state.naming = Some((true, name));
}
PdfDialogMsg::MapRename => {
+19 -6
View File
@@ -474,6 +474,17 @@ fn point_cloud_clip_lines(data: &PointCloudData) -> Vec<[f64; 3]> {
/// the scan file; the box only picks the cloud, unless the file cannot be
/// found: then the box shows, with the saved path written across it.
fn point_cloud_ex_lines(data: &PointCloudExData, document: &codec::CadDocument) -> Vec<[f64; 3]> {
let mut points = point_cloud_ex_box(data);
if crate::scene::model::point_cloud::resolve_source(document, data).is_none() {
let saved = crate::scene::model::point_cloud::definition(document, data)
.map(|definition| definition.source_filename.clone())
.unwrap_or_default();
append_missing_cloud_label(&mut points, data, &saved);
}
points
}
fn point_cloud_ex_box(data: &PointCloudExData) -> Vec<[f64; 3]> {
let corners = codec::entities::point_cloud_ex_corners(data).map(|c| [c.x, c.y, c.z]);
let mut points = Vec::new();
for a in 0..8 {
@@ -483,12 +494,14 @@ fn point_cloud_ex_lines(data: &PointCloudExData, document: &codec::CadDocument)
}
}
}
if crate::scene::model::point_cloud::resolve_source(document, data).is_none() {
let saved = crate::scene::model::point_cloud::definition(document, data)
.map(|definition| definition.source_filename.clone())
.unwrap_or_default();
append_missing_cloud_label(&mut points, data, &saved);
}
points
}
/// A cloud in the 2D wireframe visual style, which draws no points: its
/// extents box with the reference's message across the base.
pub(crate) fn point_cloud_2d_style_lines(data: &PointCloudExData) -> Vec<[f64; 3]> {
let mut points = point_cloud_ex_box(data);
append_missing_cloud_label(&mut points, data, "Point clouds are not displayed in 2D visual style.");
points
}
+1
View File
@@ -142,6 +142,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Search" => Some(("insert", "pc-search")),
"Collapse all" => Some(("insert", "pc-collapse-all")),
"Expand all" => Some(("insert", "pc-expand-all")),
"Scheme name" => Some(("insert", "pcm-scheme-name")),
"Stylization" => Some(("properties", "stylization")),
"Color scheme" => Some(("properties", "color-scheme")),
"Scan Colors" => Some(("properties", "scan-colors")),
+44
View File
@@ -8429,6 +8429,50 @@ impl Scene {
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,
+33 -1
View File
@@ -209,13 +209,15 @@ pub(crate) fn resolve_source(document: &CadDocument, data: &PointCloudExData) ->
}
/// One drawn point: position as two f32 (high + low, like every other
/// renderer vertex) and its colour.
/// 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.
@@ -414,6 +416,11 @@ pub(crate) fn placed(
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: [
@@ -422,8 +429,12 @@ pub(crate) fn placed(
(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),
@@ -436,6 +447,27 @@ pub(crate) fn 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
+4
View File
@@ -3848,6 +3848,10 @@ analytic={:.1} regular={:.1} blocks={:.1}",
self.gpu_point_clouds.upload(device, queue, set);
}
pub fn set_point_cloud_view(&mut self, queue: &wgpu::Queue, light: [f32; 8]) {
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.
+32 -7
View File
@@ -1,7 +1,8 @@
// 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, 16 bytes).
// Group 1 binding 0 — PointParams (sprite size in pixels, lighting mode and
// light source, the light direction; 32 bytes).
//
// Buffers are keyed by `PlacedCloud::key`: a new render set re-uploads only
// the clouds whose points changed; the rest keep their buffers.
@@ -26,6 +27,11 @@ pub fn instance_layout<'a>() -> wgpu::VertexBufferLayout<'a> {
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,
@@ -62,13 +68,16 @@ pub struct PointCloudGpu {
params: wgpu::Buffer,
bind_group: wgpu::BindGroup,
point_size: f32,
params_written: [f32; 8],
/// 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: 16,
size: 32,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -77,7 +86,14 @@ impl PointCloudGpu {
layout,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: params.as_entire_binding() }],
});
Self { clouds: Vec::new(), params, bind_group, point_size: f32::NAN }
Self {
clouds: Vec::new(),
params,
bind_group,
point_size: f32::NAN,
params_written: [f32::NAN; 8],
fraction: 1.0,
}
}
pub fn is_empty(&self) -> bool {
@@ -104,9 +120,17 @@ impl PointCloudGpu {
count: cloud.instances.len() as u32,
});
}
if self.point_size != set.point_size {
self.point_size = set.point_size;
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&[set.point_size, 0.0, 0.0, 0.0]));
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; 8]) {
self.fraction = light[2].clamp(0.0, 1.0);
let params = [self.point_size, light[0], light[1], 0.0, light[4], light[5], light[6], 0.0];
if params != self.params_written {
self.params_written = params;
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&params));
}
}
@@ -114,7 +138,8 @@ impl PointCloudGpu {
pass.set_bind_group(1, &self.bind_group, &[]);
for cloud in &self.clouds {
pass.set_vertex_buffer(0, cloud.buffer.slice(..));
pass.draw(0..6, 0..cloud.count);
let count = ((cloud.count as f32 * self.fraction).ceil() as u32).clamp(1, cloud.count);
pass.draw(0..6, 0..count);
}
}
}
+39 -4
View File
@@ -165,8 +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.
/// 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; 8],
pub(in crate::scene) meshes: Arc<Vec<MeshLodSet>>,
pub(in crate::scene) background_image:
Option<crate::scene::model::image_model::DecodedImage>,
@@ -564,6 +567,7 @@ impl shader::Primitive for Primitive {
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()
@@ -1427,6 +1431,7 @@ fn render_signature(vp: &ViewportData, placement: &PhysicalViewport) -> u64 {
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);
@@ -4526,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 {
@@ -4736,7 +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);
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
@@ -4820,6 +4835,25 @@ 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");
let forward = (inst.camera.rotation * glam::Vec3::NEG_Z).normalize_or(glam::Vec3::NEG_Z);
// ponytail: the default lights as one light from the upper left
// of the view; the reference's exact light set is not known.
let light = if source > 0.5 {
let (up, right) = (inst.camera.rotation * glam::Vec3::Y, inst.camera.rotation * glam::Vec3::X);
(-forward * 0.6 + up * 0.6 - right * 0.5).normalize_or(-forward)
} else {
-forward
};
// 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, light.x, light.y, light.z, 0.0]
};
Some(ViewportData {
instance_id,
force_rasterize,
@@ -4837,6 +4871,7 @@ impl Scene {
wipeout_hatches,
images,
point_clouds,
point_cloud_light,
meshes,
background_image: display.background.image.clone(),
environment_image: display.background.environment.clone(),
+21 -1
View File
@@ -22,9 +22,13 @@ struct Uniforms {
struct PointParams {
size_px: f32,
// POINTCLOUDLIGHTING (0 off; 1 lit from both sides; 2 back faces dark)
// and POINTCLOUDLIGHTSOURCE (0 headlight; 1 the default lights).
lighting: f32,
source: f32,
_pad0: f32,
light_dir: vec3<f32>,
_pad1: f32,
_pad2: f32,
};
@group(1) @binding(0) var<uniform> params: PointParams;
@@ -32,6 +36,7 @@ struct PointIn {
@location(0) pos: vec3<f32>,
@location(1) pos_low: vec3<f32>,
@location(2) color: vec4<f32>,
@location(3) normal: vec4<f32>,
};
struct VertOut {
@@ -54,6 +59,21 @@ fn vs_main(@builtin(vertex_index) corner: u32, in: PointIn) -> VertOut {
let half_ndc = params.size_px / max(u.viewport_size, vec2<f32>(1.0, 1.0));
out.clip_pos = vec4<f32>(clip.xy + corners[corner] * half_ndc * clip.w, clip.z, clip.w);
out.color = in.color;
let n = in.normal.xyz;
if params.lighting > 0.5 && dot(n, n) > 0.01 {
// Measured from the reference: headlight ambient 0.2 + 0.576 diffuse,
// the default lights 0.3 + 0.9 (clamped).
let d = dot(normalize(n), params.light_dir);
var shade: f32;
if params.source > 0.5 {
shade = min(0.3 + 0.9 * max(d, 0.0), 1.0);
} else if params.lighting > 1.5 {
shade = 0.2 + 0.576 * max(d, 0.0);
} else {
shade = 0.2 + 0.576 * abs(d);
}
out.color = vec4<f32>(in.color.rgb * shade, in.color.a);
}
return out;
}
+4 -2
View File
@@ -1343,7 +1343,9 @@ pub fn view_point_cloud_color_map<'a>(
ramp_bar(&colors, slot.gradient, state.cloud.and(state.range()), !state.elevation_tab),
);
let names: Vec<String> = state.ramps.iter().map(|r| r.name.clone()).collect();
// Listed by name, as the reference lists them.
let mut names: Vec<String> = state.ramps.iter().map(|r| r.name.clone()).collect();
names.sort_by_key(|n| n.to_lowercase());
let scheme = pick_list(ramp.map(|r| r.name.clone()), names, |n: &String| n.clone())
.on_select(|n| msg(PdfDialogMsg::MapScheme(n)))
.text_size(12)
@@ -1397,7 +1399,7 @@ pub fn view_point_cloud_color_map<'a>(
if let Some((_, name)) = &state.naming {
scheme_rows = scheme_rows.push(
row![
text(t!("Name")).size(11).style(muted_style).width(Length::Fixed(110.0)),
text(t!("Scheme name")).size(11).style(muted_style).width(Length::Fixed(110.0)),
text_input("", name)
.on_input(|v| msg(PdfDialogMsg::MapNameInput(v)))
.on_submit(msg(PdfDialogMsg::MapNameOk))