diff --git a/src/app/commands/pc_colormap.rs b/src/app/commands/pc_colormap.rs index 64501758b..f48aa4123 100644 --- a/src/app/commands/pc_colormap.rs +++ b/src/app/commands/pc_colormap.rs @@ -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 => { diff --git a/src/entities/extended.rs b/src/entities/extended.rs index d7f4fb6c9..e547f0c2e 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -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 } diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 6498ca12e..88712300f 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -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")), diff --git a/src/scene/mod.rs b/src/scene/mod.rs index ed97a2923..d5ae9db30 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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 { + 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, diff --git a/src/scene/model/point_cloud/mod.rs b/src/scene/model/point_cloud/mod.rs index 6503fbcee..6da5f4b7c 100644 --- a/src/scene/model/point_cloud/mod.rs +++ b/src/scene/model/point_cloud/mod.rs @@ -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(items: Vec) -> Vec { + 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 diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 78fe4a1db..e4c94f44c 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -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. diff --git a/src/scene/pipeline/point_cloud_gpu.rs b/src/scene/pipeline/point_cloud_gpu.rs index 0ef86571a..551b039d9 100644 --- a/src/scene/pipeline/point_cloud_gpu.rs +++ b/src/scene/pipeline/point_cloud_gpu.rs @@ -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::() 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(¶ms)); } } @@ -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); } } } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 4e78cfdce..505a8a9cd 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -165,8 +165,11 @@ pub struct ViewportData { /// Wipeout fills — rendered in a separate pass AFTER wires. pub(in crate::scene) wipeout_hatches: Arc>, pub(in crate::scene) images: Arc>, - /// 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, + /// 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>, pub(in crate::scene) background_image: Option, @@ -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 = 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(), diff --git a/src/shaders/point_cloud.wgsl b/src/shaders/point_cloud.wgsl index b033dbbf5..f0feb3b38 100644 --- a/src/shaders/point_cloud.wgsl +++ b/src/shaders/point_cloud.wgsl @@ -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, _pad1: f32, - _pad2: f32, }; @group(1) @binding(0) var params: PointParams; @@ -32,6 +36,7 @@ struct PointIn { @location(0) pos: vec3, @location(1) pos_low: vec3, @location(2) color: vec4, + @location(3) normal: vec4, }; 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(1.0, 1.0)); out.clip_pos = vec4(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(in.color.rgb * shade, in.color.a); + } return out; } diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index 08aaef4df..d70fa4fee 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -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 = state.ramps.iter().map(|r| r.name.clone()).collect(); + // Listed by name, as the reference lists them. + let mut names: Vec = 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))