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 <noreply@anthropic.com>
This commit is contained in:
ramox81andClaude Opus 5.5 committed 2026-09-30 17:21:33 +03:00
1 parent 4b00504453
commit 15c0da8b79
6 files changed
+87 -39

No files matched your search

+21 -4
View File
@@ -1666,8 +1666,8 @@ impl OpenCADStudio {
} }
} }
if grip.gizmo { if grip.gizmo {
// The first move measures where the part was grabbed; the // The cloud follows the cursor by the offset it was grabbed
// cloud then follows the cursor by that offset. // at (measured on the press; the first move as a fallback).
let grab = match grip.grab { let grab = match grip.grab {
Some(grab) => grab, Some(grab) => grab,
None => { None => {
@@ -3639,8 +3639,25 @@ impl OpenCADStudio {
self.grip_popup = None; self.grip_popup = None;
return Task::none(); return Task::none();
} }
self.tabs[i].active_grip = let mut edit = self.grip_edit_for_hit(i, handle, grip_id, is_translate, world);
Some(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_hover = None;
self.grip_popup = None; self.grip_popup = None;
self.tabs[i] self.tabs[i]
+19 -8
View File
@@ -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 ───────────────────────────────────────────────────── // ── Active drag state ─────────────────────────────────────────────────────
/// Stored on `OpenCADStudio` while a grip is being dragged. /// 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() { for (index, g) in grips.iter().enumerate() {
let project = |world: DVec3| camera.project(world, bounds); 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; continue;
}; };
let dx = screen.x - cursor.x; let d = marker_distance(g.shape, screen, dir, cursor);
let dy = screen.y - cursor.y;
let d = (dx * dx + dy * dy).sqrt();
if d < best_dist { if d < best_dist {
best_dist = d; best_dist = d;
best = Some((index, g.id, g.is_midpoint, g.world)); 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() { for (index, g) in grips.iter().enumerate() {
let project = |world: DVec3| project_rte(world, view_rot, eye, bounds); 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; continue;
}; };
let dx = screen.x - cursor.x; let d = marker_distance(g.shape, screen, dir, cursor);
let dy = screen.y - cursor.y;
let d = (dx * dx + dy * dy).sqrt();
if d < best_dist { if d < best_dist {
best_dist = d; best_dist = d;
best = Some((index, g.id, g.is_midpoint, g.world)); best = Some((index, g.id, g.is_midpoint, g.world));
+7 -3
View File
@@ -2813,7 +2813,9 @@ impl Pipeline {
); );
for &i in idxs { for &i in idxs {
if let Some(w) = wires.get(i as usize) { 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; continue;
} }
selected_wires.push(w); selected_wires.push(w);
@@ -2825,7 +2827,9 @@ impl Pipeline {
if let Some(idxs) = self.wire_handle_index.get(&h.value()) { if let Some(idxs) = self.wire_handle_index.get(&h.value()) {
for &i in idxs { for &i in idxs {
if let Some(w) = wires.get(i as usize) { 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; continue;
} }
hover_wires.push(w); hover_wires.push(w);
@@ -3848,7 +3852,7 @@ analytic={:.1} regular={:.1} blocks={:.1}",
self.gpu_point_clouds.upload(device, queue, set); 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); self.gpu_point_clouds.set_view(queue, light);
} }
+10 -6
View File
@@ -2,7 +2,7 @@
// screen-space square sprites (6 vertices per instance, no vertex buffer). // screen-space square sprites (6 vertices per instance, no vertex buffer).
// //
// Group 1 binding 0 — PointParams (sprite size in pixels, lighting mode and // 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 // Buffers are keyed by `PlacedCloud::key`: a new render set re-uploads only
// the clouds whose points changed; the rest keep their buffers. // the clouds whose points changed; the rest keep their buffers.
@@ -68,7 +68,7 @@ pub struct PointCloudGpu {
params: wgpu::Buffer, params: wgpu::Buffer,
bind_group: wgpu::BindGroup, bind_group: wgpu::BindGroup,
point_size: f32, point_size: f32,
params_written: [f32; 8], params_written: [f32; 12],
/// The share of each cloud's points drawn (real-time density). /// The share of each cloud's points drawn (real-time density).
fraction: f32, fraction: f32,
} }
@@ -77,7 +77,7 @@ impl PointCloudGpu {
pub fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout) -> Self { pub fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout) -> Self {
let params = device.create_buffer(&wgpu::BufferDescriptor { let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("point_cloud.params"), label: Some("point_cloud.params"),
size: 32, size: 48,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false, mapped_at_creation: false,
}); });
@@ -91,7 +91,7 @@ impl PointCloudGpu {
params, params,
bind_group, bind_group,
point_size: f32::NAN, point_size: f32::NAN,
params_written: [f32::NAN; 8], params_written: [f32::NAN; 12],
fraction: 1.0, fraction: 1.0,
} }
} }
@@ -125,9 +125,13 @@ impl PointCloudGpu {
/// The view's lighting (`[mode, source, fraction, _, light x, y, z, _]`) /// The view's lighting (`[mode, source, fraction, _, light x, y, z, _]`)
/// with the sprite size, written when they change. /// 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); 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 { if params != self.params_written {
self.params_written = params; self.params_written = params;
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&params)); queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&params));
+12 -9
View File
@@ -169,7 +169,7 @@ pub struct ViewportData {
pub(in crate::scene) point_clouds: Arc<crate::scene::model::point_cloud::PointCloudSet>, pub(in crate::scene) point_clouds: Arc<crate::scene::model::point_cloud::PointCloudSet>,
/// Point cloud lighting and density for this view: `[mode, source, /// Point cloud lighting and density for this view: `[mode, source,
/// drawn fraction, _, light direction x, y, z, _]`. /// 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<Vec<MeshLodSet>>, pub(in crate::scene) meshes: Arc<Vec<MeshLodSet>>,
pub(in crate::scene) background_image: pub(in crate::scene) background_image:
Option<crate::scene::model::image_model::DecodedImage>, Option<crate::scene::model::image_model::DecodedImage>,
@@ -4841,18 +4841,21 @@ impl Scene {
// Lit only in the shaded styles, as the reference. // Lit only in the shaded styles, as the reference.
let lighting = if flags.mesh_fill && !flags.hidden_line { value("POINTCLOUDLIGHTING") } else { 0.0 }; let lighting = if flags.mesh_fill && !flags.hidden_line { value("POINTCLOUDLIGHTING") } else { 0.0 };
let source = value("POINTCLOUDLIGHTSOURCE"); let source = value("POINTCLOUDLIGHTSOURCE");
let forward = (inst.camera.rotation * glam::Vec3::NEG_Z).normalize_or(glam::Vec3::NEG_Z); // The lights follow the view (measured from the reference in
// ponytail: the default lights as one light from the upper left // view coordinates: right, up, toward the viewer): the default
// of the view; the reference's exact light set is not known. // lighting is a key light from the upper left and a fill light
let light = if source > 0.5 { // from the lower right, the other source one light from above.
let (up, right) = (inst.camera.rotation * glam::Vec3::Y, inst.camera.rotation * glam::Vec3::X); let view = |x: f32, y: f32, z: f32| {
(-forward * 0.6 + up * 0.6 - right * 0.5).normalize_or(-forward) (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 { } 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. // While the view moves, POINTCLOUDRTDENSITY per cent of the points.
let fraction = if navigating { value("POINTCLOUDRTDENSITY") / 100.0 } else { 1.0 }; 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 { Some(ViewportData {
instance_id, instance_id,
+18 -9
View File
@@ -22,13 +22,15 @@ struct Uniforms {
struct PointParams { struct PointParams {
size_px: f32, size_px: f32,
// POINTCLOUDLIGHTING (0 off; 1 lit from both sides; 2 back faces dark) // POINTCLOUDLIGHTING (0 off; 1 front faces lit; 2 both faces) and
// and POINTCLOUDLIGHTSOURCE (0 headlight; 1 the default lights). // POINTCLOUDLIGHTSOURCE (0 key + fill light; 1 one light from above).
lighting: f32, lighting: f32,
source: f32, source: f32,
_pad0: f32, _pad0: f32,
light_dir: vec3<f32>, light_dir: vec3<f32>,
_pad1: f32, _pad1: f32,
fill_dir: vec3<f32>,
_pad2: f32,
}; };
@group(1) @binding(0) var<uniform> params: PointParams; @group(1) @binding(0) var<uniform> params: PointParams;
@@ -61,18 +63,25 @@ fn vs_main(@builtin(vertex_index) corner: u32, in: PointIn) -> VertOut {
out.color = in.color; out.color = in.color;
let n = in.normal.xyz; let n = in.normal.xyz;
if params.lighting > 0.5 && dot(n, n) > 0.01 { if params.lighting > 0.5 && dot(n, n) > 0.01 {
// Measured from the reference: headlight ambient 0.2 + 0.576 diffuse, // Fitted to the reference's shading of the normals.
// the default lights 0.3 + 0.9 (clamped). let nn = normalize(n);
let d = dot(normalize(n), params.light_dir); 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; var shade: f32;
if params.source > 0.5 { 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 { } 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 { } 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<f32>(in.color.rgb * shade, in.color.a); out.color = vec4<f32>(in.color.rgb * min(shade, 1.0), in.color.a);
} }
return out; return out;
} }