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 <[email protected]>
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 {
// 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]
+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 ─────────────────────────────────────────────────────
/// 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));
+7 -3
View File
@@ -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);
}
+10 -6
View File
@@ -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(&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>,
/// 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<Vec<MeshLodSet>>,
pub(in crate::scene) background_image:
Option<crate::scene::model::image_model::DecodedImage>,
@@ -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,
+18 -9
View File
@@ -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<f32>,
_pad1: f32,
fill_dir: vec3<f32>,
_pad2: f32,
};
@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;
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<f32>(in.color.rgb * shade, in.color.a);
out.color = vec4<f32>(in.color.rgb * min(shade, 1.0), in.color.a);
}
return out;
}