ifcviewer: section-plane cut tool on web — shared gizmo, true-face pick, drag/Del

Full section tool for the web viewport, with the gizmo + interaction shared with
desktop from one codebase.

- True-face surface pick. pickSurfaceAt had always ray-cast the instance AABB (to
  skip a depth readback), so cuts sat in front of the real surface. The pick
  fragment already computes the exact world_pos (it clips sections with it); now
  it OUTPUTS it to a 3rd pick MRT (RGBA32F) that every pick path renders, and
  pickSurfaceAt / pickSurfaceAtAsync read it back (decodeMappedPickPosition;
  ray-AABB kept only as a fallback). The web async pick chains id -> normal ->
  position spontaneous staging maps.
- Web tool: LMB drops a cut at the picked surface (LMB drag still orbits), K
  toggles, Shift+K clears; oriented to the real MRT surface normal. Exports + a
  Section / Clear cuts toolbar pair.
- Shared gizmo: lifted the section-gizmo renderer (SECTION_WGSL + thick-line AA +
  quad+arrow VBO + pack + screen-space hit-test) out of the Qt-coupled
  OverlayRenderer into a Qt-free SectionGizmoRenderer that ViewportCore::render
  draws for BOTH desktop and web (both already render via render()). One identical
  gizmo; OverlayRenderer's now-dead section code removed. Fixed 1 m size (matches
  the desktop constant).
- Interaction (shared): hitTestSectionGizmo (SectionGizmoRenderer::hitTest) +
  beginSectionDrag / updateSectionDrag / endSectionDrag live in ViewportCore.
  Drag a gizmo arrow to slide the plane along its normal; Del/Backspace removes
  the most recent cut. Desktop's ViewportWindow dropped its duplicate hit-test /
  drag math + state and delegates to the core; web wires the same calls.

