wgpu: section cutting tool — K hotkey, click-to-add, drag arrow, Esc/Del

Mirrors GL ViewportWindow's section tool end-to-end.

Hotkeys

  K            toggle the tool active
  Shift+K      clearSectionPlanes
  Esc          deactivate the tool
  Del/Bksp     remove the most recently added plane (tool-active only)
  LMB click    pickSurfaceAt → addSectionPlaneAtSurface (no modifier)
  LMB drag    on the arrow gizmo: slide the plane along its normal

State

FrameUniforms grows by clip_count (i32) + clip_planes[6] (vec4). The
six-plane cap matches GL's MaxSectionPlanes. WGSL pads via three
scalar i32s instead of a vec3<i32> so the array starts at offset 144
to match the tightly-packed C++ struct (240 B) — vec3 would have
forced clip_planes to 160 and broken the binding-size match.

is_section_clipped(world) in WGSL evaluates all active planes and
returns true if any signals "on the positive side". Both main and
pick fragments discard with it so cuts are visible AND selection is
consistent — you can't pick something the user can't see.

Surface pick

Pick pass now emits 2 color targets: R32UInt object_id at @location(0)
and RGBA16F packed world-space normal at @location(1). Normal is
packed × 0.5 + 0.5 so unsigned-ish halfs keep the sign. pickSurfaceAt
reads both via 1×1 texel copies (RGBA16F is a color format with no
full-mip restriction, unlike Depth32Float). World position comes from
ray-AABB intersection against the picked instance's AABB — equally
accurate for "drop a plane where I clicked" and dodges the Depth32Float
copy-extent rule entirely. The pick normal is decoded into the
per-fragment surface normal so the plane lands perpendicular to the
actual triangle (not the AABB face).

Plane gizmo

Identical geometry to GL's renderSectionPlanes: 2×2 m quad outline
(white) + 1 m arrow shaft along +n (yellow-orange) + 4 arrow-head
diagonals. Drawn inside the main MSAA pass with depth LessEqual + no
depth write. Lines are rendered as screen-space-expanded thick quads
with fwidth-based AA, same technique the axis indicator uses, so the
gizmo reads against busy BIM geometry rather than disappearing as
1-px hairlines.

Drag

mousePressEvent claims a plain-LMB press if it hits an arrow gizmo
(12 logical-px grab radius, distance to the (origin, origin+n)
screen-space segment). The drag handler projects the cursor delta
onto the screen-space axis and converts to metres via
delta·axis / |axis|² — same formula GL uses. Mid-drag camera moves
keep working because the projection re-runs every frame against the
press-time origin.

