mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = §ion_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
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user