From 15c0da8b79fcd0f33e1bce83b1e702ed20f95697 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:21:33 +0300 Subject: [PATCH] Point clouds: measured lighting, highlighted extents box, gizmo shaft grab - Lighting follows the view: the default source is a key light from the upper left and a fill light from the lower right over an ambient term, the other source one light from above; POINTCLOUDLIGHTING 1 lights the front faces, 2 both. - A selected or hovered point cloud shows its extents box. - A move-gizmo arrow can be grabbed along its shaft; the grab offset is taken on the press, so click-and-place and typed distances start from the insertion point. Co-Authored-By: Claude Opus 5.5 --- src/app/update/viewport.rs | 25 +++++++++++++++++++++---- src/scene/pick/grip.rs | 27 +++++++++++++++++++-------- src/scene/pipeline/mod.rs | 10 +++++++--- src/scene/pipeline/point_cloud_gpu.rs | 16 ++++++++++------ src/scene/view/render.rs | 21 ++++++++++++--------- src/shaders/point_cloud.wgsl | 27 ++++++++++++++++++--------- 6 files changed, 87 insertions(+), 39 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 5d9fc1c16..c12f3646d 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1666,8 +1666,8 @@ impl OpenCADStudio { } } if grip.gizmo { - // The first move measures where the part was grabbed; the - // cloud then follows the cursor by that offset. + // The cloud follows the cursor by the offset it was grabbed + // at (measured on the press; the first move as a fallback). let grab = match grip.grab { Some(grab) => grab, None => { @@ -3639,8 +3639,25 @@ impl OpenCADStudio { self.grip_popup = None; return Task::none(); } - self.tabs[i].active_grip = - Some(self.grip_edit_for_hit(i, handle, grip_id, is_translate, world)); + let mut edit = self.grip_edit_for_hit(i, handle, grip_id, is_translate, world); + if edit.gizmo { + // Where the gizmo part was grabbed: the drag keeps + // that offset from the cloud's insertion point. + let (view_rot, eye) = match &edit_cam { + Some(cam) => (cam.view_proj_rte(bounds), cam.eye()), + None => { + let cam = self.tabs[i].scene.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye()) + } + }; + let grabbed = match (edit.axis, edit.plane) { + (Some(axis), _) => cursor_on_projected_axis(p, bounds, view_rot, eye, world, axis), + (_, Some(normal)) => cursor_on_plane(p, bounds, view_rot, eye, world, normal), + _ => None, + }; + edit.grab = grabbed.map(|point| point - world); + } + self.tabs[i].active_grip = Some(edit); self.grip_hover = None; self.grip_popup = None; self.tabs[i] diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index 047edc769..245d1ad21 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -79,6 +79,21 @@ fn placed_marker( } } +/// 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. @@ -327,12 +342,10 @@ pub fn find_hit_grip( for (index, g) in grips.iter().enumerate() { let project = |world: DVec3| camera.project(world, bounds); - let Some((screen, _)) = placed_marker(g, project(g.world), project) else { + let Some((screen, dir)) = placed_marker(g, project(g.world), project) else { continue; }; - 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)); @@ -396,12 +409,10 @@ pub fn find_hit_grip_rte( for (index, g) in grips.iter().enumerate() { let project = |world: DVec3| project_rte(world, view_rot, eye, bounds); - let Some((screen, _)) = placed_marker(g, project(g.world), project) else { + let Some((screen, dir)) = placed_marker(g, project(g.world), project) else { continue; }; - 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)); diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index e4c94f44c..984d98f39 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -2813,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); @@ -2825,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); @@ -3848,7 +3852,7 @@ 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]) { + pub fn set_point_cloud_view(&mut self, queue: &wgpu::Queue, light: [f32; 12]) { self.gpu_point_clouds.set_view(queue, light); } diff --git a/src/scene/pipeline/point_cloud_gpu.rs b/src/scene/pipeline/point_cloud_gpu.rs index 551b039d9..fecb522d5 100644 --- a/src/scene/pipeline/point_cloud_gpu.rs +++ b/src/scene/pipeline/point_cloud_gpu.rs @@ -2,7 +2,7 @@ // 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 light direction; 32 bytes). +// 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. @@ -68,7 +68,7 @@ pub struct PointCloudGpu { params: wgpu::Buffer, bind_group: wgpu::BindGroup, point_size: f32, - params_written: [f32; 8], + params_written: [f32; 12], /// The share of each cloud's points drawn (real-time density). fraction: f32, } @@ -77,7 +77,7 @@ 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: 32, + size: 48, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -91,7 +91,7 @@ impl PointCloudGpu { params, bind_group, point_size: f32::NAN, - params_written: [f32::NAN; 8], + params_written: [f32::NAN; 12], fraction: 1.0, } } @@ -125,9 +125,13 @@ impl PointCloudGpu { /// 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]) { + pub fn set_view(&mut self, queue: &wgpu::Queue, light: [f32; 12]) { 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]; + 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)); diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 505a8a9cd..deabf59e7 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -169,7 +169,7 @@ pub struct ViewportData { 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) point_cloud_light: [f32; 12], pub(in crate::scene) meshes: Arc>, pub(in crate::scene) background_image: Option, @@ -4841,18 +4841,21 @@ impl Scene { // 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) + // 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 { - -forward + (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, light.x, light.y, light.z, 0.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, diff --git a/src/shaders/point_cloud.wgsl b/src/shaders/point_cloud.wgsl index f0feb3b38..8933ea82d 100644 --- a/src/shaders/point_cloud.wgsl +++ b/src/shaders/point_cloud.wgsl @@ -22,13 +22,15 @@ 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). + // POINTCLOUDLIGHTING (0 off; 1 front faces lit; 2 both faces) and + // POINTCLOUDLIGHTSOURCE (0 key + fill light; 1 one light from above). lighting: f32, source: f32, _pad0: f32, light_dir: vec3, _pad1: f32, + fill_dir: vec3, + _pad2: f32, }; @group(1) @binding(0) var params: PointParams; @@ -61,18 +63,25 @@ fn vs_main(@builtin(vertex_index) corner: u32, in: PointIn) -> VertOut { 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); + // Fitted to the reference's shading of the normals. + let nn = normalize(n); + var d = dot(nn, params.light_dir); + var f = dot(nn, params.fill_dir); + if params.lighting > 1.5 { + d = abs(d); + f = abs(f); + } var shade: f32; if params.source > 0.5 { - shade = min(0.3 + 0.9 * max(d, 0.0), 1.0); + shade = 0.3 + 0.7 * max(d, 0.0); } else if params.lighting > 1.5 { - shade = 0.2 + 0.576 * max(d, 0.0); + // ponytail: best fit only; the reference also darkens some + // regions that no normal-based term explains. + shade = 0.133 + 0.61 * d + 0.086 * f; } else { - shade = 0.2 + 0.576 * abs(d); + shade = 0.204 + 0.569 * max(d, 0.0) + 0.329 * max(f, 0.0); } - out.color = vec4(in.color.rgb * shade, in.color.a); + out.color = vec4(in.color.rgb * min(shade, 1.0), in.color.a); } return out; }