Closes the click-to-add + drag halves of #30 / #60.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-30 15:18:34 +10:00
parent e6c6df905a
commit 8173074050
2 changed files with 1002 additions and 31 deletions
File diff suppressed because it is too large Load Diff
+85 -2
View File
@@ -248,8 +248,39 @@ private:
// Synchronous pick: encodes a one-shot R32UInt render of the current
// visible_draws against the click pixel, copies the single texel back,
// waits, and returns the object_id (0 if nothing was hit). Call from
// the main thread between renders.
uint32_t pickObjectAt(int x_pixels, int y_pixels);
// the main thread between renders. When `normal_out` is non-null, the
// pick pass's RGBA16F normal MRT is also sampled at the same pixel
// (decoded from ×0.5+0.5 packing) so the section tool can drop
// perpendicular cuts.
uint32_t pickObjectAt(int x_pixels, int y_pixels,
QVector3D* normal_out = nullptr);
// Pick + ray-cast — returns the object's id, the world-space point
// where the pick-pixel pillar enters that instance's AABB, and a
// camera-facing normal. Returns false on a background miss. We do
// CPU ray-AABB rather than reading per-pixel depth because WebGPU's
// copyTextureToBuffer for Depth32Float requires copying the whole
// mip extent — wasteful per click — and ray-vs-AABB lands close
// enough to the click for the section tool's "drop a plane here" UX.
bool pickSurfaceAt(int x_pixels, int y_pixels,
uint32_t& object_id_out,
QVector3D& world_pos_out,
QVector3D& world_normal_out,
float* aabb_radius_out = nullptr);
// Section-cutting tool. Mirrors the GL ViewportWindow API:
// K toggle (sectionToolActive / toggleSectionTool)
// Shift+K clearSectionPlanes
// click addSectionPlaneAtSurface (when tool active)
// Del/Backspace removeSectionPlane (most recent, when tool active)
// Esc deactivate tool
bool sectionToolActive() const { return section_tool_active_; }
void toggleSectionTool();
bool addSectionPlaneAtSurface(const QVector3D& point,
const QVector3D& normal,
float visual_radius = 0.0f);
void removeSectionPlane(int index);
void clearSectionPlanes();
int sectionPlaneCount() const { return int(section_planes_.size()); }
void ensureHizTextures(int viewport_w, int viewport_h);
void releaseHizResources();
// Resolves the just-rendered MSAA depth into the small single-sample
@@ -445,12 +476,64 @@ private:
WGPURenderPipeline pick_pipeline_ = nullptr;
WGPUTexture pick_color_texture_ = nullptr;
WGPUTextureView pick_color_view_ = nullptr;
// Second pick MRT: RGBA16F packed world-space normal. Sampled by
// pickSurfaceAt so section cuts are perpendicular to the actual
// picked triangle (rather than the AABB face that contains it).
WGPUTexture pick_normal_texture_ = nullptr;
WGPUTextureView pick_normal_view_ = nullptr;
WGPUBuffer pick_normal_staging_buffer_ = nullptr; // 256 B (one RGBA16F texel padded)
WGPUTexture pick_depth_texture_ = nullptr;
WGPUTextureView pick_depth_view_ = nullptr;
WGPUBuffer pick_staging_buffer_ = nullptr; // 256 B (single texel + bytes-per-row pad)
int pick_w_ = 0;
int pick_h_ = 0;
// Section-cutting state. Each plane is (n, d) with normal n in world
// space and signed distance d = -dot(n, point_on_plane); a point P is
// on the kept side iff dot(n, P) + d <= 0. The vector mirrors GL's
// ViewportWindow::section_planes_.
struct SectionPlane {
QVector3D n; // unit normal
float d; // -dot(n, origin)
QVector3D origin; // surface point at the moment the plane was added
float visual_radius; // half-extent for the gizmo quad (set from the picked instance's AABB diagonal)
};
std::vector<SectionPlane> section_planes_;
bool section_tool_active_ = false;
// Drag-to-move state for the arrow gizmo. While `section_drag_active_`
// is true, mouseMoveEvent calls updateSectionDrag instead of letting
// the press fall through to the orbit/pan handlers.
bool section_drag_active_ = false;
int section_drag_index_ = -1;
QPoint section_drag_start_mouse_;
QVector3D section_drag_start_origin_;
// Mirrors GL ViewportWindow::hitTestSectionGizmo: returns the index of
// the plane whose arrow gizmo is within grab_px of (x, y), or -1.
int hitTestSectionGizmo(int x, int y) const;
// Mirrors GL ViewportWindow::updateSectionDrag: projects the cursor
// delta onto the plane's normal in screen space and slides the plane
// along that direction.
void updateSectionDrag(int x, int y);
// Section plane visualisation. Renders one translucent quad per active
// plane, sized to the scene AABB so the cut is visible at any zoom.
// Built once (unit quad in plane-local space), oriented per-plane in
// the vertex shader from u_origin + tangent/bitangent (derived from
// the plane normal). Two passes: a back-facing fill (alpha 0.18) for
// the "behind geometry" hint plus a front-facing fill (alpha 0.35).
WGPUShaderModule section_shader_module_ = nullptr;
WGPUBindGroupLayout section_bgl_ = nullptr;
WGPUPipelineLayout section_pipeline_layout_ = nullptr;
WGPURenderPipeline section_pipeline_ = nullptr;
WGPUBuffer section_vertex_buffer_ = nullptr;
WGPUBuffer section_uniform_buffer_ = nullptr;
WGPUBindGroup section_bind_group_ = nullptr;
static constexpr uint32_t kSectionUniformSlotSize = 256;
bool buildSectionVisualizer();
void encodeSectionPlanes(WGPURenderPassEncoder pass,
const QMatrix4x4& view_proj);
void releaseSectionVisualizer();
enum class HizSlotState : uint8_t { Idle, Mapping, Mapped };
static constexpr int HIZ_SLOTS = 2;
WGPUBuffer hiz_staging_buffers_[HIZ_SLOTS] = { nullptr, nullptr };