Tests: sectionPlaneCount add/clear/cap (Catch2, 125); web smoke "click a surface
cuts geometry, clear restores" exercises the shared gizmo + 3-MRT pick (11/11).
Desktop object-pick / marquee unaffected; BonsaiViewer builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-03 17:31:02 +10:00
parent 9ad10c009b
commit da5c0b7991
14 changed files with 1093 additions and 536 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ target_link_options(IfcViewerWeb PRIVATE
# EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't
# add them to Module. ccall lets shell.html pass a JS string (the ?model
# URL) to load_sidecar_from_url_c without manual heap marshalling.
"-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_toggle_xray_c','_xray_is_active_c']"
"-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c']"
# ccall: shell.html passes the ?model URL string to load_sidecar_from_url_c.
# HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large
# sidecar streams by range instead of loading whole). Standard, zero-cost.
+82 -11
View File
@@ -67,6 +67,11 @@ struct AppState {
// bindings (ViewportCore::navBindings), so any preset works on web too.
bool nav_active = false;
NavKind nav_kind = NavKind::None;
// Section-cut tool: while active, a select-button click drops a clip plane
// at the picked surface (K toggles, Shift+K clears — matches desktop).
bool section_tool_active = false;
// True while dragging a section-plane gizmo (LMB down on the arrow → slide).
bool section_dragging = false;
// Accumulated |movement| since mousedown, in CSS px. A select-button release
// under the click threshold (no real drag) is treated as a pick; a drag will
// become a marquee. Captures the down position (canvas-relative CSS px).
@@ -146,6 +151,14 @@ EM_BOOL onMouseDown(int, const EmscriptenMouseEvent* e, void* user) {
auto* app = static_cast<AppState*>(user);
// In fly mode a click exits (matches the desktop app).
if (app->fly_mode) { setFlyMode(app, false); return EM_TRUE; }
// Section tool: LMB on a plane's gizmo arrow grabs it to slide (logical px).
if (app->section_tool_active && e->button == 0) {
const int hit = app->core.hitTestSectionGizmo(int(e->targetX), int(e->targetY));
if (hit >= 0 && app->core.beginSectionDrag(hit, int(e->targetX), int(e->targetY))) {
app->section_dragging = true;
return EM_TRUE; // claim the press — don't orbit
}
}
const NavKind kind = classifyPress(app->core.navBindings(), e->button,
e->shiftKey, e->ctrlKey, e->altKey);
if (kind != NavKind::None) {
@@ -166,6 +179,11 @@ EM_BOOL onMouseMove(int, const EmscriptenMouseEvent* e, void* user) {
app->core.flyLook(float(e->movementX), float(e->movementY));
return EM_TRUE;
}
// Section gizmo drag: slide the grabbed plane along its normal (logical px).
if (app->section_dragging) {
app->core.updateSectionDrag(int(e->targetX), int(e->targetY));
return EM_TRUE;
}
if (!app->nav_active) return EM_FALSE;
const float dx = float(e->movementX);
@@ -191,11 +209,33 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) {
app->nav_active = false;
app->nav_kind = NavKind::None;
if (was_active && kind == NavKind::Select && app->ready) {
const double dpr = emscripten_get_device_pixel_ratio();
const bool add = e->shiftKey;
const bool remove = e->ctrlKey;
if (app->nav_drag_px > kClickDragThresholdPx) {
// End a section-gizmo drag (took over the press; no pick/orbit on release).
if (app->section_dragging) {
app->core.endSectionDrag();
app->section_dragging = false;
return EM_TRUE;
}
if (!was_active || !app->ready) return EM_TRUE;
const double dpr = emscripten_get_device_pixel_ratio();
const bool no_drag = app->nav_drag_px <= kClickDragThresholdPx;
// Section tool claims a LEFT-button click ("click a surface to cut"); LMB
// drag still orbits. Takes priority over nav while the tool is active.
if (app->section_tool_active && e->button == 0 && no_drag) {
const int px = int(app->down_x * dpr), py = int(app->down_y * dpr);
app->core.pickSurfaceAtAsync(px, py, [app](ViewportCore::SurfaceHit hit) {
if (hit.found) // pad past the AABB so the cut reads as a cap
app->core.addSectionPlaneAtSurface(hit.world_pos, hit.world_normal,
hit.aabb_radius * 1.5f);
app->host.requestFrame();
});
return EM_TRUE;
}
if (kind == NavKind::Select) {
const bool add = e->shiftKey, remove = e->ctrlKey;
if (!no_drag) {
// Marquee drag → box-pick the rect (device px) and apply to selection.
hideMarquee();
const long x0 = std::min<long>(app->down_x, e->targetX);
@@ -209,14 +249,11 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) {
app->host.requestFrame();
});
} else {
// No real drag → single pick under the cursor (Shift add, Ctrl remove,
// plain replace). Async readback: highlight lands a frame later.
const int px = int(app->down_x * dpr);
const int py = int(app->down_y * dpr);
// Single pick under the cursor (Shift add, Ctrl remove, plain replace).
const int px = int(app->down_x * dpr), py = int(app->down_y * dpr);
app->core.pickObjectAtAsync(px, py, [app, add, remove](std::uint32_t id) {
app->core.applyPickToSelection(id, add, remove);
// v15 on-demand deferred fetch: log the picked object's IFC GUID
// (first pick fetches the property block off the network).
// v15 on-demand element metadata fetch: log the picked object's IFC GUID.
if (id != 0) app->core.logSelectedObjectGuidWeb(id);
app->host.requestFrame();
});
@@ -300,6 +337,25 @@ EM_BOOL onKeyDown(int, const EmscriptenKeyboardEvent* e, void* user) {
return EM_TRUE;
}
if (!std::strcmp(code, "KeyX") && alt) { app->core.toggleXray(); return EM_TRUE; }
// Section tool: K toggles drop-a-plane mode, Shift+K clears all cuts.
if (!std::strcmp(code, "KeyK")) {
if (shift) app->core.clearSectionPlanes();
else {
app->section_tool_active = !app->section_tool_active;
Log::info() << "[section] tool "
<< (app->section_tool_active ? "active — click a surface" : "off");
}
app->host.requestFrame();
return EM_TRUE;
}
// Del/Backspace removes the most recent cut while the tool is active.
if (app->section_tool_active &&
(!std::strcmp(code, "Delete") || !std::strcmp(code, "Backspace"))) {
const int n = app->core.sectionPlaneCount();
if (n > 0) app->core.removeSectionPlane(n - 1);
app->host.requestFrame();
return EM_TRUE;
}
using SV = ViewportCore::StandardView;
if (!std::strcmp(code, "Home")) app->core.viewAll();
else if (!std::strcmp(code, "KeyF") && !shift) app->core.frameSelection();
@@ -438,6 +494,21 @@ extern "C" EMSCRIPTEN_KEEPALIVE void show_all_c() { if (g_app && g_app->
extern "C" EMSCRIPTEN_KEEPALIVE void toggle_xray_c() { if (g_app && g_app->ready) g_app->core.toggleXray(); }
extern "C" EMSCRIPTEN_KEEPALIVE int xray_is_active_c() { return (g_app && g_app->ready && g_app->core.xrayActive()) ? 1 : 0; }
// Section-cut tool: toggle the drop-a-plane mode, clear all planes, query state.
extern "C" EMSCRIPTEN_KEEPALIVE void toggle_section_c() {
if (!g_app || !g_app->ready) return;
g_app->section_tool_active = !g_app->section_tool_active;
Log::info() << "[section] tool "
<< (g_app->section_tool_active ? "active — click a surface to cut" : "off");
g_app->host.requestFrame();
}
extern "C" EMSCRIPTEN_KEEPALIVE void clear_section_c() {
if (g_app && g_app->ready) { g_app->core.clearSectionPlanes(); g_app->host.requestFrame(); }
}
extern "C" EMSCRIPTEN_KEEPALIVE int section_is_active_c() {
return (g_app && g_app->ready && g_app->section_tool_active) ? 1 : 0;
}
// id: 0 Front, 1 Back, 2 Left, 3 Right, 4 Top, 5 Bottom.
extern "C" EMSCRIPTEN_KEEPALIVE void standard_view_c(int id) {
if (!g_app || !g_app->ready || id < 0 || id > 5) return;
+9
View File
@@ -96,6 +96,9 @@
<button data-act="isolate" title="Isolate selected (Shift+H)">Isolate</button>
<button data-act="showall" title="Show all (Alt+H)">Show all</button>
<button data-act="xray" id="xray-btn" title="X-ray — translucent everything (Alt+X)">X-ray</button>
<span class="sep"></span>
<button data-act="section" id="section-btn" title="Section tool — click a surface to cut (K)">Section</button>
<button data-act="clearcut" title="Clear all section cuts (Shift+K)">Clear cuts</button>
</div>
<div id="status">Starting…</div>
<script>
@@ -176,6 +179,10 @@
var xb = document.getElementById('xray-btn');
if (xb) xb.classList.toggle('active', !!Module._xray_is_active_c());
}
if (Module._section_is_active_c) {
var sb = document.getElementById('section-btn');
if (sb) sb.classList.toggle('active', !!Module._section_is_active_c());
}
Module._raf_tick_c(Module._app_ptr);
}
requestAnimationFrame(shellTick);
@@ -364,6 +371,8 @@
else if (act === 'isolate') Module._isolate_selected_c();
else if (act === 'showall') Module._show_all_c();
else if (act === 'xray') Module._toggle_xray_c();
else if (act === 'section') Module._toggle_section_c();
else if (act === 'clearcut') Module._clear_section_c();
else if (view !== null) Module._standard_view_c(parseInt(view, 10));
});
});
+22
View File
@@ -372,3 +372,25 @@ test('RMB marquee drag box-selects (Web preset)', async ({ page }) => {
expect(hiddenAfter, 'marquee was not hidden after release').toBe(true);
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
});
test('section tool: click a surface cuts geometry, clear restores', async ({ page }) => {
const gpuErrors = [];
page.on('console', (m) => { if (/Uncaptured WebGPU error|is invalid/i.test(m.text())) gpuErrors.push(m.text()); });
await ready(page);
const box = await page.locator('#viewer-canvas').boundingBox();
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
const before = await shot(page);
await page.evaluate(() => window.Module._toggle_section_c());
expect(await page.evaluate(() => window.Module._section_is_active_c())).toBe(1);
// Section tool claims LMB: a left click on the model drops a cut.
await page.mouse.click(cx, cy);
await page.waitForTimeout(700); // async surface pick + add plane + render
const cut = await shot(page);
expect(Buffer.compare(before, cut), 'section cut did not change the render').not.toBe(0);
await page.evaluate(() => window.Module._clear_section_c());
await page.waitForTimeout(400);
const cleared = await shot(page);
expect(Buffer.compare(cleared, cut), 'clearing cuts did not change the render').not.toBe(0);
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
});
+2
View File
@@ -146,6 +146,7 @@ set(IFCVIEWER_CORE_SOURCES
SidecarCompress.cpp
StreamingLoader.cpp
StreamingThread.cpp
SectionGizmoRenderer.cpp
ViewportCore.cpp
)
# Web needs a zstd DECODER (Emscripten has no zstd port; the desktop links the
@@ -195,6 +196,7 @@ set(IFCVIEWER_CORE_HEADERS
ModelGpuData.h
FrameStats.h
OverlayFrame.h
SectionGizmoRenderer.h
SectionPlane.h
SelectionState.h
SidecarCache.h
+1 -270
View File
@@ -87,36 +87,6 @@ void packAxisUniform(uint8_t* dst,
std::memcpy(dst + 92, &viewport_h, sizeof(float));
}
// Pack the section uniform's 256-byte slot. Layout matches WGSL
// SectionUniforms: mat4 + 4×(vec3 + scalar pad) + vec4 + vec2 + 8 B pad
// = 160 B used, padded to 256.
void packSectionUniform(uint8_t* dst,
const Eigen::Matrix4f& mvp,
const Eigen::Vector3f& origin, float half_size,
const Eigen::Vector3f& tangent, float line_width_px,
const Eigen::Vector3f& bitangent,
const Eigen::Vector3f& normal,
float r, float g, float b, float a,
float viewport_w, float viewport_h) {
std::memset(dst, 0, 256);
std::memcpy(dst, mvp.data(), 16 * sizeof(float));
auto put_vec3_pad = [&](size_t off, const Eigen::Vector3f& v, float pad_val) {
float vx = v.x(), vy = v.y(), vz = v.z();
std::memcpy(dst + off + 0, &vx, sizeof(float));
std::memcpy(dst + off + 4, &vy, sizeof(float));
std::memcpy(dst + off + 8, &vz, sizeof(float));
std::memcpy(dst + off + 12, &pad_val, sizeof(float));
};
put_vec3_pad(64, origin, half_size);
put_vec3_pad(80, tangent, line_width_px);
put_vec3_pad(96, bitangent, 0.0f);
put_vec3_pad(112, normal, 0.0f);
float tint[4] = { r, g, b, a };
std::memcpy(dst + 128, tint, sizeof(tint));
std::memcpy(dst + 144, &viewport_w, sizeof(float));
std::memcpy(dst + 148, &viewport_h, sizeof(float));
}
} // namespace
// -----------------------------------------------------------------------------
@@ -185,46 +155,6 @@ fn vs_main(@location(0) start: vec3<f32>,
}
)WGSL";
static const std::string SECTION_WGSL = std::string(THICK_LINE_HELPERS_WGSL) + R"WGSL(
struct SectionUniforms {
mvp: mat4x4<f32>,
origin: vec3<f32>,
half_size: f32,
tangent: vec3<f32>,
line_width_px: f32,
bitangent: vec3<f32>,
_pad1: f32,
normal: vec3<f32>,
_pad2: f32,
tint: vec4<f32>,
viewport_size: vec2<f32>,
_pad3: vec2<f32>,
};
@group(0) @binding(0) var<uniform> u: SectionUniforms;
fn plane_to_world(p: vec3<f32>) -> vec3<f32> {
return u.origin + (u.tangent * p.x + u.bitangent * p.y + u.normal * p.z)
* u.half_size;
}
@vertex
fn vs_main(@location(0) start_local: vec3<f32>,
@location(1) end_local: vec3<f32>,
@location(2) col: vec3<f32>,
@location(3) t: f32,
@location(4) side: f32) -> VsOut {
let p_start = u.mvp * vec4<f32>(plane_to_world(start_local), 1.0);
let p_end = u.mvp * vec4<f32>(plane_to_world(end_local), 1.0);
var out: VsOut;
out.clip_pos = thick_line_clip(p_start, p_end, t, side,
u.viewport_size, u.line_width_px);
out.color = vec4<f32>(col * u.tint.xyz, u.tint.w);
out.side_t = side;
return out;
}
)WGSL";
static const std::string MARQUEE_WGSL = std::string(THICK_LINE_HELPERS_WGSL) + R"WGSL(
struct MarqueeUniforms {
rect_min: vec2<f32>,
@@ -454,7 +384,7 @@ bool OverlayRenderer::init(WGPUInstance instance, WGPUDevice device,
surface_format_ = surface_format;
sample_count_ = sample_count;
if (!buildAxisIndicator()) return false;
if (!buildSectionVisualizer()) return false;
// Section-plane gizmos moved to the shared SectionGizmoRenderer (ViewportCore).
if (!buildMarquee()) return false;
if (!buildOverlayLines()) return false;
if (!buildOverlayPoints()) return false;
@@ -476,13 +406,6 @@ void OverlayRenderer::destroy() {
if (axis_vertex_buffer_) { wgpuBufferRelease(axis_vertex_buffer_); axis_vertex_buffer_ = nullptr; }
// Section visualizer
if (section_bind_group_) { wgpuBindGroupRelease(section_bind_group_); section_bind_group_ = nullptr; }
if (section_pipeline_) { wgpuRenderPipelineRelease(section_pipeline_); section_pipeline_ = nullptr; }
if (section_shader_module_) { wgpuShaderModuleRelease(section_shader_module_); section_shader_module_ = nullptr; }
if (section_pipeline_layout_) { wgpuPipelineLayoutRelease(section_pipeline_layout_); section_pipeline_layout_ = nullptr; }
if (section_bgl_) { wgpuBindGroupLayoutRelease(section_bgl_); section_bgl_ = nullptr; }
if (section_uniform_buffer_) { wgpuBufferRelease(section_uniform_buffer_); section_uniform_buffer_ = nullptr; }
if (section_vertex_buffer_) { wgpuBufferRelease(section_vertex_buffer_); section_vertex_buffer_ = nullptr; }
// Marquee
if (marquee_bind_group_) { wgpuBindGroupRelease(marquee_bind_group_); marquee_bind_group_ = nullptr; }
@@ -825,198 +748,6 @@ void OverlayRenderer::encodeCornerAxis(WGPUCommandEncoder enc,
wgpuRenderPassEncoderRelease(pass);
}
// -----------------------------------------------------------------------------
// Section plane visualizer
// -----------------------------------------------------------------------------
bool OverlayRenderer::buildSectionVisualizer() {
struct Seg {
std::array<float, 3> s, e;
std::array<float, 3> c;
};
static constexpr std::array<float, 3> kSectionRed = {1.000f, 0.200f, 0.322f};
static const Seg segs[] = {
// ---- quad outline ----
{ {-1, -1, 0}, { 1, -1, 0}, kSectionRed },
{ { 1, -1, 0}, { 1, 1, 0}, kSectionRed },
{ { 1, 1, 0}, {-1, 1, 0}, kSectionRed },
{ {-1, 1, 0}, {-1, -1, 0}, kSectionRed },
// ---- arrow shaft along +n ----
{ { 0, 0, 0}, { 0, 0, 1}, kSectionRed },
// ---- arrow head: 4 diagonals from tip to ring at z = 0.78 ----
{ { 0, 0, 1}, {-0.18f, 0, 0.78f}, kSectionRed },
{ { 0, 0, 1}, { 0.18f, 0, 0.78f}, kSectionRed },
{ { 0, 0, 1}, { 0, -0.18f, 0.78f}, kSectionRed },
{ { 0, 0, 1}, { 0, 0.18f, 0.78f}, kSectionRed },
};
std::vector<float> verts;
verts.reserve(std::size(segs) * 6 * 11);
auto push_v = [&](const Seg& s, float t, float side) {
verts.insert(verts.end(), { s.s[0], s.s[1], s.s[2],
s.e[0], s.e[1], s.e[2],
s.c[0], s.c[1], s.c[2],
t, side });
};
for (const auto& s : segs) {
push_v(s, 0.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, -1.f);
push_v(s, 1.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, +1.f);
}
{
WGPUBufferDescriptor bdesc = {};
bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
bdesc.size = verts.size() * sizeof(float);
bdesc.label = svFromCStr("ifcviewer-wgpu.section_gizmo_vbo");
section_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
wgpuQueueWriteBuffer(queue_, section_vertex_buffer_, 0,
verts.data(), verts.size() * sizeof(float));
}
{
WGPUBufferDescriptor bdesc = {};
bdesc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
bdesc.size = uint64_t(kMaxSectionPlanes) * kSectionUniformSlotSize;
bdesc.label = svFromCStr("ifcviewer-wgpu.section_uniforms");
section_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
}
{
WGPUBindGroupLayoutEntry entry = {};
entry.binding = 0;
entry.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
entry.buffer.type = WGPUBufferBindingType_Uniform;
entry.buffer.hasDynamicOffset = 1;
entry.buffer.minBindingSize = 160;
WGPUBindGroupLayoutDescriptor bgl_desc = {};
bgl_desc.entryCount = 1;
bgl_desc.entries = &entry;
bgl_desc.label = svFromCStr("ifcviewer-wgpu.section_bgl");
section_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
}
{
WGPUPipelineLayoutDescriptor pl_desc = {};
pl_desc.bindGroupLayoutCount = 1;
pl_desc.bindGroupLayouts = &section_bgl_;
pl_desc.label = svFromCStr("ifcviewer-wgpu.section_pipeline_layout");
section_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
}
{
WGPUBindGroupEntry entry = {};
entry.binding = 0;
entry.buffer = section_uniform_buffer_;
entry.offset = 0;
entry.size = kSectionUniformSlotSize;
WGPUBindGroupDescriptor bg_desc = {};
bg_desc.layout = section_bgl_;
bg_desc.entryCount = 1;
bg_desc.entries = &entry;
bg_desc.label = svFromCStr("ifcviewer-wgpu.section_bind_group");
section_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
}
{
WGPUShaderSourceWGSL wgsl_src = {};
wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL;
wgsl_src.code = svFromCStr(SECTION_WGSL.c_str());
WGPUShaderModuleDescriptor sm_desc = {};
sm_desc.nextInChain = &wgsl_src.chain;
sm_desc.label = svFromCStr("ifcviewer-wgpu.section_wgsl");
section_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
}
WGPUVertexAttribute attribs[5] = {};
WGPUVertexBufferLayout vbl = thickLineVertexLayout(attribs);
WGPUBlendState blend = {};
blend.color.srcFactor = WGPUBlendFactor_SrcAlpha;
blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.color.operation = WGPUBlendOperation_Add;
blend.alpha.srcFactor = WGPUBlendFactor_One;
blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.alpha.operation = WGPUBlendOperation_Add;
WGPUColorTargetState ct = {};
ct.format = surface_format_;
ct.blend = &blend;
ct.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = section_shader_module_;
frag.entryPoint = svFromCStr("fs_main");
frag.targetCount = 1;
frag.targets = &ct;
WGPUDepthStencilState depth = {};
depth.format = WGPUTextureFormat_Depth32Float;
depth.depthWriteEnabled = WGPUOptionalBool_False;
depth.depthCompare = WGPUCompareFunction_LessEqual;
depth.stencilFront.compare = WGPUCompareFunction_Always;
depth.stencilBack.compare = WGPUCompareFunction_Always;
WGPURenderPipelineDescriptor rp_desc = {};
rp_desc.layout = section_pipeline_layout_;
rp_desc.label = svFromCStr("ifcviewer-wgpu.section_pipeline");
rp_desc.vertex.module = section_shader_module_;
rp_desc.vertex.entryPoint = svFromCStr("vs_main");
rp_desc.vertex.bufferCount = 1;
rp_desc.vertex.buffers = &vbl;
rp_desc.fragment = &frag;
rp_desc.depthStencil = &depth;
rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp_desc.primitive.cullMode = WGPUCullMode_None;
rp_desc.multisample.count = uint32_t(sample_count_);
rp_desc.multisample.mask = 0xFFFFFFFFu;
section_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
return section_pipeline_ != nullptr;
}
void OverlayRenderer::encodeSectionGizmos(WGPURenderPassEncoder pass,
const OverlayFrame& f,
const std::vector<SectionPlane>& planes) {
if (!section_pipeline_ || planes.empty()) return;
wgpuRenderPassEncoderSetPipeline(pass, section_pipeline_);
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, section_vertex_buffer_, 0,
WGPU_WHOLE_SIZE);
const int n = std::min<int>(int(planes.size()), kMaxSectionPlanes);
for (int i = 0; i < n; ++i) {
const SectionPlane& p = planes[i];
// Stable in-plane basis: pick the world axis least parallel to n
// so the cross-product stays well-conditioned at any orientation.
Eigen::Vector3f nn = p.n.normalized();
const float ax = std::abs(nn.x()), ay = std::abs(nn.y()), az = std::abs(nn.z());
Eigen::Vector3f seed = (ax < ay && ax < az) ? Eigen::Vector3f(1, 0, 0)
: (ay < az) ? Eigen::Vector3f(0, 1, 0)
: Eigen::Vector3f(0, 0, 1);
Eigen::Vector3f tangent = nn.cross(seed);
if (tangent.squaredNorm() < 1e-12f) tangent = Eigen::Vector3f(1, 0, 0);
tangent.normalize();
Eigen::Vector3f bitangent = nn.cross(tangent).normalized();
// Fixed 1 m half-size matches GL's renderSectionPlanes constant.
const float half_size = 1.0f;
const float dpr = float(std::max(1, f.device_pixel_ratio));
const float line_w = 5.0f * dpr;
const float vw = float(f.viewport_w_px);
const float vh = float(f.viewport_h_px);
uint8_t slot[256];
// Neutral tint — actual colours come from the per-vertex VBO
// (red quad outline + red arrow). Tint stays available for a
// future "selected" multiplier.
packSectionUniform(slot, f.view_proj, p.origin, half_size,
tangent, line_w, bitangent, nn,
1.0f, 1.0f, 1.0f, 1.0f,
vw, vh);
const uint32_t slot_offset = uint32_t(i) * kSectionUniformSlotSize;
wgpuQueueWriteBuffer(queue_, section_uniform_buffer_,
slot_offset, slot, sizeof(slot));
wgpuRenderPassEncoderSetBindGroup(pass, 0, section_bind_group_,
1, &slot_offset);
wgpuRenderPassEncoderDraw(pass, 54, 1, 0, 0);
}
}
// -----------------------------------------------------------------------------
// Marquee
// -----------------------------------------------------------------------------
+2 -17
View File
@@ -64,12 +64,8 @@ public:
const OverlayFrame& f,
bool visible);
// Per-plane wireframe gizmo (2 × 2 m quad outline + arrow shaft +
// arrow head). Drawn at each plane's origin in its local basis;
// colour comes from Bonsai's decorator_color_error.
void encodeSectionGizmos(WGPURenderPassEncoder pass,
const OverlayFrame& f,
const std::vector<SectionPlane>& planes);
// Section-plane gizmos moved to the shared SectionGizmoRenderer (drawn by
// ViewportCore for both desktop + web).
// Replace the highlight-triangle list. `world_xyz` is 3 floats per
// vertex, 3 vertices per triangle, in world space (post-composed-
@@ -175,7 +171,6 @@ public:
private:
bool buildAxisIndicator();
bool buildSectionVisualizer();
bool buildMarquee();
bool buildOverlayLines();
bool buildOverlayPoints();
@@ -218,16 +213,6 @@ private:
WGPUBindGroup axis_bind_group_ = nullptr;
static constexpr uint32_t kAxisUniformSlotSize = 256;
// ---- Section plane gizmos (1 pipeline, dynamic offset per plane) ----
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;
// ---- Marquee (fill + outline pipelines, one uniform buffer) ----
WGPUShaderModule marquee_shader_module_ = nullptr;
WGPUBindGroupLayout marquee_bgl_ = nullptr;
+369
View File
@@ -0,0 +1,369 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "SectionGizmoRenderer.h"
#include <algorithm>
#include <array>
#include <cstring>
#include <string>
namespace {
constexpr int kMaxPlanes = 6; // matches kMaxSectionPlanes
constexpr uint32_t kSectionUniformSlot = 256; // dynamic-offset slot stride
WGPUStringView svFromCStr(const char* s) {
WGPUStringView v;
v.data = s;
v.length = s ? std::strlen(s) : 0;
return v;
}
// Thick-line rendering helper (shared shape with OverlayRenderer's other
// overlays) + the section-gizmo vertex/fragment shaders. Each line segment is
// expanded to a screen-space-thick, anti-aliased quad.
static const std::string SECTION_GIZMO_WGSL = std::string(R"WGSL(
struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) side_t: f32,
};
fn thick_line_clip(p_start: vec4<f32>, p_end: vec4<f32>,
t: f32, side: f32,
viewport_size: vec2<f32>,
line_width_px: f32) -> vec4<f32> {
let p_here = mix(p_start, p_end, t);
let s_start = (p_start.xy / p_start.w) * viewport_size * 0.5;
let s_end = (p_end.xy / p_end.w ) * viewport_size * 0.5;
let dir = normalize(s_end - s_start);
let perp = vec2<f32>(-dir.y, dir.x);
let off_pixels = perp * (line_width_px * 0.5) * side;
let off_ndc = off_pixels * 2.0 / viewport_size;
return vec4<f32>(p_here.xy + off_ndc * p_here.w, p_here.zw);
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let d = abs(in.side_t);
let aa = fwidth(in.side_t);
let coverage = 1.0 - smoothstep(1.0 - aa, 1.0, d);
return vec4<f32>(in.color.xyz, in.color.w * coverage);
}
struct SectionUniforms {
mvp: mat4x4<f32>,
origin: vec3<f32>,
half_size: f32,
tangent: vec3<f32>,
line_width_px: f32,
bitangent: vec3<f32>,
_pad1: f32,
normal: vec3<f32>,
_pad2: f32,
tint: vec4<f32>,
viewport_size: vec2<f32>,
_pad3: vec2<f32>,
};
@group(0) @binding(0) var<uniform> u: SectionUniforms;
fn plane_to_world(p: vec3<f32>) -> vec3<f32> {
return u.origin + (u.tangent * p.x + u.bitangent * p.y + u.normal * p.z)
* u.half_size;
}
@vertex
fn vs_main(@location(0) start_local: vec3<f32>,
@location(1) end_local: vec3<f32>,
@location(2) col: vec3<f32>,
@location(3) t: f32,
@location(4) side: f32) -> VsOut {
let p_start = u.mvp * vec4<f32>(plane_to_world(start_local), 1.0);
let p_end = u.mvp * vec4<f32>(plane_to_world(end_local), 1.0);
var out: VsOut;
out.clip_pos = thick_line_clip(p_start, p_end, t, side,
u.viewport_size, u.line_width_px);
out.color = vec4<f32>(col * u.tint.xyz, u.tint.w);
out.side_t = side;
return out;
}
)WGSL");
// Pack the 256-byte dynamic-offset slot. Layout matches SectionUniforms above:
// mat4 + 4×(vec3 + scalar) + vec4 + vec2 + pad = 160 B used, padded to 256.
void packSectionUniform(uint8_t* dst,
const Eigen::Matrix4f& mvp,
const Eigen::Vector3f& origin, float half_size,
const Eigen::Vector3f& tangent, float line_width_px,
const Eigen::Vector3f& bitangent,
const Eigen::Vector3f& normal,
float r, float g, float b, float a,
float viewport_w, float viewport_h) {
std::memset(dst, 0, 256);
std::memcpy(dst, mvp.data(), 16 * sizeof(float));
auto put_vec3_pad = [&](size_t off, const Eigen::Vector3f& v, float pad_val) {
float vx = v.x(), vy = v.y(), vz = v.z();
std::memcpy(dst + off + 0, &vx, sizeof(float));
std::memcpy(dst + off + 4, &vy, sizeof(float));
std::memcpy(dst + off + 8, &vz, sizeof(float));
std::memcpy(dst + off + 12, &pad_val, sizeof(float));
};
put_vec3_pad(64, origin, half_size);
put_vec3_pad(80, tangent, line_width_px);
put_vec3_pad(96, bitangent, 0.0f);
put_vec3_pad(112, normal, 0.0f);
float tint[4] = { r, g, b, a };
std::memcpy(dst + 128, tint, sizeof(tint));
std::memcpy(dst + 144, &viewport_w, sizeof(float));
std::memcpy(dst + 148, &viewport_h, sizeof(float));
}
// Stable in-plane basis: pick the world axis least parallel to n so the
// cross-product stays well-conditioned at any orientation.
void planeBasis(const Eigen::Vector3f& n_in,
Eigen::Vector3f& nn, Eigen::Vector3f& tangent, Eigen::Vector3f& bitangent) {
nn = n_in.normalized();
const float ax = std::abs(nn.x()), ay = std::abs(nn.y()), az = std::abs(nn.z());
Eigen::Vector3f seed = (ax < ay && ax < az) ? Eigen::Vector3f(1, 0, 0)
: (ay < az) ? Eigen::Vector3f(0, 1, 0)
: Eigen::Vector3f(0, 0, 1);
tangent = nn.cross(seed);
if (tangent.squaredNorm() < 1e-12f) tangent = Eigen::Vector3f(1, 0, 0);
tangent.normalize();
bitangent = nn.cross(tangent).normalized();
}
bool projectWorldToLogicalScreen(const Eigen::Matrix4f& vp, const Eigen::Vector3f& world,
int win_w, int win_h, Eigen::Vector2f& out) {
const Eigen::Vector4f clip = vp * Eigen::Vector4f(world.x(), world.y(), world.z(), 1.0f);
if (clip.w() <= 0.0f) return false;
const float invw = 1.0f / clip.w();
out = Eigen::Vector2f((clip.x() * invw * 0.5f + 0.5f) * float(win_w),
(1.0f - (clip.y() * invw * 0.5f + 0.5f)) * float(win_h));
return true;
}
} // namespace
SectionGizmoRenderer::~SectionGizmoRenderer() { destroy(); }
bool SectionGizmoRenderer::init(WGPUDevice device, WGPUQueue queue,
WGPUTextureFormat color_format, int sample_count) {
device_ = device;
queue_ = queue;
if (!device_ || !queue_) return false;
// ---- Gizmo geometry: 9 line segments (quad outline + normal arrow) ----
struct Seg { std::array<float, 3> s, e, c; };
static constexpr std::array<float, 3> kRed = { 1.000f, 0.200f, 0.322f };
static const Seg segs[] = {
{ {-1, -1, 0}, { 1, -1, 0}, kRed }, // quad outline
{ { 1, -1, 0}, { 1, 1, 0}, kRed },
{ { 1, 1, 0}, {-1, 1, 0}, kRed },
{ {-1, 1, 0}, {-1, -1, 0}, kRed },
{ { 0, 0, 0}, { 0, 0, 1}, kRed }, // arrow shaft along +n
{ { 0, 0, 1}, {-0.18f, 0, 0.78f}, kRed }, // arrow head
{ { 0, 0, 1}, { 0.18f, 0, 0.78f}, kRed },
{ { 0, 0, 1}, { 0, -0.18f, 0.78f}, kRed },
{ { 0, 0, 1}, { 0, 0.18f, 0.78f}, kRed },
};
std::vector<float> verts;
verts.reserve(std::size(segs) * 6 * 11);
auto push_v = [&](const Seg& s, float t, float side) {
verts.insert(verts.end(), { s.s[0], s.s[1], s.s[2], s.e[0], s.e[1], s.e[2],
s.c[0], s.c[1], s.c[2], t, side });
};
for (const auto& s : segs) {
push_v(s, 0.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, -1.f);
push_v(s, 1.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, +1.f);
}
vertex_count_ = int(std::size(segs)) * 6;
WGPUBufferDescriptor vb = {};
vb.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
vb.size = verts.size() * sizeof(float);
vb.label = svFromCStr("ifcviewer-wgpu.section_gizmo_vbo");
vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &vb);
wgpuQueueWriteBuffer(queue_, vertex_buffer_, 0, verts.data(), verts.size() * sizeof(float));
WGPUBufferDescriptor ub = {};
ub.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
ub.size = uint64_t(kMaxPlanes) * kSectionUniformSlot;
ub.label = svFromCStr("ifcviewer-wgpu.section_gizmo_uniforms");
uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &ub);
WGPUBindGroupLayoutEntry ble = {};
ble.binding = 0;
ble.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
ble.buffer.type = WGPUBufferBindingType_Uniform;
ble.buffer.hasDynamicOffset = 1;
ble.buffer.minBindingSize = 160;
WGPUBindGroupLayoutDescriptor bgl_desc = {};
bgl_desc.entryCount = 1;
bgl_desc.entries = &ble;
bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
WGPUPipelineLayoutDescriptor pl_desc = {};
pl_desc.bindGroupLayoutCount = 1;
pl_desc.bindGroupLayouts = &bgl_;
layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
WGPUBindGroupEntry bge = {};
bge.binding = 0;
bge.buffer = uniform_buffer_;
bge.offset = 0;
bge.size = kSectionUniformSlot;
WGPUBindGroupDescriptor bg_desc = {};
bg_desc.layout = bgl_;
bg_desc.entryCount = 1;
bg_desc.entries = &bge;
bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
WGPUShaderSourceWGSL wgsl = {};
wgsl.chain.sType = WGPUSType_ShaderSourceWGSL;
wgsl.code = svFromCStr(SECTION_GIZMO_WGSL.c_str());
WGPUShaderModuleDescriptor sm_desc = {};
sm_desc.nextInChain = &wgsl.chain;
shader_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
// Vertex layout: start_local vec3, end_local vec3, col vec3, t f32, side f32.
WGPUVertexAttribute attribs[5] = {};
attribs[0].format = WGPUVertexFormat_Float32x3; attribs[0].offset = 0; attribs[0].shaderLocation = 0;
attribs[1].format = WGPUVertexFormat_Float32x3; attribs[1].offset = 12; attribs[1].shaderLocation = 1;
attribs[2].format = WGPUVertexFormat_Float32x3; attribs[2].offset = 24; attribs[2].shaderLocation = 2;
attribs[3].format = WGPUVertexFormat_Float32; attribs[3].offset = 36; attribs[3].shaderLocation = 3;
attribs[4].format = WGPUVertexFormat_Float32; attribs[4].offset = 40; attribs[4].shaderLocation = 4;
WGPUVertexBufferLayout vbl = {};
vbl.arrayStride = 44;
vbl.stepMode = WGPUVertexStepMode_Vertex;
vbl.attributeCount = 5;
vbl.attributes = attribs;
WGPUBlendState blend = {};
blend.color.srcFactor = WGPUBlendFactor_SrcAlpha;
blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.color.operation = WGPUBlendOperation_Add;
blend.alpha.srcFactor = WGPUBlendFactor_One;
blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.alpha.operation = WGPUBlendOperation_Add;
WGPUColorTargetState ct = {};
ct.format = color_format;
ct.blend = &blend;
ct.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = shader_;
frag.entryPoint = svFromCStr("fs_main");
frag.targetCount = 1;
frag.targets = &ct;
// Depth-test against geometry (LessEqual) but don't write depth.
WGPUDepthStencilState depth = {};
depth.format = WGPUTextureFormat_Depth32Float;
depth.depthWriteEnabled = WGPUOptionalBool_False;
depth.depthCompare = WGPUCompareFunction_LessEqual;
depth.stencilFront.compare = WGPUCompareFunction_Always;
depth.stencilBack.compare = WGPUCompareFunction_Always;
WGPURenderPipelineDescriptor rp = {};
rp.layout = layout_;
rp.label = svFromCStr("ifcviewer-wgpu.section_gizmo_pipeline");
rp.vertex.module = shader_;
rp.vertex.entryPoint = svFromCStr("vs_main");
rp.vertex.bufferCount = 1;
rp.vertex.buffers = &vbl;
rp.fragment = &frag;
rp.depthStencil = &depth;
rp.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp.primitive.cullMode = WGPUCullMode_None;
rp.multisample.count = uint32_t(sample_count);
rp.multisample.mask = 0xFFFFFFFFu;
pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp);
return pipeline_ != nullptr;
}
void SectionGizmoRenderer::encode(WGPURenderPassEncoder pass, const Eigen::Matrix4f& view_proj,
const std::vector<SectionPlane>& planes,
int viewport_w_px, int viewport_h_px, int device_pixel_ratio) {
if (!pipeline_ || planes.empty()) return;
wgpuRenderPassEncoderSetPipeline(pass, pipeline_);
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, vertex_buffer_, 0, WGPU_WHOLE_SIZE);
const float dpr = float(std::max(1, device_pixel_ratio));
const float line_w = 5.0f * dpr;
const float vw = float(viewport_w_px);
const float vh = float(viewport_h_px);
const int n = std::min<int>(int(planes.size()), kMaxPlanes);
for (int i = 0; i < n; ++i) {
const SectionPlane& p = planes[i];
Eigen::Vector3f nn, tangent, bitangent;
planeBasis(p.n, nn, tangent, bitangent);
// Fixed 1 m gizmo (matches the desktop OverlayRenderer / GL constant).
// NOT visual_radius: the normal is flipped toward the camera, so a large
// arrow would shoot past the eye (clip.w<0) and vanish.
const float half = 1.0f;
uint8_t slot[256];
packSectionUniform(slot, view_proj, p.origin, half, tangent, line_w,
bitangent, nn, 1.0f, 1.0f, 1.0f, 1.0f, vw, vh);
const uint32_t slot_offset = uint32_t(i) * kSectionUniformSlot;
wgpuQueueWriteBuffer(queue_, uniform_buffer_, slot_offset, slot, sizeof(slot));
wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &slot_offset);
wgpuRenderPassEncoderDraw(pass, uint32_t(vertex_count_), 1, 0, 0);
}
}
int SectionGizmoRenderer::hitTest(int x, int y, const std::vector<SectionPlane>& planes,
const Eigen::Matrix4f& view, const Eigen::Matrix4f& proj,
int viewport_w_px, int viewport_h_px, float tolerance_px) {
const Eigen::Matrix4f vp = proj * view;
const Eigen::Vector2f q{ float(x), float(y) };
int best_i = -1;
float best_d = tolerance_px;
const int n = std::min<int>(int(planes.size()), kMaxPlanes);
for (int i = 0; i < n; ++i) {
const SectionPlane& p = planes[i];
// The arrow runs origin → origin + n * 1 m (visual radius scales the
// gizmo, but hit-test the unit arrow to mirror the desktop).
Eigen::Vector2f s_origin, s_tip;
if (!projectWorldToLogicalScreen(vp, p.origin, viewport_w_px, viewport_h_px, s_origin)) continue;
if (!projectWorldToLogicalScreen(vp, p.origin + p.n * 1.0f, viewport_w_px, viewport_h_px, s_tip)) continue;
const Eigen::Vector2f ab = s_tip - s_origin;
const float ab_len2 = ab.squaredNorm();
if (ab_len2 < 1e-3f) continue;
float t = (q - s_origin).dot(ab) / ab_len2;
t = std::clamp(t, 0.0f, 1.0f);
const Eigen::Vector2f proj_pt = s_origin + ab * t;
const float d = (q - proj_pt).norm();
if (d < best_d) { best_d = d; best_i = i; }
}
return best_i;
}
void SectionGizmoRenderer::destroy() {
if (pipeline_) { wgpuRenderPipelineRelease(pipeline_); pipeline_ = nullptr; }
if (layout_) { wgpuPipelineLayoutRelease(layout_); layout_ = nullptr; }
if (bgl_) { wgpuBindGroupLayoutRelease(bgl_); bgl_ = nullptr; }
if (bind_group_) { wgpuBindGroupRelease(bind_group_); bind_group_ = nullptr; }
if (vertex_buffer_) { wgpuBufferRelease(vertex_buffer_); vertex_buffer_ = nullptr; }
if (uniform_buffer_) { wgpuBufferRelease(uniform_buffer_); uniform_buffer_ = nullptr; }
if (shader_) { wgpuShaderModuleRelease(shader_); shader_ = nullptr; }
}
+79
View File
@@ -0,0 +1,79 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef SECTIONGIZMORENDERER_H
#define SECTIONGIZMORENDERER_H
#include <webgpu/webgpu.h>
#include <Eigen/Dense>
#include <vector>
#include "SectionPlane.h"
// Qt-free renderer for the section-plane gizmo — a red quad outline plus a
// normal arrow, drawn with anti-aliased thick lines. Lifted out of the
// Qt-coupled OverlayRenderer so BOTH the desktop and web builds draw one
// identical gizmo from a single place (ViewportCore::render calls it on both).
//
// The gizmo is plane-local geometry scaled by each plane's visual radius and
// oriented by a stable tangent/bitangent basis derived from the plane normal.
class SectionGizmoRenderer {
public:
SectionGizmoRenderer() = default;
~SectionGizmoRenderer();
SectionGizmoRenderer(const SectionGizmoRenderer&) = delete;
SectionGizmoRenderer& operator=(const SectionGizmoRenderer&) = delete;
// Create the pipeline, gizmo VBO, and per-plane uniform buffer. `color_format`
// is the render target's format; `sample_count` the MSAA count. Returns false
// (and leaves the renderer inert) if pipeline creation fails.
bool init(WGPUDevice device, WGPUQueue queue,
WGPUTextureFormat color_format, int sample_count);
void destroy();
bool ready() const { return pipeline_ != nullptr; }
// Draw one gizmo per plane into an already-open render pass (the main pass).
void encode(WGPURenderPassEncoder pass, const Eigen::Matrix4f& view_proj,
const std::vector<SectionPlane>& planes,
int viewport_w_px, int viewport_h_px, int device_pixel_ratio);
// Screen-space hit test: index of the plane whose gizmo (arrow segment,
// origin→origin+normal) the (x, y) logical-pixel point lies within
// `tolerance_px` of, or -1. Pure math — no GPU. Nearest wins.
static int hitTest(int x, int y, const std::vector<SectionPlane>& planes,
const Eigen::Matrix4f& view, const Eigen::Matrix4f& proj,
int viewport_w_px, int viewport_h_px,
float tolerance_px = 12.0f);
private:
WGPUDevice device_ = nullptr;
WGPUQueue queue_ = nullptr;
WGPURenderPipeline pipeline_ = nullptr;
WGPUPipelineLayout layout_ = nullptr;
WGPUBindGroupLayout bgl_ = nullptr;
WGPUBindGroup bind_group_ = nullptr;
WGPUBuffer vertex_buffer_ = nullptr;
WGPUBuffer uniform_buffer_ = nullptr;
WGPUShaderModule shader_ = nullptr;
int vertex_count_ = 0;
};
#endif // SECTIONGIZMORENDERER_H
+401 -111
View File
@@ -272,7 +272,7 @@ void ViewportCore::recomposeAndUploadModel(uint32_t model_id) {
std::vector<InstanceGpu> gpu(m.instances.size());
for (size_t i = 0; i < m.instances.size(); ++i) {
InstanceCpu& inst = m.instances[i];
InstanceInfo& inst = m.instances[i];
composeInstanceFromPlacement(inst, m);
InstanceGpu& dst = gpu[i];
@@ -298,7 +298,7 @@ void ViewportCore::recomposeAndUploadModel(uint32_t model_id) {
-std::numeric_limits<float>::infinity();
for (uint32_t inst_idx : c.instance_ids) {
if (inst_idx >= m.instances.size()) continue;
const InstanceCpu& inst = m.instances[inst_idx];
const InstanceInfo& inst = m.instances[inst_idx];
for (int a = 0; a < 3; ++a) {
c.aabb_min[a] = std::min(c.aabb_min[a], inst.world_aabb_min[a]);
c.aabb_max[a] = std::max(c.aabb_max[a], inst.world_aabb_max[a]);
@@ -321,7 +321,7 @@ bool ViewportCore::firstGeometryPointWorldM(uint32_t model_id,
const ModelGpuData& m = it->second;
if (m.instances.empty()) return false;
const InstanceCpu& inst0 = m.instances[0];
const InstanceInfo& inst0 = m.instances[0];
if (inst0.mesh_id >= m.meshes.size()) return false;
const MeshInfo& mesh0 = m.meshes[inst0.mesh_id];
@@ -344,7 +344,7 @@ bool ViewportCore::firstGeometryPointWorldM(uint32_t model_id,
return true;
}
void ViewportCore::composeInstanceFromPlacement(InstanceCpu& inst,
void ViewportCore::composeInstanceFromPlacement(InstanceInfo& inst,
const ModelGpuData& m) const {
if (inst.mesh_id < m.meshes.size()) {
const MeshInfo& mi = m.meshes[inst.mesh_id];
@@ -679,7 +679,7 @@ double ViewportCore::volumeOfObjects(
for (const auto& [mid, m] : models_gpu_) {
auto it = m.object_id_to_instance.find(oid);
if (it == m.object_id_to_instance.end()) continue;
const InstanceCpu& inst = m.instances[it->second];
const InstanceInfo& inst = m.instances[it->second];
if (inst.mesh_id >= m.mesh_local_volumes.size()) break;
const double v_local = m.mesh_local_volumes[inst.mesh_id];
const double det = std::abs(det3OfPlacement(inst.placement_transformation));
@@ -700,7 +700,7 @@ ViewportCore::volumesPerObject(
for (const auto& [mid, m] : models_gpu_) {
auto it = m.object_id_to_instance.find(oid);
if (it == m.object_id_to_instance.end()) continue;
const InstanceCpu& inst = m.instances[it->second];
const InstanceInfo& inst = m.instances[it->second];
if (inst.mesh_id >= m.mesh_local_volumes.size()) break;
const double v_local = m.mesh_local_volumes[inst.mesh_id];
const double det = std::abs(det3OfPlacement(inst.placement_transformation));
@@ -985,6 +985,7 @@ struct VsOutPick {
struct FsOutPick {
@location(0) object_id: u32,
@location(1) normal: vec4<f32>,
@location(2) world_pos: vec4<f32>,
};
@vertex
@@ -1042,6 +1043,9 @@ fn fs_pick(in: VsOutPick) -> FsOutPick {
out.object_id = in.object_id;
// Pack signed normal into RGBA16F (unsigned-ish half range) as ×0.5+0.5.
out.normal = vec4<f32>(normalize(in.normal) * 0.5 + vec3<f32>(0.5), 1.0);
// Exact surface world position (F32) so surface pick lands on the true face,
// not a ray-AABB approximation.
out.world_pos = vec4<f32>(in.world_pos, 1.0);
return out;
}
)";
@@ -1203,6 +1207,9 @@ bool ViewportCore::buildPipelines() {
// buffer to bind alongside the uniform — ensureSelectionFlagsBuffer
// handles both the first creation and any subsequent resize.
// Section-plane gizmo (shared desktop + web). Optional — a failure just
// means no gizmo, not a dead viewport.
section_gizmo_.init(device_, queue_, surface_view_format_, kViewportSampleCount);
return true;
}
@@ -1784,6 +1791,7 @@ void ViewportCore::shutdown() {
selection_flags_capacity_ = 0;
if (main_pipeline_) { wgpuRenderPipelineRelease(main_pipeline_); main_pipeline_ = nullptr; }
if (main_pipeline_transparent_) { wgpuRenderPipelineRelease(main_pipeline_transparent_); main_pipeline_transparent_ = nullptr; }
section_gizmo_.destroy();
if (main_shader_module_) { wgpuShaderModuleRelease(main_shader_module_); main_shader_module_ = nullptr; }
if (pipeline_layout_) { wgpuPipelineLayoutRelease(pipeline_layout_); pipeline_layout_ = nullptr; }
if (model_bgl_) { wgpuBindGroupLayoutRelease(model_bgl_); model_bgl_ = nullptr; }
@@ -3127,7 +3135,7 @@ void ViewportCore::applyCachedModel(std::uint32_t model_id,
inst_gpu.push_back(instance_gpu);
}
next_object_id_ = object_id_base + max_local_id + 1;
model_gpu_data.object_id_base = object_id_base; // deferred elements rebase to match
model_gpu_data.object_id_base = object_id_base; // element metadata records rebase to match
const std::size_t inst_storage_bytes = inst_gpu.size() * sizeof(InstanceGpu);
model_gpu_data.instance_storage = createBufferWithData(
device_, queue_,
@@ -3276,7 +3284,7 @@ void ViewportCore::uploadMeshChunk(const MeshChunk& chunk) {
void ViewportCore::uploadInstanceChunk(const InstanceChunk& chunk) {
SidecarData& s = getOrCreateDirectStaging(pending_direct_loads_, chunk.model_id);
InstanceCpu instance{};
InstanceInfo instance{};
instance.mesh_id = chunk.local_mesh_id;
instance.object_id = chunk.object_id;
instance.color_override_rgba8 = chunk.color_override_rgba8;
@@ -3555,30 +3563,45 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
Log::warn() << "loadSidecarMetadataWeb: metadata past EOF";
return;
}
// Critical block on disk: [comp u64][raw u64][zstd frame].
// Geometry metadata block on disk: [comp u64][raw u64][zstd frame].
webReadRangesAsync(source_id, 0, {{meta_off, 16}},
[this, fsize, meta_off, source_id, source_label]
(bool ok2, std::vector<std::uint8_t>&& h) {
if (!ok2 || h.size() < 16) { Log::warn() << "loadSidecarMetadataWeb: short crit header"; return; }
std::uint64_t crit_comp = 0, crit_raw = 0;
std::memcpy(&crit_comp, h.data(), 8);
std::memcpy(&crit_raw, h.data() + 8, 8);
const std::uint64_t crit_off = meta_off + 16;
if (double(crit_off + crit_comp + 16) > fsize) { Log::warn() << "loadSidecarMetadataWeb: crit past EOF"; return; }
webReadRangesAsync(source_id, 0, {{crit_off, crit_comp}},
[this, crit_off, crit_comp, crit_raw, source_id, source_label]
if (!ok2 || h.size() < 16) {
Log::warn() << "loadSidecarMetadataWeb: short geometry metadata header";
return;
}
std::uint64_t geometry_metadata_comp = 0, geometry_metadata_raw = 0;
std::memcpy(&geometry_metadata_comp, h.data(), 8);
std::memcpy(&geometry_metadata_raw, h.data() + 8, 8);
const std::uint64_t geometry_metadata_off = meta_off + 16;
if (double(geometry_metadata_off + geometry_metadata_comp + 16) > fsize) {
Log::warn() << "loadSidecarMetadataWeb: geometry metadata past EOF";
return;
}
webReadRangesAsync(source_id, 0,
{{geometry_metadata_off, geometry_metadata_comp}},
[this, geometry_metadata_off, geometry_metadata_comp,
geometry_metadata_raw, source_id, source_label]
(bool ok3, std::vector<std::uint8_t>&& cz) {
if (!ok3) { Log::warn() << "loadSidecarMetadataWeb: critical read failed"; return; }
std::vector<std::uint8_t> crit(static_cast<std::size_t>(crit_raw));
if (!SidecarCompress::decompress(cz.data(), cz.size(), crit.data(), crit.size())) {
Log::warn() << "loadSidecarMetadataWeb: critical decompress failed";
if (!ok3) {
Log::warn() << "loadSidecarMetadataWeb: geometry metadata read failed";
return;
}
std::vector<std::uint8_t> geometry_metadata(
static_cast<std::size_t>(geometry_metadata_raw));
if (!SidecarCompress::decompress(cz.data(), cz.size(),
geometry_metadata.data(),
geometry_metadata.size())) {
Log::warn() << "loadSidecarMetadataWeb: geometry metadata decompress failed";
return;
}
StreamingSidecar sc;
sc.file_path = source_label;
sc.geometry_section_offset = SIDECAR_HEAD_BYTES;
if (!parseSidecarCritical(crit.data(), crit.size(), sc.meta)) {
Log::warn() << "loadSidecarMetadataWeb: bad critical metadata";
if (!parseSidecarGeometryMetadata(geometry_metadata.data(),
geometry_metadata.size(), sc.meta)) {
Log::warn() << "loadSidecarMetadataWeb: bad geometry metadata";
return;
}
const std::size_t n_meshes = sc.meta.meshes.size();
@@ -3587,7 +3610,7 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
applyCachedModel(mid, std::move(sc));
// Mark web-streamed + set the source IMMEDIATELY — the
// model now has non-resident chunks and the RAF loop's
// driveStreamingLoads will run before the deferred-header
// driveStreamingLoads will run before the element metadata header
// read below returns. If streaming_from_web weren't set
// yet it would take the sync fopen path and fail
// ("failed to read/decompress chunk 0").
@@ -3595,11 +3618,13 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
m0->second.streaming_from_web = true;
m0->second.web_source_id = source_id;
}
// Read the deferred block header to record its locator
// Read the element metadata block header to record its locator
// (the property block is fetched on demand later).
const std::uint64_t def_hdr_off = crit_off + crit_comp;
webReadRangesAsync(source_id, 0, {{def_hdr_off, 16}},
[this, mid, def_hdr_off, source_id, source_label, n_meshes, n_instances]
const std::uint64_t element_metadata_hdr_off =
geometry_metadata_off + geometry_metadata_comp;
webReadRangesAsync(source_id, 0, {{element_metadata_hdr_off, 16}},
[this, mid, element_metadata_hdr_off, source_id, source_label,
n_meshes, n_instances]
(bool ok4, std::vector<std::uint8_t>&& dh) {
auto mit = models_gpu_.find(mid);
if (mit != models_gpu_.end()) {
@@ -3607,9 +3632,9 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
std::uint64_t dc = 0, dr = 0;
std::memcpy(&dc, dh.data(), 8);
std::memcpy(&dr, dh.data() + 8, 8);
mit->second.deferred_comp_offset = def_hdr_off + 16;
mit->second.deferred_comp_size = dc;
mit->second.deferred_raw_size = dr;
mit->second.element_metadata_comp_offset = element_metadata_hdr_off + 16;
mit->second.element_metadata_comp_size = dc;
mit->second.element_metadata_raw_size = dr;
}
}
// NOTE: no viewAll() here — applyCachedModel
@@ -3627,23 +3652,24 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
});
}
void ViewportCore::loadDeferredMetadataWeb(std::uint32_t model_id,
std::function<void(bool)> done) {
// On-demand fetch of the v15 deferred block (element tree + string table)
// for a web-streamed model — the property data a UI needs (tree, selected-
void ViewportCore::loadElementMetadataWeb(std::uint32_t model_id,
std::function<void(bool)> done) {
// On-demand fetch of the v15 element metadata block (elements + string table)
// for a web-streamed model — the property data a UI needs (selected-
// object name, search) but rendering doesn't. Fetches at most once. Reads
// from the model's own registered byte-source, so it works per-model even
// with several federated files loaded.
auto it = models_gpu_.find(model_id);
if (it == models_gpu_.end()) { if (done) done(false); return; }
ModelGpuData& m = it->second;
if (m.deferred_meta_loaded || m.deferred_comp_size == 0) {
m.deferred_meta_loaded = true;
if (m.element_metadata_loaded || m.element_metadata_comp_size == 0) {
m.element_metadata_loaded = true;
if (done) done(true);
return;
}
const std::uint64_t raw_size = m.deferred_raw_size;
webReadRangesAsync(m.web_source_id, 0, {{m.deferred_comp_offset, m.deferred_comp_size}},
const std::uint64_t raw_size = m.element_metadata_raw_size;
webReadRangesAsync(m.web_source_id, 0,
{{m.element_metadata_comp_offset, m.element_metadata_comp_size}},
[this, model_id, raw_size, done](bool ok, std::vector<std::uint8_t>&& cz) {
auto mit = models_gpu_.find(model_id);
if (mit == models_gpu_.end()) { if (done) done(false); return; }
@@ -3651,8 +3677,8 @@ void ViewportCore::loadDeferredMetadataWeb(std::uint32_t model_id,
SidecarData tmp;
if (!ok ||
!SidecarCompress::decompress(cz.data(), cz.size(), buf.data(), buf.size()) ||
!parseSidecarDeferred(buf.data(), buf.size(), tmp)) {
Log::warn() << "loadDeferredMetadataWeb: read/decompress/parse failed";
!parseSidecarElementMetadata(buf.data(), buf.size(), tmp)) {
Log::warn() << "loadElementMetadataWeb: read/decompress/parse failed";
if (done) done(false);
return;
}
@@ -3662,8 +3688,8 @@ void ViewportCore::loadDeferredMetadataWeb(std::uint32_t model_id,
// match the (already-rebased) instance ids used by pick/selection.
const std::uint32_t base = mit->second.object_id_base;
for (auto& e : mit->second.elements) e.object_id += base;
mit->second.deferred_meta_loaded = true;
Log::info() << "ifcviewer-web: loaded deferred metadata ("
mit->second.element_metadata_loaded = true;
Log::info() << "ifcviewer-web: loaded element metadata ("
<< mit->second.elements.size() << " elements)";
if (done) done(true);
});
@@ -3673,9 +3699,9 @@ void ViewportCore::logSelectedObjectGuidWeb(std::uint32_t object_id) {
InstanceCompose::InstanceLookup lk;
if (!findInstance(object_id, lk)) return; // empty pick / unknown id
const std::uint32_t model_id = lk.model_id;
loadDeferredMetadataWeb(model_id, [this, object_id, model_id](bool ok) {
loadElementMetadataWeb(model_id, [this, object_id, model_id](bool ok) {
if (!ok) {
Log::warn() << "pick: deferred property fetch failed for object " << object_id;
Log::warn() << "pick: element metadata fetch failed for object " << object_id;
return;
}
auto it = models_gpu_.find(model_id);
@@ -4693,19 +4719,21 @@ bool rayAABBHit(const Eigen::Vector3f& origin, const Eigen::Vector3f& dir,
} // namespace
bool ViewportCore::buildPickPipeline() {
// Two color attachments: R32UInt for object_id, RGBA16F for the
// packed world-space normal so the section tool can drop
// perpendicular cuts at the picked pixel.
WGPUColorTargetState color_targets[2] = {};
// Three color attachments: R32UInt object_id, RGBA16F packed normal, and
// RGBA32F exact world position (so surface pick lands on the true face, not
// a ray-AABB approximation).
WGPUColorTargetState color_targets[3] = {};
color_targets[0].format = WGPUTextureFormat_R32Uint;
color_targets[0].writeMask = WGPUColorWriteMask_All;
color_targets[1].format = WGPUTextureFormat_RGBA16Float;
color_targets[1].writeMask = WGPUColorWriteMask_All;
color_targets[2].format = WGPUTextureFormat_RGBA32Float;
color_targets[2].writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = main_shader_module_;
frag.entryPoint = svFromCStr("fs_pick");
frag.targetCount = 2;
frag.targetCount = 3;
frag.targets = color_targets;
WGPUDepthStencilState depth = {};
@@ -4745,6 +4773,8 @@ void ViewportCore::ensurePickAttachments(int w, int h) {
if (pick_color_texture_) { wgpuTextureRelease(pick_color_texture_); pick_color_texture_ = nullptr; }
if (pick_normal_view_) { wgpuTextureViewRelease(pick_normal_view_); pick_normal_view_ = nullptr; }
if (pick_normal_texture_) { wgpuTextureRelease(pick_normal_texture_); pick_normal_texture_ = nullptr; }
if (pick_position_view_) { wgpuTextureViewRelease(pick_position_view_); pick_position_view_ = nullptr; }
if (pick_position_texture_) { wgpuTextureRelease(pick_position_texture_); pick_position_texture_ = nullptr; }
if (pick_depth_view_) { wgpuTextureViewRelease(pick_depth_view_); pick_depth_view_ = nullptr; }
if (pick_depth_texture_) { wgpuTextureRelease(pick_depth_texture_); pick_depth_texture_ = nullptr; }
@@ -4767,6 +4797,12 @@ void ViewportCore::ensurePickAttachments(int w, int h) {
pick_normal_texture_ = wgpuDeviceCreateTexture(device_, &ndesc);
pick_normal_view_ = wgpuTextureCreateView(pick_normal_texture_, nullptr);
WGPUTextureDescriptor pdesc = cdesc;
pdesc.format = WGPUTextureFormat_RGBA32Float;
pdesc.label = svFromCStr("ifcviewer-wgpu.pick_position");
pick_position_texture_ = wgpuDeviceCreateTexture(device_, &pdesc);
pick_position_view_ = wgpuTextureCreateView(pick_position_texture_, nullptr);
WGPUTextureDescriptor ddesc = {};
ddesc.usage = WGPUTextureUsage_RenderAttachment;
ddesc.dimension = WGPUTextureDimension_2D;
@@ -4801,6 +4837,13 @@ void ViewportCore::ensurePickAttachments(int w, int h) {
sb.label = svFromCStr("ifcviewer-wgpu.pick_normal_staging");
pick_normal_staging_buffer_ = wgpuDeviceCreateBuffer(device_, &sb);
}
if (!pick_position_staging_buffer_) {
WGPUBufferDescriptor sb = {};
sb.size = 256;
sb.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead;
sb.label = svFromCStr("ifcviewer-wgpu.pick_position_staging");
pick_position_staging_buffer_ = wgpuDeviceCreateBuffer(device_, &sb);
}
pick_w_ = w;
pick_h_ = h;
}
@@ -4810,6 +4853,8 @@ void ViewportCore::releasePickResources() {
if (pick_color_texture_) { wgpuTextureRelease(pick_color_texture_); pick_color_texture_ = nullptr; }
if (pick_normal_view_) { wgpuTextureViewRelease(pick_normal_view_); pick_normal_view_ = nullptr; }
if (pick_normal_texture_) { wgpuTextureRelease(pick_normal_texture_); pick_normal_texture_ = nullptr; }
if (pick_position_view_) { wgpuTextureViewRelease(pick_position_view_); pick_position_view_ = nullptr; }
if (pick_position_texture_) { wgpuTextureRelease(pick_position_texture_); pick_position_texture_ = nullptr; }
if (pick_depth_view_) { wgpuTextureViewRelease(pick_depth_view_); pick_depth_view_ = nullptr; }
if (pick_depth_texture_) { wgpuTextureRelease(pick_depth_texture_); pick_depth_texture_ = nullptr; }
if (pick_staging_buffer_) { wgpuBufferRelease(pick_staging_buffer_); pick_staging_buffer_ = nullptr; }
@@ -4817,6 +4862,10 @@ void ViewportCore::releasePickResources() {
wgpuBufferRelease(pick_normal_staging_buffer_);
pick_normal_staging_buffer_ = nullptr;
}
if (pick_position_staging_buffer_) {
wgpuBufferRelease(pick_position_staging_buffer_);
pick_position_staging_buffer_ = nullptr;
}
if (pick_pipeline_) { wgpuRenderPipelineRelease(pick_pipeline_); pick_pipeline_ = nullptr; }
if (box_pick_staging_buffer_) {
wgpuBufferRelease(box_pick_staging_buffer_);
@@ -4837,7 +4886,7 @@ void ViewportCore::encodePickReadbackToStaging(int x_pixels, int y_pixels,
// by the last render's cullModelCpuUpload). Encode a one-shot pass.
WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr);
WGPURenderPassColorAttachment color[2] = {};
WGPURenderPassColorAttachment color[3] = {};
color[0].view = pick_color_view_;
color[0].loadOp = WGPULoadOp_Clear;
color[0].storeOp = WGPUStoreOp_Store;
@@ -4848,6 +4897,11 @@ void ViewportCore::encodePickReadbackToStaging(int x_pixels, int y_pixels,
color[1].storeOp = WGPUStoreOp_Store;
color[1].clearValue = { 0.5, 0.5, 0.5, 0.0 };
color[1].depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
color[2].view = pick_position_view_;
color[2].loadOp = WGPULoadOp_Clear;
color[2].storeOp = WGPUStoreOp_Store;
color[2].clearValue = { 0.0, 0.0, 0.0, 0.0 };
color[2].depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
WGPURenderPassDepthStencilAttachment depth = {};
depth.view = pick_depth_view_;
@@ -4859,7 +4913,7 @@ void ViewportCore::encodePickReadbackToStaging(int x_pixels, int y_pixels,
depth.stencilReadOnly = true;
WGPURenderPassDescriptor pass_desc = {};
pass_desc.colorAttachmentCount = 2;
pass_desc.colorAttachmentCount = 3;
pass_desc.colorAttachments = color;
pass_desc.depthStencilAttachment = &depth;
pass_desc.label = svFromCStr("ifcviewer-wgpu.pick_pass");
@@ -4910,6 +4964,13 @@ void ViewportCore::encodePickReadbackToStaging(int x_pixels, int y_pixels,
ndst.layout.rowsPerImage = 1;
wgpuCommandEncoderCopyTextureToBuffer(enc, &nsrc, &ndst, &extent);
// Exact world position too (surface pick wants both).
WGPUTexelCopyTextureInfo psrc = nsrc;
psrc.texture = pick_position_texture_;
WGPUTexelCopyBufferInfo pdst = ndst;
pdst.buffer = pick_position_staging_buffer_;
wgpuCommandEncoderCopyTextureToBuffer(enc, &psrc, &pdst, &extent);
}
WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, nullptr);
@@ -4961,48 +5022,63 @@ std::uint32_t ViewportCore::pickObjectAt(int x_pixels, int y_pixels,
wgpuBufferMapAsync(pick_normal_staging_buffer_, WGPUMapMode_Read, 0, 256, ncb);
while (!nreq.done) waitTickInstance(instance_);
if (nreq.ok) {
const std::uint16_t* halves = static_cast<const std::uint16_t*>(
wgpuBufferGetConstMappedRange(pick_normal_staging_buffer_, 0, 256));
if (halves) {
// IEEE 754 half → float. Standard bit-fiddle (no STL
// helper in pre-C++23).
auto h2f = [](std::uint16_t h) -> float {
const std::uint32_t sign = std::uint32_t(h & 0x8000u) << 16;
std::uint32_t exponent = std::uint32_t(h & 0x7C00u) >> 10;
std::uint32_t mantissa = std::uint32_t(h & 0x03FFu);
if (exponent == 0) {
if (mantissa == 0) {
union { std::uint32_t u; float f; } v{ sign };
return v.f;
}
while ((mantissa & 0x0400u) == 0) {
mantissa <<= 1;
--exponent;
}
++exponent;
mantissa &= 0x03FFu;
} else if (exponent == 0x1Fu) {
exponent = 0xFFu;
} else {
exponent += (127u - 15u);
}
const std::uint32_t bits = sign | (exponent << 23) | (mantissa << 13);
union { std::uint32_t u; float f; } v{ bits };
return v.f;
};
const float nx = h2f(halves[0]) * 2.0f - 1.0f;
const float ny = h2f(halves[1]) * 2.0f - 1.0f;
const float nz = h2f(halves[2]) * 2.0f - 1.0f;
Eigen::Vector3f n(nx, ny, nz);
if (n.squaredNorm() > 1e-6f) *normal_out = n.normalized();
}
wgpuBufferUnmap(pick_normal_staging_buffer_);
Eigen::Vector3f n;
if (decodeMappedPickNormal(n)) *normal_out = n; // decodeMapped… unmaps
}
}
return object_id;
}
bool ViewportCore::decodeMappedPickPosition(Eigen::Vector3f& out) {
const float* p = static_cast<const float*>(
wgpuBufferGetConstMappedRange(pick_position_staging_buffer_, 0, 256));
bool ok = false;
if (p && p[3] > 0.5f) { // w == 1.0 for a real fragment, 0 for a cleared miss
out = Eigen::Vector3f(p[0], p[1], p[2]);
ok = true;
}
wgpuBufferUnmap(pick_position_staging_buffer_);
return ok;
}
bool ViewportCore::decodeMappedPickNormal(Eigen::Vector3f& out) {
const std::uint16_t* halves = static_cast<const std::uint16_t*>(
wgpuBufferGetConstMappedRange(pick_normal_staging_buffer_, 0, 256));
bool ok = false;
if (halves) {
// IEEE 754 half → float. Standard bit-fiddle (no STL helper pre-C++23).
auto h2f = [](std::uint16_t h) -> float {
const std::uint32_t sign = std::uint32_t(h & 0x8000u) << 16;
std::uint32_t exponent = std::uint32_t(h & 0x7C00u) >> 10;
std::uint32_t mantissa = std::uint32_t(h & 0x03FFu);
if (exponent == 0) {
if (mantissa == 0) {
union { std::uint32_t u; float f; } v{ sign };
return v.f;
}
while ((mantissa & 0x0400u) == 0) { mantissa <<= 1; --exponent; }
++exponent;
mantissa &= 0x03FFu;
} else if (exponent == 0x1Fu) {
exponent = 0xFFu;
} else {
exponent += (127u - 15u);
}
const std::uint32_t bits = sign | (exponent << 23) | (mantissa << 13);
union { std::uint32_t u; float f; } v{ bits };
return v.f;
};
const float nx = h2f(halves[0]) * 2.0f - 1.0f;
const float ny = h2f(halves[1]) * 2.0f - 1.0f;
const float nz = h2f(halves[2]) * 2.0f - 1.0f;
Eigen::Vector3f n(nx, ny, nz);
if (n.squaredNorm() > 1e-6f) { out = n.normalized(); ok = true; }
}
wgpuBufferUnmap(pick_normal_staging_buffer_);
return ok;
}
// Route a pick result through the selection state machine. Mirrors the
// desktop ViewportWindow::mouseReleaseEvent semantics: no modifier replaces,
// add(=Shift) extends, remove(=Ctrl) subtracts, and an empty-space click
@@ -5046,7 +5122,7 @@ void ViewportCore::isolateSelected() {
const auto& sel_ids = selection_.selectionIds();
for (const auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
for (const InstanceCpu& inst : m.instances) {
for (const InstanceInfo& inst : m.instances) {
if (inst.object_id == 0) continue;
if (sel_ids.find(inst.object_id) == sel_ids.end())
visibility_.hide(inst.object_id);
@@ -5160,7 +5236,7 @@ bool ViewportCore::encodeBoxPickToStaging(int& x, int& y, int& w, int& h,
WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr);
WGPURenderPassColorAttachment color[2] = {};
WGPURenderPassColorAttachment color[3] = {};
color[0].view = pick_color_view_;
color[0].loadOp = WGPULoadOp_Clear;
color[0].storeOp = WGPUStoreOp_Store;
@@ -5171,6 +5247,11 @@ bool ViewportCore::encodeBoxPickToStaging(int& x, int& y, int& w, int& h,
color[1].storeOp = WGPUStoreOp_Store;
color[1].clearValue = { 0.5, 0.5, 0.5, 0 };
color[1].depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
color[2].view = pick_position_view_; // rendered (pipeline outputs 3), not read here
color[2].loadOp = WGPULoadOp_Clear;
color[2].storeOp = WGPUStoreOp_Store;
color[2].clearValue = { 0, 0, 0, 0 };
color[2].depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
WGPURenderPassDepthStencilAttachment depth = {};
depth.view = pick_depth_view_;
@@ -5182,7 +5263,7 @@ bool ViewportCore::encodeBoxPickToStaging(int& x, int& y, int& w, int& h,
depth.stencilReadOnly = true;
WGPURenderPassDescriptor pass_desc = {};
pass_desc.colorAttachmentCount = 2;
pass_desc.colorAttachmentCount = 3;
pass_desc.colorAttachments = color;
pass_desc.depthStencilAttachment = &depth;
pass_desc.label = svFromCStr("ifcviewer-wgpu.box_pick_pass");
@@ -5311,15 +5392,13 @@ void ViewportCore::picksInRectAsync(int x, int y, int w, int h,
}
#endif
bool ViewportCore::pickSurfaceAt(int x_pixels, int y_pixels,
std::uint32_t& object_id_out,
Eigen::Vector3f& world_pos_out,
Eigen::Vector3f& world_normal_out,
float* aabb_radius_out) {
if (aabb_radius_out) *aabb_radius_out = 0.0f;
Eigen::Vector3f picked_normal(0, 0, 1);
const std::uint32_t id = pickObjectAt(x_pixels, y_pixels, &picked_normal);
if (id == 0) return false;
bool ViewportCore::raycastSurfaceForObject(std::uint32_t object_id, int x_pixels, int y_pixels,
const Eigen::Vector3f& mrt_normal,
Eigen::Vector3f& world_pos_out,
Eigen::Vector3f& world_normal_out,
float& aabb_radius_out) {
aabb_radius_out = 0.0f;
if (object_id == 0) return false;
// WebGPU forbids partial copies of Depth32Float, so ray-cast against
// each instance carrying the picked object_id rather than reading
@@ -5350,7 +5429,7 @@ bool ViewportCore::pickSurfaceAt(int x_pixels, int y_pixels,
for (const auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
for (const auto& inst : m.instances) {
if (inst.object_id != id) continue;
if (inst.object_id != object_id) continue;
float t = 0.0f;
Eigen::Vector3f n;
if (!rayAABBHit(eye, ray_dir,
@@ -5370,27 +5449,165 @@ bool ViewportCore::pickSurfaceAt(int x_pixels, int y_pixels,
}
if (!found) return false;
if (aabb_radius_out) *aabb_radius_out = best_radius;
aabb_radius_out = best_radius;
world_pos_out = best_point;
// Prefer per-fragment normal from the pick MRT; fall back to AABB face.
world_normal_out = (picked_normal.squaredNorm() > 1e-3f)
? picked_normal : best_normal;
object_id_out = id;
world_normal_out = (mrt_normal.squaredNorm() > 1e-3f) ? mrt_normal : best_normal;
return true;
}
bool ViewportCore::pickSurfaceAt(int x_pixels, int y_pixels,
std::uint32_t& object_id_out,
Eigen::Vector3f& world_pos_out,
Eigen::Vector3f& world_normal_out,
float* aabb_radius_out) {
if (aabb_radius_out) *aabb_radius_out = 0.0f;
Eigen::Vector3f picked_normal(0, 0, 1);
// pickObjectAt encodes + reads id + normal, and (now) stages the exact world
// position into pick_position_staging_buffer_ in the same pass.
const std::uint32_t id = pickObjectAt(x_pixels, y_pixels, &picked_normal);
if (id == 0) return false;
// Read the exact surface position from the pick MRT (sync map — desktop
// path). This lands the hit on the true face rather than a ray-AABB point.
if (pick_position_staging_buffer_) {
struct MapReq { bool done = false; bool ok = false; };
MapReq req;
WGPUBufferMapCallbackInfo mcb = {};
mcb.mode = kAsyncCbMode;
mcb.callback = [](WGPUMapAsyncStatus s, WGPUStringView, void* u, void*) {
auto* r = static_cast<MapReq*>(u); r->done = true;
r->ok = (s == WGPUMapAsyncStatus_Success);
};
mcb.userdata1 = &req;
wgpuBufferMapAsync(pick_position_staging_buffer_, WGPUMapMode_Read, 0, 256, mcb);
while (!req.done) waitTickInstance(instance_);
Eigen::Vector3f mrt_pos;
if (req.ok && decodeMappedPickPosition(mrt_pos)) {
world_pos_out = mrt_pos;
world_normal_out = picked_normal;
object_id_out = id;
return true;
}
}
// Fallback: ray-AABB (e.g. if the position read failed).
float radius = 0.0f;
if (!raycastSurfaceForObject(id, x_pixels, y_pixels, picked_normal,
world_pos_out, world_normal_out, radius)) return false;
if (aabb_radius_out) *aabb_radius_out = radius;
object_id_out = id;
return true;
}
#if defined(__EMSCRIPTEN__)
void ViewportCore::finishSurfaceAsync(SurfaceHit hit) {
auto cb = std::move(surface_async_cb_);
surface_async_cb_ = nullptr;
pick_async_in_flight_ = false;
if (cb) cb(hit);
}
void ViewportCore::pickSurfaceAtAsync(int x_pixels, int y_pixels,
std::function<void(SurfaceHit)> cb) {
auto miss = [&cb]() { if (cb) cb(SurfaceHit{}); };
if (!pick_pipeline_ || !device_ || !queue_ || models_gpu_.empty()) { miss(); return; }
if (configured_w_ <= 0 || configured_h_ <= 0) { miss(); return; }
if (x_pixels < 0 || y_pixels < 0 ||
x_pixels >= configured_w_ || y_pixels >= configured_h_) { miss(); return; }
ensurePickAttachments(configured_w_, configured_h_);
if (!pick_color_view_ || !pick_depth_view_ ||
!pick_staging_buffer_ || !pick_normal_staging_buffer_) { miss(); return; }
// Shares the single-pick staging buffers → shares the in-flight guard.
if (pick_async_in_flight_) { miss(); return; }
pick_async_in_flight_ = true;
surface_async_x_ = x_pixels;
surface_async_y_ = y_pixels;
surface_async_id_ = 0;
surface_async_cb_ = std::move(cb);
// Render pick + normal MRTs, copy both texels to their staging buffers.
encodePickReadbackToStaging(x_pixels, y_pixels, /*want_normal=*/true);
// Map the object-id texel; then (chained) the normal texel; then raycast.
WGPUBufferMapCallbackInfo idcb = {};
idcb.mode = kAsyncCbMode;
idcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/,
void* ud1, void* /*ud2*/) {
auto* self = static_cast<ViewportCore*>(ud1);
std::uint32_t id = 0;
if (status == WGPUMapAsyncStatus_Success) {
const std::uint32_t* mapped = static_cast<const std::uint32_t*>(
wgpuBufferGetConstMappedRange(self->pick_staging_buffer_, 0, 256));
id = mapped ? mapped[0] : 0u;
wgpuBufferUnmap(self->pick_staging_buffer_);
}
if (id == 0) { self->finishSurfaceAsync(SurfaceHit{}); return; }
self->surface_async_id_ = id;
// Chain: normal texel → then the exact-position texel → then deliver.
WGPUBufferMapCallbackInfo ncb = {};
ncb.mode = kAsyncCbMode;
ncb.callback = [](WGPUMapAsyncStatus s2, WGPUStringView /*msg*/,
void* u1, void* /*u2*/) {
auto* self = static_cast<ViewportCore*>(u1);
self->surface_async_normal_ = Eigen::Vector3f::Zero();
if (s2 == WGPUMapAsyncStatus_Success)
self->decodeMappedPickNormal(self->surface_async_normal_);
WGPUBufferMapCallbackInfo pcb = {};
pcb.mode = kAsyncCbMode;
pcb.callback = [](WGPUMapAsyncStatus s3, WGPUStringView /*msg*/,
void* u2, void* /*u3*/) {
auto* self = static_cast<ViewportCore*>(u2);
Eigen::Vector3f pos;
const bool have_pos = (s3 == WGPUMapAsyncStatus_Success)
&& self->decodeMappedPickPosition(pos);
const bool have_n = self->surface_async_normal_.squaredNorm() > 1e-3f;
SurfaceHit hit;
if (have_pos && have_n) {
// True surface point + MRT normal — no ray-AABB.
hit.found = true;
hit.object_id = self->surface_async_id_;
hit.world_pos = pos;
hit.world_normal = self->surface_async_normal_.normalized();
} else {
// Fallback: ray-AABB (prefers the MRT position if we had it).
float radius = 0.0f;
Eigen::Vector3f p, n;
if (self->raycastSurfaceForObject(self->surface_async_id_,
self->surface_async_x_, self->surface_async_y_,
self->surface_async_normal_, p, n, radius)) {
hit.found = true;
hit.object_id = self->surface_async_id_;
hit.world_pos = have_pos ? pos : p;
hit.world_normal = n;
}
}
self->finishSurfaceAsync(hit);
};
pcb.userdata1 = self;
wgpuBufferMapAsync(self->pick_position_staging_buffer_, WGPUMapMode_Read, 0, 256, pcb);
};
ncb.userdata1 = self;
wgpuBufferMapAsync(self->pick_normal_staging_buffer_, WGPUMapMode_Read, 0, 256, ncb);
};
idcb.userdata1 = this;
wgpuBufferMapAsync(pick_staging_buffer_, WGPUMapMode_Read, 0, 256, idcb);
}
#endif
bool ViewportCore::pickMeshLocalAt(int x, int y, MeshLocalPick& out) {
std::uint32_t obj_id = 0;
Eigen::Vector3f world_pos, world_normal;
if (!pickSurfaceAt(x, y, obj_id, world_pos, world_normal)) return false;
// Use the OUTER mid (the live map key) rather than inst.model_id —
// InstanceCpu::model_id is stale across sessions.
// InstanceInfo::model_id is stale across sessions.
for (const auto& [mid, m] : models_gpu_) {
auto it = m.object_id_to_instance.find(obj_id);
if (it == m.object_id_to_instance.end()) continue;
const InstanceCpu& inst = m.instances[it->second];
const InstanceInfo& inst = m.instances[it->second];
const Eigen::Matrix4f T = Eigen::Map<const Eigen::Matrix4f>(inst.transform);
Eigen::Matrix4f Ti;
@@ -5528,7 +5745,7 @@ bool ViewportCore::raycast(const float origin[3], const float dir[3],
for (const auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
for (std::uint32_t inst_idx = 0; inst_idx < std::uint32_t(m.instances.size()); ++inst_idx) {
const InstanceCpu& inst = m.instances[inst_idx];
const InstanceInfo& inst = m.instances[inst_idx];
if (!rayAabbSlab(origin, inv_d, inst.world_aabb_min, inst.world_aabb_max)) {
continue;
}
@@ -6202,8 +6419,13 @@ void ViewportCore::render() {
overlay_frame.viewport_h_px = viewport_h_px;
overlay_frame.device_pixel_ratio = dpr_int;
// In-pass overlays (section gizmos, highlight triangles, pivot,
// overlay lines/points). QtViewportHost forwards to overlays_.X().
// Section-plane gizmo — shared renderer, drawn for desktop + web from here.
// (The desktop's OverlayRenderer no longer draws it, to avoid doubling.)
section_gizmo_.encode(pass, vp_this_frame, section_planes_,
viewport_w_px, viewport_h_px, dpr_int);
// Remaining in-pass overlays (highlight triangles, pivot, overlay
// lines/points). QtViewportHost forwards to overlays_.X(); web host no-ops.
host_->encodeOverlaysInMainPass(pass, overlay_frame);
wgpuRenderPassEncoderEnd(pass);
@@ -6510,3 +6732,71 @@ void ViewportCore::clearSectionPlanes() {
Log::info() << "[wgpu section] cleared all planes";
host_->requestFrame();
}
// Logical (CSS-px) viewport from the host framebuffer + DPR.
void ViewportCore::sectionLogicalViewport(int& w, int& h) const {
int fb_w = 0, fb_h = 0;
host_->framebufferSize(fb_w, fb_h);
const int dpr = std::max(1, int(host_->dpr()));
w = fb_w / dpr;
h = fb_h / dpr;
}
int ViewportCore::hitTestSectionGizmo(int x, int y) {
if (section_planes_.empty()) return -1;
Eigen::Matrix4f view, proj;
buildViewProj(view, proj);
int w = 0, h = 0;
sectionLogicalViewport(w, h);
if (w <= 0 || h <= 0) return -1;
return SectionGizmoRenderer::hitTest(x, y, section_planes_, view, proj, w, h);
}
bool ViewportCore::beginSectionDrag(int gizmo_index, int mouse_x, int mouse_y) {
if (gizmo_index < 0 || gizmo_index >= int(section_planes_.size())) return false;
section_drag_active_ = true;
section_drag_index_ = gizmo_index;
section_drag_start_origin_ = section_planes_[gizmo_index].origin;
section_drag_start_mx_ = mouse_x;
section_drag_start_my_ = mouse_y;
return true;
}
void ViewportCore::updateSectionDrag(int mouse_x, int mouse_y) {
if (!section_drag_active_) return;
if (section_drag_index_ < 0 || section_drag_index_ >= int(section_planes_.size())) return;
SectionPlane& p = section_planes_[section_drag_index_];
int w = 0, h = 0;
sectionLogicalViewport(w, h);
if (w <= 0 || h <= 0) return;
Eigen::Matrix4f view, proj;
buildViewProj(view, proj);
const Eigen::Matrix4f vp = proj * view;
// Reproject the PRESS-TIME origin (and origin + n) every frame so the slide
// stays smooth even if the camera moves mid-drag.
auto to_screen = [&](const Eigen::Vector3f& world, Eigen::Vector2f& out) -> bool {
const Eigen::Vector4f clip = vp * Eigen::Vector4f(world.x(), world.y(), world.z(), 1.0f);
if (clip.w() <= 0.0f) return false;
const float invw = 1.0f / clip.w();
out = Eigen::Vector2f((clip.x() * invw * 0.5f + 0.5f) * float(w),
(1.0f - (clip.y() * invw * 0.5f + 0.5f)) * float(h));
return true;
};
Eigen::Vector2f s_origin, s_n;
if (!to_screen(section_drag_start_origin_, s_origin)) return;
if (!to_screen(section_drag_start_origin_ + p.n, s_n)) return;
const Eigen::Vector2f axis = s_n - s_origin;
const float len2 = axis.squaredNorm();
if (len2 < 1e-3f) return; // arrow edge-on
// Project the pixel delta onto the screen-space normal axis; the axis is 1 m
// in world space, so (delta·axis)/|axis|² is the slide in metres.
const Eigen::Vector2f delta(float(mouse_x - section_drag_start_mx_),
float(mouse_y - section_drag_start_my_));
const float meters = delta.dot(axis) / len2;
p.origin = section_drag_start_origin_ + p.n * meters;
p.d = -p.n.dot(p.origin);
host_->requestFrame();
}
+83 -7
View File
@@ -50,6 +50,7 @@
#include "InstanceCompose.h"
#include "InstancedGeometry.h"
#include "ModelGpuData.h"
#include "SectionGizmoRenderer.h"
#include "SectionPlane.h"
#include "SelectionState.h"
#include "SidecarCache.h"
@@ -110,7 +111,7 @@ public:
// from the mesh-local one. Used by the per-model recompose path
// after any of the four federation matrices change. Pure scene
// math — no GPU touch.
void composeInstanceFromPlacement(InstanceCpu& inst,
void composeInstanceFromPlacement(InstanceInfo& inst,
const ModelGpuData& m) const;
// Cross-model object_id lookup. Delegates to
@@ -425,16 +426,16 @@ public:
// `source_label` is a log/identity tag.
void loadSidecarMetadataWeb(int source_id, std::string source_label);
// On-demand fetch of the v15 deferred property block (element tree + string
// On-demand fetch of the v15 element metadata block (elements + string
// table) for a web-streamed model — what a UI (object tree / selected-name
// / search) needs, fetched only when asked so first paint never waits on
// it. Populates ModelGpuData.elements/string_table; fires done(ok). At most
// one fetch per model.
void loadDeferredMetadataWeb(std::uint32_t model_id,
std::function<void(bool)> done = {});
void loadElementMetadataWeb(std::uint32_t model_id,
std::function<void(bool)> done = {});
// Demo consumer of the deferred fetch: on pick, ensure the owning model's
// property block is loaded (loadDeferredMetadataWeb — fetched once, on
// Demo consumer of the element metadata fetch: on pick, ensure the owning model's
// property block is loaded (loadElementMetadataWeb — fetched once, on
// demand), then log the picked object's IFC GUID. The first pick triggers
// the network fetch; later picks reuse the cached element table.
void logSelectedObjectGuidWeb(std::uint32_t object_id);
@@ -524,6 +525,22 @@ public:
// Drop every section plane. No-op when none are active.
void clearSectionPlanes();
// Number of active section planes (0..kMaxSectionPlanes).
int sectionPlaneCount() const { return int(section_planes_.size()); }
// ---- Section gizmo interaction (shared desktop + web) -------------------
//
// All coords are LOGICAL (CSS) pixels; the core derives the logical viewport
// from the host. hitTestSectionGizmo returns the plane index whose gizmo
// arrow is under (x,y), or -1. The drag trio slides a plane along its normal:
// begin captures the plane origin + press point, update reprojects and moves
// it, end finishes.
int hitTestSectionGizmo(int x, int y);
bool beginSectionDrag(int gizmo_index, int mouse_x, int mouse_y);
void updateSectionDrag(int mouse_x, int mouse_y);
void endSectionDrag() { section_drag_active_ = false; }
bool sectionDragActive() const { return section_drag_active_; }
// ---- Render loop (#84-x) ----------------------------------------------
//
// Encode one frame: acquire the swapchain texture, run cull (parallel
@@ -646,6 +663,27 @@ public:
int w, int h,
std::uint64_t needed_bytes);
// CPU half of pickSurfaceAt: cast the pixel's world ray against every
// instance carrying `object_id`, returning the closest hit's world pos,
// normal (mrt_normal if non-degenerate, else the AABB-face normal), and the
// instance bounding-sphere radius. Shared by the sync pickSurfaceAt and the
// async pickSurfaceAtAsync. False if no instance is hit.
bool raycastSurfaceForObject(std::uint32_t object_id, int x_pixels, int y_pixels,
const Eigen::Vector3f& mrt_normal,
Eigen::Vector3f& world_pos_out,
Eigen::Vector3f& world_normal_out,
float& aabb_radius_out);
// Decode the RGBA16F pick-normal from the (already-mapped) normal staging
// buffer into a unit world normal; unmaps. False if degenerate. Shared by
// the sync pickObjectAt and the async pickSurfaceAtAsync.
bool decodeMappedPickNormal(Eigen::Vector3f& out);
// Logical (CSS-px) viewport size from the host framebuffer / DPR, for the
// section-gizmo hit-test + drag (which work in logical pixels).
void sectionLogicalViewport(int& w, int& h) const;
// Decode the RGBA32F exact world position from the (already-mapped) position
// staging buffer; unmaps. False if the texel was a miss (w == 0).
bool decodeMappedPickPosition(Eigen::Vector3f& out);
// Tear down every pick-owned wgpu resource (pipeline + MRTs +
// staging buffers). Called from shutdown() before device_ dies.
void releasePickResources();
@@ -715,8 +753,24 @@ public:
Eigen::Vector3f& world_normal_out,
float* aabb_radius_out = nullptr);
#if defined(__EMSCRIPTEN__)
// Async surface pick for web (drives the section tool). Reuses the async
// object pick (no new GPU readback), then runs the same CPU ray-AABB cast as
// pickSurfaceAt. On web the normal is the AABB-face normal (the precise MRT
// normal would need a second async map — a later refinement).
struct SurfaceHit {
bool found = false;
std::uint32_t object_id = 0;
Eigen::Vector3f world_pos = Eigen::Vector3f::Zero();
Eigen::Vector3f world_normal = Eigen::Vector3f::UnitZ();
float aabb_radius = 0.0f;
};
void pickSurfaceAtAsync(int x_pixels, int y_pixels,
std::function<void(SurfaceHit)> cb);
#endif
// Per-pick result for the Area / Length / Volume tools. The
// composed_transform mirrors InstanceCpu::transform so callers can
// composed_transform mirrors InstanceInfo::transform so callers can
// round-trip from mesh-local back to world without re-deriving it.
struct MeshLocalPick {
std::uint32_t object_id = 0;
@@ -845,6 +899,10 @@ private:
WGPUPipelineLayout pipeline_layout_ = nullptr;
WGPURenderPipeline main_pipeline_ = nullptr;
WGPURenderPipeline main_pipeline_transparent_ = nullptr;
// Section-plane gizmo, shared by desktop + web (both render via render()).
// Lifted out of the Qt-coupled OverlayRenderer so one identical gizmo draws
// everywhere; the desktop's OverlayRenderer no longer draws it.
SectionGizmoRenderer section_gizmo_;
// HiZ occlusion-cull pipeline group. Downsamples MSAA depth into a
// mip pyramid; consumed by next-frame cull.
@@ -934,10 +992,13 @@ private:
WGPUTextureView pick_color_view_ = nullptr;
WGPUTexture pick_normal_texture_ = nullptr;
WGPUTextureView pick_normal_view_ = nullptr;
WGPUTexture pick_position_texture_ = nullptr; // RGBA32F exact world pos
WGPUTextureView pick_position_view_ = nullptr;
WGPUTexture pick_depth_texture_ = nullptr;
WGPUTextureView pick_depth_view_ = nullptr;
WGPUBuffer pick_staging_buffer_ = nullptr;
WGPUBuffer pick_normal_staging_buffer_ = nullptr;
WGPUBuffer pick_position_staging_buffer_ = nullptr;
int pick_w_ = 0;
int pick_h_ = 0;
WGPUBuffer box_pick_staging_buffer_ = nullptr;
@@ -955,6 +1016,14 @@ private:
int box_pick_async_h_ = 0;
std::uint64_t box_pick_async_padded_bpr_ = 0;
std::uint64_t box_pick_async_bytes_ = 0;
// Async surface pick (section tool): chained id→normal staging maps. Reuses
// pick_async_in_flight_ (same staging buffers as the single object pick).
std::function<void(SurfaceHit)> surface_async_cb_;
int surface_async_x_ = 0;
int surface_async_y_ = 0;
std::uint32_t surface_async_id_ = 0;
Eigen::Vector3f surface_async_normal_ = Eigen::Vector3f::Zero();
void finishSurfaceAsync(SurfaceHit hit);
#endif
// ---- Frame uniforms + selection bind ----------------------------------
@@ -978,6 +1047,13 @@ private:
// removeSectionPlane (still Qt-bound — they wire into the input
// path). Reading happens here.
std::vector<SectionPlane> section_planes_;
// Section-gizmo drag state (shared): which plane, its press-time origin, and
// the press point (logical px) so update can slide it along the normal.
bool section_drag_active_ = false;
int section_drag_index_ = -1;
Eigen::Vector3f section_drag_start_origin_ = Eigen::Vector3f::Zero();
int section_drag_start_mx_ = 0;
int section_drag_start_my_ = 0;
// X-ray mode alpha clamp: when < 1.0 every instance routes through
// the transparent pass with fragment.a clamped to min(in.color.a, cap).
+19 -104
View File
@@ -427,7 +427,8 @@ void ViewportWindow::encodeOverlaysInMainPass(WGPURenderPassEncoder pass,
// — drawn inside the MSAA pass so depth-test correctly hides them
// behind closer geometry. (Corner axis / marquee / labels run on the
// resolved surface; see encodeOverlaysPostMain.)
overlays_.encodeSectionGizmos(pass, frame, section_planes_);
// NB: section-plane gizmos now draw from ViewportCore::render via the shared
// SectionGizmoRenderer (desktop + web), so they are NOT drawn here.
overlays_.encodeHighlightTriangles(pass, frame);
overlays_.encodePivot(pass, frame, pivot_indicator_visible_);
overlays_.encodeOverlayLines(pass, frame);
@@ -1038,11 +1039,11 @@ bool ViewportWindow::meshLocalToGlobal(uint32_t object_id,
double global_out[3]) const {
// Find the instance via the per-model object_id_to_instance map.
// Use the live map key (`mid`) — see pickMeshLocalAt comment about
// stale InstanceCpu::model_id from sidecar writes.
// stale InstanceInfo::model_id from sidecar writes.
for (const auto& [mid, m] : models_gpu_) {
auto it = m.object_id_to_instance.find(object_id);
if (it == m.object_id_to_instance.end()) continue;
const InstanceCpu& inst = m.instances[it->second];
const InstanceInfo& inst = m.instances[it->second];
// CoordinateOperation · placement · local — gives the IFC's own
// georeferenced world frame (ENH). Excludes FederatedFalseOrigin
// and ModelTransformation, matching the GL meshLocalToGlobal
@@ -1138,7 +1139,7 @@ void ViewportWindow::invertElementVisibility() {
to_hide.reserve(1024);
for (const auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
for (const InstanceCpu& inst : m.instances) {
for (const InstanceInfo& inst : m.instances) {
if (inst.object_id == 0) continue;
if (!visibility_.isHidden(inst.object_id)) {
to_hide.push_back(inst.object_id);
@@ -1234,7 +1235,7 @@ void ViewportWindow::updateVolumeReadout() {
total += v;
if (!show_labels) continue;
// O(1) instance lookup via object_id_to_instance, then read the
// world AABB from the cached InstanceCpu directly — same data
// world AABB from the cached InstanceInfo directly — same data
// computeObjectAabb's linear scan would have produced for the
// first matching instance. For label placement at the AABB
// centre this is identical-looking; only the rare multi-
@@ -1242,7 +1243,7 @@ void ViewportWindow::updateVolumeReadout() {
for (const auto& [mid, m] : models_gpu_) {
auto it = m.object_id_to_instance.find(oid);
if (it == m.object_id_to_instance.end()) continue;
const InstanceCpu& inst = m.instances[it->second];
const InstanceInfo& inst = m.instances[it->second];
OverlayRenderer::Label lbl;
lbl.world_pos[0] = (inst.world_aabb_min[0] + inst.world_aabb_max[0]) * 0.5f;
lbl.world_pos[1] = (inst.world_aabb_min[1] + inst.world_aabb_max[1]) * 0.5f;
@@ -1265,93 +1266,12 @@ void ViewportWindow::updateVolumeReadout() {
overlays_.setOverlayLabels(labels);
}
// Project a world point to LOGICAL pixel coords (Qt's mouse-event units).
// Returns false if behind the camera.
static bool projectWorldToLogicalScreen(const Eigen::Matrix4f& vp,
const Eigen::Vector3f& world,
int win_w, int win_h,
Eigen::Vector2f& out) {
const Eigen::Vector4f clip = vp * Eigen::Vector4f(world.x(), world.y(), world.z(), 1.0f);
if (clip.w() <= 0.0f) return false;
const float invw = 1.0f / clip.w();
out = Eigen::Vector2f(
(clip.x() * invw * 0.5f + 0.5f) * float(win_w),
(1.0f - (clip.y() * invw * 0.5f + 0.5f)) * float(win_h));
return true;
}
// projectWorldToLogicalScreen moved to SectionGizmoRenderer (its only users,
// the section hit-test + drag, now live in ViewportCore).
int ViewportWindow::hitTestSectionGizmo(int x, int y) const {
if (section_planes_.empty()) return -1;
const int w = width();
const int h = height();
if (w <= 0 || h <= 0) return -1;
Eigen::Matrix4f view, proj;
core_.buildViewProj(view, proj);
const Eigen::Matrix4f vp = proj * view;
const float grab_px = 12.0f;
int best = -1;
float best_d2 = grab_px * grab_px;
for (int i = 0; i < int(section_planes_.size()); ++i) {
const SectionPlane& p = section_planes_[i];
Eigen::Vector2f s_origin, s_tip;
if (!projectWorldToLogicalScreen(vp, p.origin,
w, h, s_origin)) continue;
// The gizmo's arrow extends along +n by exactly 1 m in world
// space — OverlayRenderer::encodeSectionGizmos uses
// half_size = 1.0 to scale a plane-local arrow tip at z = 1.
// Mirror that here.
if (!projectWorldToLogicalScreen(vp, p.origin + p.n * 1.0f,
w, h, s_tip)) continue;
const Eigen::Vector2f q{float(x), float(y)};
const Eigen::Vector2f ab = s_tip - s_origin;
const float ab_len2 = ab.squaredNorm();
if (ab_len2 < 1e-3f) continue;
float t = (q - s_origin).dot(ab) / ab_len2;
t = std::clamp(t, 0.0f, 1.0f);
const Eigen::Vector2f proj_pt = s_origin + ab * t;
const float d2 = (q - proj_pt).squaredNorm();
if (d2 < best_d2) { best_d2 = d2; best = i; }
}
return best;
}
void ViewportWindow::updateSectionDrag(int x, int y) {
if (!section_drag_active_) return;
if (section_drag_index_ < 0
|| section_drag_index_ >= int(section_planes_.size())) return;
SectionPlane& p = section_planes_[section_drag_index_];
const int w = width();
const int h = height();
if (w <= 0 || h <= 0) return;
Eigen::Matrix4f view, proj;
core_.buildViewProj(view, proj);
const Eigen::Matrix4f vp = proj * view;
// Re-project the press-time origin and origin + n to screen space.
// The press-time origin is what `start` should be relative to — so the
// plane slides smoothly even as the camera moves (we re-project every
// frame to handle mid-drag camera rotation cleanly).
Eigen::Vector2f s_origin, s_n;
if (!projectWorldToLogicalScreen(vp, section_drag_start_origin_,
w, h, s_origin)) return;
if (!projectWorldToLogicalScreen(vp, section_drag_start_origin_ + p.n,
w, h, s_n)) return;
const Eigen::Vector2f screen_axis = s_n - s_origin;
const float screen_axis_len2 = screen_axis.squaredNorm();
if (screen_axis_len2 < 1e-3f) return; // arrow is edge-on
// Project pixel delta onto the screen-space axis; convert to metres
// via (delta · axis) / |axis|² (axis is 1 m long in world space).
const Eigen::Vector2f delta_px(float(x - section_drag_start_mouse_.x()),
float(y - section_drag_start_mouse_.y()));
const float meters = delta_px.dot(screen_axis)
/ screen_axis_len2;
p.origin = section_drag_start_origin_ + p.n * meters;
p.d = -p.n.dot(p.origin);
requestUpdate();
}
// Section-gizmo hit-test + drag-to-move now live in ViewportCore (shared with
// web, using SectionGizmoRenderer::hitTest). The mouse handlers call
// core_.hitTestSectionGizmo / beginSectionDrag / updateSectionDrag / endSectionDrag.
// buildHizPipeline moved to ViewportCore (#84-r).
@@ -1617,13 +1537,9 @@ void ViewportWindow::mousePressEvent(QMouseEvent* event) {
&& event->button() == Qt::LeftButton
&& event->modifiers() == Qt::NoModifier) {
const Eigen::Vector2i lp = toV2i(event->position().toPoint());
const int hit = hitTestSectionGizmo(lp.x(), lp.y());
if (hit >= 0) {
section_drag_active_ = true;
section_drag_index_ = hit;
section_drag_start_mouse_ = lp;
section_drag_start_origin_ = section_planes_[hit].origin;
nav_drag_kind_ = NavDrag::Inactive;
const int hit = core_.hitTestSectionGizmo(lp.x(), lp.y());
if (hit >= 0 && core_.beginSectionDrag(hit, lp.x(), lp.y())) {
nav_drag_kind_ = NavDrag::Inactive;
Log::info().noquote().nospace()
<< "[wgpu section] drag start: plane=" << hit;
return;
@@ -1662,9 +1578,8 @@ void ViewportWindow::mousePressEvent(QMouseEvent* event) {
}
void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
if (section_drag_active_ && event->button() == Qt::LeftButton) {
section_drag_active_ = false;
section_drag_index_ = -1;
if (core_.sectionDragActive() && event->button() == Qt::LeftButton) {
core_.endSectionDrag();
nav_active_button_ = Qt::NoButton;
return;
}
@@ -1863,9 +1778,9 @@ void ViewportWindow::mouseMoveEvent(QMouseEvent* event) {
// Section drag intercepts the move handler entirely: the orbit/pan
// classification already declined this drag in mousePressEvent, so all
// we have to do is slide the plane along its normal.
if (section_drag_active_) {
if (core_.sectionDragActive()) {
const Eigen::Vector2i pos = toV2i(event->position().toPoint());
updateSectionDrag(pos.x(), pos.y());
core_.updateSectionDrag(pos.x(), pos.y());
return;
}
+4 -15
View File
@@ -396,7 +396,7 @@ public:
// A point that actually lies on the model's first instance — the
// first instance's mesh AABB centre transformed by that instance's
// placement, in metres, pre-CoordinateOperation. Lookup only — the
// viewport already keeps the CPU-side MeshInfo + InstanceCpu around
// viewport already keeps the CPU-side MeshInfo + InstanceInfo around
// for picking / measurement; the federation false-origin guess
// (ViewportView::guessFederatedFalseOriginFromFirstModel) consumes
// this lazily on modelGeometryReady. Returns false when the model
@@ -737,20 +737,9 @@ private:
Qt::KeyboardModifiers box_select_press_mods_ = Qt::NoModifier;
static constexpr int kBoxSelectThresholdPx = 5;
// box_pick_staging_buffer_/_capacity_ moved to ViewportCore (#84-t).
// 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;
Eigen::Vector2i section_drag_start_mouse_;
Eigen::Vector3f 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-gizmo drag state + hit-test/drag math moved to ViewportCore
// (shared with web; the mouse handlers call core_.begin/update/endSectionDrag
// and core_.hitTestSectionGizmo).
// HiZ slot + pyramid aliases (storage in core_, #84-r). VW's render
// loop reads hiz_valid_ / hiz_vp_ to gate the HizOccludedFn, and
@@ -143,6 +143,25 @@ TEST_CASE("toggleXray flips the active state", "[camera][xray]") {
REQUIRE_FALSE(core.xrayActive());
}
TEST_CASE("section planes: add appends, clear drops all, capped at the max",
"[camera][section]") {
MockHost host; ViewportCore core(&host);
REQUIRE(core.sectionPlaneCount() == 0);
const Eigen::Vector3f pt(1, 2, 3), n(0, 0, 1);
REQUIRE(core.addSectionPlaneAtSurface(pt, n, 1.0f));
REQUIRE(core.addSectionPlaneAtSurface(pt, n, 1.0f));
REQUIRE(core.sectionPlaneCount() == 2);
// Fill to the cap (kMaxSectionPlanes == 6); further adds are rejected.
while (core.sectionPlaneCount() < 6) REQUIRE(core.addSectionPlaneAtSurface(pt, n, 1.0f));
REQUIRE_FALSE(core.addSectionPlaneAtSurface(pt, n, 1.0f));
REQUIRE(core.sectionPlaneCount() == 6);
core.clearSectionPlanes();
REQUIRE(core.sectionPlaneCount() == 0);
}
TEST_CASE("setNavPreset maps names to the shared button bindings", "[camera][nav]") {
MockHost host; ViewportCore core(&host);
using B = ViewportCore::MouseBtn; using M = ViewportCore::NavMod;