mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-19 03:33:48 +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([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user