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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-03 17:31:02 +10:00
parent 9ad10c009b
commit da5c0b7991
14 changed files with 1093 additions and 536 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ target_link_options(IfcViewerWeb PRIVATE
# EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't
# add them to Module. ccall lets shell.html pass a JS string (the ?model
# URL) to load_sidecar_from_url_c without manual heap marshalling.
"-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_toggle_xray_c','_xray_is_active_c']"
"-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c']"
# ccall: shell.html passes the ?model URL string to load_sidecar_from_url_c.
# HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large
# sidecar streams by range instead of loading whole). Standard, zero-cost.
+82 -11
View File
@@ -67,6 +67,11 @@ struct AppState {
// bindings (ViewportCore::navBindings), so any preset works on web too.
bool nav_active = false;
NavKind nav_kind = NavKind::None;
// Section-cut tool: while active, a select-button click drops a clip plane
// at the picked surface (K toggles, Shift+K clears — matches desktop).
bool section_tool_active = false;
// True while dragging a section-plane gizmo (LMB down on the arrow → slide).
bool section_dragging = false;
// Accumulated |movement| since mousedown, in CSS px. A select-button release
// under the click threshold (no real drag) is treated as a pick; a drag will
// become a marquee. Captures the down position (canvas-relative CSS px).
@@ -146,6 +151,14 @@ EM_BOOL onMouseDown(int, const EmscriptenMouseEvent* e, void* user) {
auto* app = static_cast<AppState*>(user);
// In fly mode a click exits (matches the desktop app).
if (app->fly_mode) { setFlyMode(app, false); return EM_TRUE; }
// Section tool: LMB on a plane's gizmo arrow grabs it to slide (logical px).
if (app->section_tool_active && e->button == 0) {
const int hit = app->core.hitTestSectionGizmo(int(e->targetX), int(e->targetY));
if (hit >= 0 && app->core.beginSectionDrag(hit, int(e->targetX), int(e->targetY))) {
app->section_dragging = true;
return EM_TRUE; // claim the press — don't orbit
}
}
const NavKind kind = classifyPress(app->core.navBindings(), e->button,
e->shiftKey, e->ctrlKey, e->altKey);
if (kind != NavKind::None) {
@@ -166,6 +179,11 @@ EM_BOOL onMouseMove(int, const EmscriptenMouseEvent* e, void* user) {
app->core.flyLook(float(e->movementX), float(e->movementY));
return EM_TRUE;
}
// Section gizmo drag: slide the grabbed plane along its normal (logical px).
if (app->section_dragging) {
app->core.updateSectionDrag(int(e->targetX), int(e->targetY));
return EM_TRUE;
}
if (!app->nav_active) return EM_FALSE;
const float dx = float(e->movementX);
@@ -191,11 +209,33 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) {
app->nav_active = false;
app->nav_kind = NavKind::None;
if (was_active && kind == NavKind::Select && app->ready) {
const double dpr = emscripten_get_device_pixel_ratio();
const bool add = e->shiftKey;
const bool remove = e->ctrlKey;
if (app->nav_drag_px > kClickDragThresholdPx) {
// End a section-gizmo drag (took over the press; no pick/orbit on release).
if (app->section_dragging) {
app->core.endSectionDrag();
app->section_dragging = false;
return EM_TRUE;
}
if (!was_active || !app->ready) return EM_TRUE;
const double dpr = emscripten_get_device_pixel_ratio();
const bool no_drag = app->nav_drag_px <= kClickDragThresholdPx;
// Section tool claims a LEFT-button click ("click a surface to cut"); LMB
// drag still orbits. Takes priority over nav while the tool is active.
if (app->section_tool_active && e->button == 0 && no_drag) {
const int px = int(app->down_x * dpr), py = int(app->down_y * dpr);
app->core.pickSurfaceAtAsync(px, py, [app](ViewportCore::SurfaceHit hit) {
if (hit.found) // pad past the AABB so the cut reads as a cap
app->core.addSectionPlaneAtSurface(hit.world_pos, hit.world_normal,
hit.aabb_radius * 1.5f);
app->host.requestFrame();
});
return EM_TRUE;
}
if (kind == NavKind::Select) {
const bool add = e->shiftKey, remove = e->ctrlKey;
if (!no_drag) {
// Marquee drag → box-pick the rect (device px) and apply to selection.
hideMarquee();
const long x0 = std::min<long>(app->down_x, e->targetX);
@@ -209,14 +249,11 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) {
app->host.requestFrame();
});
} else {
// No real drag → single pick under the cursor (Shift add, Ctrl remove,
// plain replace). Async readback: highlight lands a frame later.
const int px = int(app->down_x * dpr);
const int py = int(app->down_y * dpr);
// Single pick under the cursor (Shift add, Ctrl remove, plain replace).
const int px = int(app->down_x * dpr), py = int(app->down_y * dpr);
app->core.pickObjectAtAsync(px, py, [app, add, remove](std::uint32_t id) {
app->core.applyPickToSelection(id, add, remove);
// v15 on-demand deferred fetch: log the picked object's IFC GUID
// (first pick fetches the property block off the network).
// v15 on-demand element metadata fetch: log the picked object's IFC GUID.
if (id != 0) app->core.logSelectedObjectGuidWeb(id);
app->host.requestFrame();
});
@@ -300,6 +337,25 @@ EM_BOOL onKeyDown(int, const EmscriptenKeyboardEvent* e, void* user) {
return EM_TRUE;
}
if (!std::strcmp(code, "KeyX") && alt) { app->core.toggleXray(); return EM_TRUE; }
// Section tool: K toggles drop-a-plane mode, Shift+K clears all cuts.
if (!std::strcmp(code, "KeyK")) {
if (shift) app->core.clearSectionPlanes();
else {
app->section_tool_active = !app->section_tool_active;
Log::info() << "[section] tool "
<< (app->section_tool_active ? "active — click a surface" : "off");
}
app->host.requestFrame();
return EM_TRUE;
}
// Del/Backspace removes the most recent cut while the tool is active.
if (app->section_tool_active &&
(!std::strcmp(code, "Delete") || !std::strcmp(code, "Backspace"))) {
const int n = app->core.sectionPlaneCount();
if (n > 0) app->core.removeSectionPlane(n - 1);
app->host.requestFrame();
return EM_TRUE;
}
using SV = ViewportCore::StandardView;
if (!std::strcmp(code, "Home")) app->core.viewAll();
else if (!std::strcmp(code, "KeyF") && !shift) app->core.frameSelection();
@@ -438,6 +494,21 @@ extern "C" EMSCRIPTEN_KEEPALIVE void show_all_c() { if (g_app && g_app->
extern "C" EMSCRIPTEN_KEEPALIVE void toggle_xray_c() { if (g_app && g_app->ready) g_app->core.toggleXray(); }
extern "C" EMSCRIPTEN_KEEPALIVE int xray_is_active_c() { return (g_app && g_app->ready && g_app->core.xrayActive()) ? 1 : 0; }
// Section-cut tool: toggle the drop-a-plane mode, clear all planes, query state.
extern "C" EMSCRIPTEN_KEEPALIVE void toggle_section_c() {
if (!g_app || !g_app->ready) return;
g_app->section_tool_active = !g_app->section_tool_active;
Log::info() << "[section] tool "
<< (g_app->section_tool_active ? "active — click a surface to cut" : "off");
g_app->host.requestFrame();
}
extern "C" EMSCRIPTEN_KEEPALIVE void clear_section_c() {
if (g_app && g_app->ready) { g_app->core.clearSectionPlanes(); g_app->host.requestFrame(); }
}
extern "C" EMSCRIPTEN_KEEPALIVE int section_is_active_c() {
return (g_app && g_app->ready && g_app->section_tool_active) ? 1 : 0;
}
// id: 0 Front, 1 Back, 2 Left, 3 Right, 4 Top, 5 Bottom.
extern "C" EMSCRIPTEN_KEEPALIVE void standard_view_c(int id) {
if (!g_app || !g_app->ready || id < 0 || id > 5) return;
+9
View File
@@ -96,6 +96,9 @@
<button data-act="isolate" title="Isolate selected (Shift+H)">Isolate</button>
<button data-act="showall" title="Show all (Alt+H)">Show all</button>
<button data-act="xray" id="xray-btn" title="X-ray — translucent everything (Alt+X)">X-ray</button>
<span class="sep"></span>
<button data-act="section" id="section-btn" title="Section tool — click a surface to cut (K)">Section</button>
<button data-act="clearcut" title="Clear all section cuts (Shift+K)">Clear cuts</button>
</div>
<div id="status">Starting…</div>
<script>
@@ -176,6 +179,10 @@
var xb = document.getElementById('xray-btn');
if (xb) xb.classList.toggle('active', !!Module._xray_is_active_c());
}
if (Module._section_is_active_c) {
var sb = document.getElementById('section-btn');
if (sb) sb.classList.toggle('active', !!Module._section_is_active_c());
}
Module._raf_tick_c(Module._app_ptr);
}
requestAnimationFrame(shellTick);
@@ -364,6 +371,8 @@
else if (act === 'isolate') Module._isolate_selected_c();
else if (act === 'showall') Module._show_all_c();
else if (act === 'xray') Module._toggle_xray_c();
else if (act === 'section') Module._toggle_section_c();
else if (act === 'clearcut') Module._clear_section_c();
else if (view !== null) Module._standard_view_c(parseInt(view, 10));
});
});
+22
View File
@@ -372,3 +372,25 @@ test('RMB marquee drag box-selects (Web preset)', async ({ page }) => {
expect(hiddenAfter, 'marquee was not hidden after release').toBe(true);
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
});
test('section tool: click a surface cuts geometry, clear restores', async ({ page }) => {
const gpuErrors = [];
page.on('console', (m) => { if (/Uncaptured WebGPU error|is invalid/i.test(m.text())) gpuErrors.push(m.text()); });
await ready(page);
const box = await page.locator('#viewer-canvas').boundingBox();
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
const before = await shot(page);
await page.evaluate(() => window.Module._toggle_section_c());
expect(await page.evaluate(() => window.Module._section_is_active_c())).toBe(1);
// Section tool claims LMB: a left click on the model drops a cut.
await page.mouse.click(cx, cy);
await page.waitForTimeout(700); // async surface pick + add plane + render
const cut = await shot(page);
expect(Buffer.compare(before, cut), 'section cut did not change the render').not.toBe(0);
await page.evaluate(() => window.Module._clear_section_c());
await page.waitForTimeout(400);
const cleared = await shot(page);
expect(Buffer.compare(cleared, cut), 'clearing cuts did not change the render').not.toBe(0);
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
});