Compare commits

..

3 Commits

Author SHA1 Message Date
dependabot[bot] 4517784a7a build(deps): bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-21 22:45:27 +00:00
Dion Moult 2c1d445d5b ifcviewer-web: mint session model ids when a load is requested
A federated pick could be attributed to the wrong file. The model slot a
host sees — ElementRef::model_index, modelProgress's index — is a rank in
session_model_id order, and on web that id was minted at the END of the
sidecar read chain, after three network round trips. So the ranking was
the order the models' reads happened to finish in, not the order the host
added them. With ~40 similarly-sized models over HTTP, adjacent models
swapped and a click reported its neighbour's file; the host page then
asked for a GUID the file does not contain.

Mint the id at the top of loadSidecarMetadataWeb instead, which runs
synchronously from load_sidecar_from_source_c and therefore in the order
the host asked for its models. A load that fails partway just abandons
its id, and the ranks compact over the surviving models as before.

Positions are still positions, though: if one model fails to load, every
later index shifts down one and a host mapping index into its own list
silently drifts again. So also carry the source id — the handle the host
minted itself when it registered the file — through ElementRef into the
pick payload and getObjects rows, and document it as the way to attribute
an object to a file. ModelGpuData::web_source_id defaults to -1 now, since
0 is a real source id and cannot double as "none".

The test server grows a ?delay=<ms> knob so a test can force the losing
interleaving: georef-a is added first and served slowly, and its objects
must still come back as model 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 11:30:56 +10:00
Dion Moult d86f89090b ifcviewer-web: draw the axis indicator (corner gizmo + orbit pivot)
The desktop viewport draws an RGB triad in the bottom-left corner and a
second one at the orbit target while navigating; the web build drew
neither. Both lived in the Qt-coupled OverlayRenderer, which only
ViewportWindow drives — the web host no-ops the overlay hooks — so the
wasm build had no path to them at all.

Lift them into AxisIndicatorRenderer, a Qt-free renderer in
IfcViewerCore, and drive it from ViewportCore::render for desktop and
web alike. Same move SectionGizmoRenderer already made; the drawing code
is unchanged apart from swapping qDegreesToRadians for CameraMath's kPiF.

Pivot visibility moves to the core with it: it was a QTimer on
ViewportWindow, so the afterglow couldn't follow the gizmo across. It is
now a Stopwatch deadline next to the drawing, with render() requesting
frames until an armed afterglow expires. Hosts keep the same three
triggers (on for orbit/pan drags, off on release, 600 ms on wheel).

The web demo shell's log overlay sat exactly on top of the corner gizmo,
so it shifts right of the 110 px box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 11:16:41 +10:00
22 changed files with 885 additions and 749 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ jobs:
max-size: 5000MB
- name: Set up Python for connector build
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.12'
-9
View File
@@ -109,11 +109,6 @@ if(BUILD_BONSAIVIEWER AND NOT BUILD_BONSAIVIEWER_WGPU)
endif()
option(BUILD_PACKAGE "" OFF)
option(
BUILD_FUZZERS
"Build libFuzzer security-fuzzing harnesses. Requires a Clang toolchain configured with -fsanitize=fuzzer (typically also address,undefined) via CMAKE_CXX_FLAGS."
OFF
)
option(
IFCOPENSHELL_DEPLOY_QT_RUNTIME
"Deploy Qt runtime dependencies for installed Qt applications."
@@ -763,10 +758,6 @@ if(BUILD_BONSAIVIEWER)
add_subdirectory(../src/bonsaiviewer bonsaiviewer)
endif()
if(BUILD_FUZZERS)
add_subdirectory(../src/ifcfuzz ifcfuzz)
endif()
# Cmake uninstall target
if(NOT TARGET uninstall)
configure_file(
-37
View File
@@ -1,37 +0,0 @@
################################################################################
# #
# 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/>. #
# #
################################################################################
# libFuzzer harness(es) for IfcOpenShell. Only built when BUILD_FUZZERS is ON,
# which is expected to be paired with a Clang toolchain configured with
# -fsanitize=fuzzer (and typically also address,undefined) in
# CMAKE_CXX_FLAGS - this target does not add sanitizer flags itself.
include_directories("${CMAKE_SOURCE_DIR}/../src")
add_executable(ifcparse_fuzzer ifcparse_fuzzer.cpp)
target_include_directories(ifcparse_fuzzer PRIVATE "${CMAKE_SOURCE_DIR}/../src")
target_link_libraries(ifcparse_fuzzer PRIVATE IfcParse)
# -fsanitize=fuzzer supplies its own main() and libFuzzer's driver, so it
# must stay scoped to this one executable rather than going in the global
# CMAKE_CXX_FLAGS - every other target (including CMake's own compiler
# checks) would otherwise fail to link. ASan/UBSan, by contrast, are applied
# globally via CMAKE_CXX_FLAGS so that IfcParse itself is instrumented.
target_compile_options(ifcparse_fuzzer PRIVATE -fsanitize=fuzzer)
target_link_options(ifcparse_fuzzer PRIVATE -fsanitize=fuzzer)
-91
View File
@@ -1,91 +0,0 @@
# ifcparse_fuzzer
A libFuzzer harness for `ifcopenshell::file`. It parses fuzzer input entirely
in-memory (no subprocess, no temp files), then walks every parsed instance
and calls `to_string()` on it. Constructing the file already tokenizes,
type-checks, and resolves every attribute of every instance, so
`to_string()` mainly adds coverage of the reserialization/formatting code
path rather than the parser itself.
Disabled by default (`BUILD_FUZZERS=OFF`); building it needs Clang, not GCC.
## Build
libFuzzer (`-fsanitize=fuzzer`) is only implemented by Clang, and it
supplies its own `main()`, so it has to be built in its own directory,
separate from any normal GCC build of IfcOpenShell - putting
`-fsanitize=fuzzer` in the global flags would break every other target,
including CMake's own compiler check. That's why `BUILD_FUZZERS` only adds
`-fsanitize=fuzzer` to this one target (see `CMakeLists.txt`); ASan/UBSan
are applied globally instead, so that `IfcParse` itself is instrumented.
```bash
mkdir -p build-fuzz && cd build-fuzz
cmake ../cmake \
-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all -g -O1 -fno-omit-frame-pointer" \
-DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all -g -O1 -fno-omit-frame-pointer" \
-DBUILD_FUZZERS=ON -DMINIMAL_BUILD=ON \
-DBUILD_IFCGEOM=OFF -DBUILD_CONVERT=OFF -DWITH_OPENCASCADE=OFF \
-DSCHEMA_VERSIONS="2x3;4;4x3_add2"
cmake --build . --target ifcparse_fuzzer -- -j$(nproc)
```
`-fno-sanitize-recover=all` matters: without it, most UBSan checks just log
and continue rather than aborting, so a fuzzing session would run straight
past real bugs without ever capturing them as a crash artifact.
`-DBUILD_IFCGEOM=OFF -DBUILD_CONVERT=OFF -DWITH_OPENCASCADE=OFF
-DMINIMAL_BUILD=ON` keep the build scoped to `IfcParse` (the code this
harness actually exercises) so it doesn't also have to compile/instrument
OpenCASCADE-dependent geometry code.
## Run
```bash
mkdir -p corpus # or point at your own seed corpus of .ifc files
./src/ifcfuzz/run.sh corpus/
```
`run.sh` just sets sane sanitizer defaults and execs the binary - any
libFuzzer flag can be passed through, e.g. `-jobs=4 -workers=4` for
parallel fuzzing, or `-runs=0 <file>` to run once against a specific input.
No seed corpus or dictionary ships in this repo. Any small set of valid and
invalid `.ifc` files works as a starting corpus; a dictionary of STEP/IFC
tokens (`ISO-10303-21`, `HEADER`, common `IFCxxx` entity names, etc.) passed
via `-dict=` measurably helps the mutator get past the header boilerplate.
### Log output
`Logger` output is only wired up when the binary is run against an explicit
file argument (e.g. `-runs=1 <file>`), not during a real campaign against a
corpus directory - logging every parse warning on every execution of a
fuzzing campaign would dominate the runtime. Repro runs print
`[Warning]`/`[Error]` messages to stderr.
### Leak detection
`run.sh` sets `ASAN_OPTIONS=detect_leaks=0` by default. A leak that used to
fire on almost any malformed header (`IfcSpfLexer` allocated in
`in_memory_file_storage::read_from_stream`, not freed if header parsing
returned early or threw) has been fixed, but a second, narrower leak
remains in entity attribute parsing when a syntactically valid header is
followed by malformed entity data. libFuzzer treats a detected leak like a
crash and halts the *entire* session on the first occurrence, so leak
detection stays off by default until that one's fixed too. Run a separate,
short, deliberate pass with `ASAN_OPTIONS=detect_leaks=1` instead if you're
specifically hunting for leaks.
## Minimizing and deduplicating crashes
Not covered by `run.sh` - use libFuzzer's own flags directly:
```bash
./ifcparse_fuzzer -minimize_crash=1 -max_total_time=60 -exact_artifact_path=minimized crash-input
```
Sanitizer reports for two different bugs can look identical at a glance
(same `SUMMARY` line) if the bug is a duplicated code pattern hit from
multiple call sites - check the full symbolized stack trace, not just the
summary, before assuming two crashes are the same bug.
-89
View File
@@ -1,89 +0,0 @@
/********************************************************************************
* *
* 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/>. *
* *
********************************************************************************/
// libFuzzer entry point for ifcopenshell::file. Parses the input entirely
// in-memory (no subprocess, no temp files) so a coverage-guided fuzzer can
// reach the tokenizer and argument parser directly instead of only ever
// observing IfcConvert's exit code.
#include "ifcparse/file.h"
#include "ifcparse/logger.h"
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <limits>
#include <sstream>
#include <sys/stat.h>
namespace {
bool is_regular_file(const char* path) {
struct stat st;
return ::stat(path, &st) == 0 && S_ISREG(st.st_mode);
}
} // namespace
// libFuzzer runs in two modes: a real fuzzing campaign (given corpus
// directories to mutate from, executed millions of times) and a
// single-input repro (given one or more explicit file paths, e.g.
// `-runs=1 crashes/<hash>/input`). logger::set_output is only wired up for
// the latter -- logging every parse warning to a stream on every execution
// of a real campaign would dominate the runtime.
extern "C" int LLVMFuzzerInitialize(int* argc, char*** argv) {
for (int i = 1; i < *argc; ++i) {
if (is_regular_file((*argv)[i])) {
ifcopenshell::logger::root().set_output(&std::cerr, &std::cerr);
break;
}
}
return 0;
}
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
if (size == 0 || size > static_cast<size_t>(std::numeric_limits<int>::max())) {
return 0;
}
try {
ifcopenshell::file ifc_file(const_cast<void*>(static_cast<const void*>(data)), static_cast<int>(size));
if (ifc_file.good()) {
// Constructing the file already tokenizes and type-checks every
// attribute of every instance (and resolves references), so
// most tokenizer/argument bugs are reachable without going any
// further. to_string() is still exercised here since
// reserialization walks a different code path and may surface
// additional faults.
std::ostringstream discard;
for (const auto& entity : ifc_file) {
try {
entity.second.to_string(discard);
} catch (const std::exception&) {
// Malformed attributes are expected on fuzzed input.
}
}
}
} catch (const std::exception&) {
// IfcException (and friends) is expected control flow for malformed
// input, not a bug. Only crashes caught by ASan/UBSan/libFuzzer
// itself - which bypass try/catch - are findings.
}
return 0;
}
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env bash
# Minimal runner for ifcparse_fuzzer. See README.md for build instructions
# and an explanation of the ASan/UBSan options set below.
#
# Usage: run.sh [libFuzzer args...]
# FUZZER_BIN=./build-fuzz/ifcfuzz/ifcparse_fuzzer ./run.sh corpus/
#
# Env overrides:
# FUZZER_BIN path to the built harness (default: ./build-fuzz/ifcfuzz/ifcparse_fuzzer)
# SYMBOLIZER path to llvm-symbolizer, if not already on PATH
set -euo pipefail
BIN="${FUZZER_BIN:-./build-fuzz/ifcfuzz/ifcparse_fuzzer}"
if [ ! -x "$BIN" ]; then
echo "error: fuzzer binary not found or not executable: $BIN" >&2
echo "build it first - see README.md in this directory" >&2
exit 1
fi
SYM_OPT=""
if [ -n "${SYMBOLIZER:-}" ] && [ -x "$SYMBOLIZER" ]; then
SYM_OPT=":external_symbolizer_path=$SYMBOLIZER"
fi
# detect_leaks defaults OFF: entity attribute parsing can still leak when
# malformed entity data follows a syntactically valid header (a narrower
# case than the old header-parse leak, which has been fixed). libFuzzer
# treats a leak like a crash and halts the whole session on the first one,
# so leak detection needs to run as a separate, deliberate, short pass
# instead of the default campaign mode.
export ASAN_OPTIONS="${ASAN_OPTIONS:-abort_on_error=1:symbolize=1:detect_leaks=0$SYM_OPT}"
export UBSAN_OPTIONS="${UBSAN_OPTIONS:-abort_on_error=1:print_stacktrace=1:symbolize=1$SYM_OPT}"
exec "$BIN" "$@"
+13 -1
View File
@@ -253,6 +253,10 @@ EM_BOOL onMouseDown(int, const EmscriptenMouseEvent* e, void* user) {
app->nav_drag_px = 0.0f;
app->down_x = e->targetX; // canvas-relative CSS px
app->down_y = e->targetY;
// Show the pivot triad for the duration of an orbit / pan drag, so
// it's visible what the camera turns around (matches the desktop).
if (kind == NavKind::Orbit || kind == NavKind::Pan)
app->core.setPivotIndicatorVisible(true);
}
return EM_TRUE;
}
@@ -297,6 +301,10 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) {
const NavKind kind = app->nav_kind;
app->nav_active = false;
app->nav_kind = NavKind::None;
// Drag is over — hide the pivot indicator without afterglow. Only for the
// gesture that raised it; a stray mouseup must not cut a wheel afterglow.
if (was_active && (kind == NavKind::Orbit || kind == NavKind::Pan))
app->core.setPivotIndicatorVisible(false);
// End a section-gizmo drag (took over the press; no pick/orbit on release).
if (app->section_dragging) {
@@ -351,7 +359,7 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) {
if (id != 0) {
app->core.logSelectedObjectGuidWeb(id);
} else if (!add && !remove) {
EM_ASM({ if (Module.__ifcvOnSelect) Module.__ifcvOnSelect(0, '', -1); });
EM_ASM({ if (Module.__ifcvOnSelect) Module.__ifcvOnSelect(0, '', -1, -1); });
}
app->host.requestFrame();
});
@@ -373,6 +381,9 @@ EM_BOOL onWheel(int, const EmscriptenWheelEvent* e, void* user) {
// In fly mode the wheel tunes move speed (Blender convention), not zoom.
if (app->fly_mode) { app->core.flyAdjustSpeed(-float(dy) / 100.0f); return EM_TRUE; }
app->core.dollyBy(-float(dy) / 100.0f);
// Pivot afterglow on wheel — visible for 600 ms so the user can see what
// they're zooming around without holding a drag.
app->core.setPivotIndicatorVisible(true, 600);
return EM_TRUE; // consume so the page doesn't scroll
}
@@ -824,6 +835,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_request_objects_c(int token) {
first = false;
json += "{\"objectId\":" + std::to_string(e.object_id)
+ ",\"model\":" + std::to_string(e.model_index)
+ ",\"sourceId\":" + std::to_string(e.source_id)
+ ",\"guid\":" + jsonString(e.guid)
+ ",\"name\":" + jsonString(e.name)
+ ",\"type\":" + jsonString(e.type) + '}';
+99
View File
@@ -0,0 +1,99 @@
// This file was generated with the assistance of an AI coding tool.
//
// The RGB axis indicator, in both of its guises: the corner gizmo that sits
// in the viewport's bottom-left, and the pivot triad that appears at the
// orbit target while a navigation drag is running. Both are drawn by the
// shared AxisIndicatorRenderer from ViewportCore, so a regression here would
// most likely be a wiring one — the renderer never inited, the pivot gate
// never set, the corner pass encoded before the surface resolved — none of
// which any other test in the suite would notice.
import { test, expect } from '@playwright/test';
import zlib from 'node:zlib';
// Decode the top-left pixel (RGB) of a PNG buffer. Row 0 pixel 0 is
// filter-agnostic — every PNG predictor references zero neighbours there —
// so this can skip filter handling entirely.
function firstPixelRGB(png) {
let off = 8;
const idat = [];
while (off + 8 <= png.length) {
const len = png.readUInt32BE(off);
const type = png.toString('ascii', off + 4, off + 8);
const data = png.subarray(off + 8, off + 8 + len);
if (type === 'IDAT') idat.push(data);
else if (type === 'IEND') break;
off += 12 + len;
}
const raw = zlib.inflateSync(Buffer.concat(idat));
return [raw[1], raw[2], raw[3]]; // skip the row filter byte
}
// Is this pixel on the +Z arm? Its colour is Bonsai's decorator blue
// (0.157, 0.565, 1.000), so blue leads red by a mile. Everything it can be
// drawn over stays well under the threshold: the background is a near-grey
// (32, 35, 41), the sample model is white, and even the dim x-ray pass —
// 0.3 alpha where the arm is behind geometry — lands around (191, 222, 255).
const isAxisBlue = ([r, , b]) => b - r > 30;
// Sample 1x1 pixels straight up from (cx, cy), which is where the +Z arm
// points at the default camera pitch. Stepping rather than picking one exact
// pixel keeps this off the anti-aliased edges of a 2.5 px line.
async function scanUp(page, cx, cy, from, to, step = 4) {
const hits = [];
for (let dy = from; dy <= to; dy += step) {
const png = await page.screenshot({
clip: { x: Math.round(cx), y: Math.round(cy - dy), width: 1, height: 1 },
});
hits.push(firstPixelRGB(png));
}
return hits;
}
async function boot(page) {
await page.goto('/IfcViewerWeb.html');
await page.waitForFunction(
() => !!(window.Module && window.Module._app_ptr), null, { timeout: 30_000 });
await page.waitForTimeout(1200);
return page.locator('#viewer-canvas').boundingBox();
}
test('corner axis gizmo draws in the bottom-left', async ({ page }) => {
const box = await boot(page);
// Gizmo box: 110 CSS px square, 10 px in from the bottom-left corner. The
// +Z arm runs up from its centre for ~39 px (arm 1.0 in a 1.4 half-extent
// ortho, over a 55 px half-box).
const cx = box.x + 10 + 55;
const cy = box.y + box.height - 10 - 55;
const hits = await scanUp(page, cx, cy, 10, 34);
expect(
hits.some(isAxisBlue),
`no +Z arm above the gizmo centre — corner axis missing (sampled ${JSON.stringify(hits)})`,
).toBe(true);
});
test('pivot triad shows during an orbit drag and clears on release', async ({ page }) => {
const box = await boot(page);
// The orbit target projects to the viewport centre, and the pivot arms are
// 30 CSS px, so the +Z arm runs up from there.
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
const before = await scanUp(page, cx, cy, 8, 26);
expect(before.some(isAxisBlue), 'pivot visible before any drag').toBe(false);
await page.mouse.move(cx, cy);
await page.mouse.down();
await page.mouse.move(cx + 90, cy + 30, { steps: 8 });
await page.waitForTimeout(200);
const during = await scanUp(page, cx, cy, 8, 26);
await page.mouse.up();
expect(
during.some(isAxisBlue),
`no pivot triad mid-drag (sampled ${JSON.stringify(during)})`,
).toBe(true);
// Released without afterglow — the indicator goes on the next frame.
await page.waitForTimeout(400);
const after = await scanUp(page, cx, cy, 8, 26);
expect(after.some(isAxisBlue), 'pivot triad still up after mouse release').toBe(false);
});
@@ -0,0 +1,84 @@
import { test, expect } from '@playwright/test';
// Which file did this object come from? Every host page answers that by taking
// the `model` index the viewer reports and looking it up in its own list of
// models, in the order it added them — the mapping the API documents. The
// index is only worth anything if it survives federated models finishing their
// loads out of order, which is exactly what happens over a real network.
//
// The two georef fixtures carry fixed GUIDs, so an object can be attributed to
// its file here without trusting the very index under test.
const GUIDS = {
'georef-a': ['13r0IXtWf5pf18Q1EGzHXl', '22CLYZYiz8ZhbpLaYDVIu6'],
'georef-b': ['3DkP2KRu5AIRxhhAz$DcQH', '2ueyz_jIr2QgMKs4v0fWl2'],
};
test('model index follows add order when the first model loads last', async ({ page }) => {
const errors = [];
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
await page.goto('/scripting.html');
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
{ timeout: 30_000 });
// georef-a is added first but served slowly, so every one of its range reads
// lands after georef-b's. Without a stable ordering the core hands out its
// load-order slots in completion order and the two models come back swapped.
const sourceIds = await page.evaluate(async () => {
const a = await window.viewer.addUrl('/georef-a.ifcview?delay=120', { replace: true });
const b = await window.viewer.addUrl('/georef-b.ifcview');
return [a, b];
});
expect(sourceIds[0]).toBeLessThan(sourceIds[1]);
await page.waitForFunction(() => window.viewer.modelCount() === 2, null, { timeout: 30_000 });
const objects = await page.evaluate(() => window.viewer.getObjects());
const rowFor = (guid) => objects.find((o) => o.guid === guid) || {};
for (const guid of GUIDS['georef-a']) {
expect(rowFor(guid).model, `${guid} belongs to georef-a, added first`).toBe(0);
expect(rowFor(guid).sourceId, `${guid} came from georef-a's source`).toBe(sourceIds[0]);
}
for (const guid of GUIDS['georef-b']) {
expect(rowFor(guid).model, `${guid} belongs to georef-b, added second`).toBe(1);
expect(rowFor(guid).sourceId, `${guid} came from georef-b's source`).toBe(sourceIds[1]);
}
expect(errors, errors.join('\n')).toEqual([]);
});
test('a pick reports the source the model was added from', async ({ page }) => {
const errors = [];
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
await page.goto('/scripting.html');
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
{ timeout: 30_000 });
await page.evaluate(async () => {
await window.viewer.addUrl('/georef-a.ifcview?delay=120', { replace: true });
await window.viewer.addUrl('/georef-b.ifcview');
});
await page.waitForFunction(() => window.viewer.modelCount() === 2, null, { timeout: 30_000 });
// The pick payload is built from the element table, so make sure it is
// resident and take the same table to check the answer against.
const objects = await page.evaluate(() => window.viewer.getObjects());
await page.evaluate(() => window.viewer.viewAll());
await page.waitForTimeout(800);
// Whichever box the click lands on is fine — what is under test is that the
// pick and the object table agree about which file the object came from.
await page.evaluate(() => {
window.__pick = new Promise((resolve) => window.viewer.onSelect(resolve));
});
const box = await page.locator('#viewer-canvas').boundingBox();
// Web preset: RMB selects (LMB orbits).
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' });
const detail = await page.evaluate(() => window.__pick);
expect(detail.guid, 'click hit empty space').toBeTruthy();
const row = objects.find((o) => o.guid === detail.guid);
expect(row, 'picked a GUID that is not in the object table').toBeTruthy();
expect(detail.sourceId, 'pick and object table disagree on the source').toBe(row.sourceId);
expect(detail.modelIndex).toBe(row.model);
expect(detail.sourceId).not.toBeNull();
expect(errors, errors.join('\n')).toEqual([]);
});
+6
View File
@@ -31,6 +31,12 @@ http.createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://localhost:${PORT}`);
let p = decodeURIComponent(url.pathname);
// ?delay=<ms> stalls every response for this URL, HEAD and Range alike.
// Load order across federated models is decided by whichever model's
// async read chain finishes first, so a test that wants a specific
// interleaving has to be able to make one source slower than another.
const delay = Number(url.searchParams.get('delay') || 0);
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
if (p === '/') p = '/IfcViewerWeb.html';
const inRoot = path.join(ROOT, p);
const inSrc = path.join(SRC, p);
+4 -2
View File
@@ -12,8 +12,10 @@
eats pointer events so the drag keeps reaching the canvas. */
#marquee { position: fixed; display: none; z-index: 50; pointer-events: none;
border: 1px solid #4a9eff; background: rgba(74, 158, 255, 0.15); }
/* Log overlay sits bottom-left and never eats pointer events. */
#status { position: fixed; bottom: 8px; left: 12px;
/* Log overlay sits bottom-left and never eats pointer events. Kept clear
of the corner axis gizmo, which the viewport draws in the bottom-left
110 CSS px (plus a 10 px margin). */
#status { position: fixed; bottom: 8px; left: 132px;
max-width: min(60vw, 680px); max-height: 28vh; overflow-y: auto;
font-size: 11px;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
+25 -12
View File
@@ -11,7 +11,7 @@
// await viewer.addFile(file, { replace: true });
// await viewer.addUrl('/model.ifcview'); // appends (federation)
//
// const objects = await viewer.getObjects(); // [{objectId, guid, name, type, model}]
// const objects = await viewer.getObjects(); // [{objectId, guid, name, type, model, sourceId}]
// viewer.setSelection(['3vB2YO$MX4xv5uCqZZG05x']);
// viewer.setColor(objects.filter(o => o.type === 'IfcWall'), '#ff8800');
// viewer.setCamera({ yaw: 45, pitch: 30 });
@@ -21,6 +21,14 @@
// The canvas element MUST have id="viewer-canvas" — the wasm side hard-codes
// that selector for its WebGPU surface and input handlers.
//
// Model identity. addFile/addUrl return a source id: the handle for that model,
// minted the moment it is registered and stable for the session. Objects come
// back tagged with both their `sourceId` and a `model` index (the model's slot
// in load order). Map an object to the file it came from through the source id
// — the index is a POSITION, so it shifts down if an earlier model fails to
// load, and a host keying its own list off it then attributes objects to the
// wrong file.
//
// Object identity. Everything the scripting API takes or returns is keyed by
// `objectId`: a u32 the renderer assigns, unique across the federation but only
// meaningful for this session. IFC GlobalIds are the stable identity, and every
@@ -197,15 +205,17 @@
// a remote URL (HTTP Range). load_sidecar_from_source_c(sid) streams one.
Module.__ifcvSources = Module.__ifcvSources || [];
// The wasm calls this on every single-object pick; (0, '', -1) means the
// The wasm calls this on every single-object pick; (0, '', -1, -1) means the
// selection was cleared. modelIndex is the picked object's model in load
// order (matches the modelProgress index), or -1. A marquee box-select does
// NOT fire this (it has no single object) — use onSelectionChange for that.
Module.__ifcvOnSelect = function (objectId, guid, modelIndex) {
// order (matches the modelProgress index) and sourceId the source it was
// added from, either null when unknown. A marquee box-select does NOT fire
// this (it has no single object) — use onSelectionChange for that.
Module.__ifcvOnSelect = function (objectId, guid, modelIndex, sourceId) {
const detail = {
objectId: objectId >>> 0,
guid: guid || null,
modelIndex: (typeof modelIndex === 'number' && modelIndex >= 0) ? modelIndex : null,
sourceId: (typeof sourceId === 'number' && sourceId >= 0) ? sourceId : null,
};
selectListeners.forEach(function (cb) {
try { cb(detail); } catch (e) { console.error(e); }
@@ -279,8 +289,8 @@
// ---- Events ----------------------------------------------------------
// Single-object picks (click). Fires with {objectId, guid, modelIndex}.
// Returns an unsubscribe function.
// Single-object picks (click). Fires with
// {objectId, guid, modelIndex, sourceId}. Returns an unsubscribe function.
onSelect: function (cb) {
selectListeners.push(cb);
return function () {
@@ -421,11 +431,14 @@
// ---- Objects ---------------------------------------------------------
// Every object in the scene: [{objectId, guid, name, type, model}], where
// `model` is the index into the load-ordered model list (same index as
// modelProgress). Asynchronous — the element tables are fetched lazily per
// model so first paint never waits on them. Resolving this is also what
// lets every other call accept GlobalIds; the result is cached for that.
// Every object in the scene:
// [{objectId, guid, name, type, model, sourceId}], where `model` is the
// index into the load-ordered model list (same index as modelProgress)
// and `sourceId` the source the model was added from — see the model
// identity note at the top of the file. Asynchronous — the element tables
// are fetched lazily per model so first paint never waits on them.
// Resolving this is also what lets every other call accept GlobalIds; the
// result is cached for that.
getObjects: function () {
const token = ++objectsToken;
return new Promise(function (resolve) {
+423
View File
@@ -0,0 +1,423 @@
/********************************************************************************
* *
* 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 "AxisIndicatorRenderer.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <string>
#include "CameraMath.h"
namespace {
constexpr uint32_t kAxisUniformSlot = 256; // dynamic-offset slot stride
constexpr uint32_t kAxisVertexCount = 18; // 3 arms x 2 triangles x 3 verts
// Uniform slots in the shared buffer.
constexpr uint32_t kSlotCorner = 0;
constexpr uint32_t kSlotPivot = 1;
constexpr uint32_t kSlotPivotXray = 2;
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 the other overlays) + the
// axis vertex shader. Each arm is expanded to a screen-space-thick,
// anti-aliased quad.
static const std::string AXIS_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 AxisUniforms {
mvp: mat4x4<f32>,
origin: vec3<f32>,
arm: f32,
alpha: f32,
line_width_px: f32,
viewport_size: vec2<f32>,
};
@group(0) @binding(0) var<uniform> u: AxisUniforms;
@vertex
fn vs_main(@location(0) start: vec3<f32>,
@location(1) end: vec3<f32>,
@location(2) col: vec3<f32>,
@location(3) t: f32,
@location(4) side: f32) -> VsOut {
let p_start = u.mvp * vec4<f32>(u.origin + start * u.arm, 1.0);
let p_end = u.mvp * vec4<f32>(u.origin + end * u.arm, 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.alpha);
out.side_t = side;
return out;
}
)WGSL");
// Pack the axis uniform's 256-byte slot. Layout matches WGSL AxisUniforms:
// mat4 + vec3 + f32 + f32 + f32 + vec2 = 96 B used, padded to 256.
void packAxisUniform(uint8_t* dst,
const Eigen::Matrix4f& mvp, const Eigen::Vector3f& origin,
float arm, float alpha, float line_width_px,
float viewport_w, float viewport_h) {
std::memset(dst, 0, kAxisUniformSlot);
std::memcpy(dst, mvp.data(), 16 * sizeof(float));
float ox = origin.x(), oy = origin.y(), oz = origin.z();
std::memcpy(dst + 64, &ox, sizeof(float));
std::memcpy(dst + 68, &oy, sizeof(float));
std::memcpy(dst + 72, &oz, sizeof(float));
std::memcpy(dst + 76, &arm, sizeof(float));
std::memcpy(dst + 80, &alpha, sizeof(float));
std::memcpy(dst + 84, &line_width_px, sizeof(float));
std::memcpy(dst + 88, &viewport_w, sizeof(float));
std::memcpy(dst + 92, &viewport_h, sizeof(float));
}
} // namespace
AxisIndicatorRenderer::~AxisIndicatorRenderer() { destroy(); }
bool AxisIndicatorRenderer::init(WGPUDevice device, WGPUQueue queue,
WGPUTextureFormat color_format, int sample_count) {
device_ = device;
queue_ = queue;
if (!device_ || !queue_) return false;
// Bonsai decorator palette (src/bonsai/bonsai/bim/ui.py:593+):
// decorator_color_error = (1.000, 0.200, 0.322) — red → +X
// decorator_color_selected = (0.545, 0.863, 0.000) — green → +Y
// decorator_color_special = (0.157, 0.565, 1.000) — blue → +Z
// Same palette is reused for the section gizmo + marquee so all overlay
// colours come from one canonical source.
static const float axis_verts[] = {
// start end color (RGB — Bonsai decorators) t side
// ---- +X red ----
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, +1.f,
// ---- +Y green ----
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, +1.f,
// ---- +Z blue ----
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, +1.f,
};
WGPUBufferDescriptor vb = {};
vb.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
vb.size = sizeof(axis_verts);
vb.label = svFromCStr("ifcviewer-wgpu.axis_vbo");
vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &vb);
wgpuQueueWriteBuffer(queue_, vertex_buffer_, 0, axis_verts, sizeof(axis_verts));
WGPUBufferDescriptor ub = {};
ub.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
ub.size = 3u * kAxisUniformSlot;
ub.label = svFromCStr("ifcviewer-wgpu.axis_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 = 96;
WGPUBindGroupLayoutDescriptor bgl_desc = {};
bgl_desc.entryCount = 1;
bgl_desc.entries = &ble;
bgl_desc.label = svFromCStr("ifcviewer-wgpu.axis_bgl");
bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
WGPUPipelineLayoutDescriptor pl_desc = {};
pl_desc.bindGroupLayoutCount = 1;
pl_desc.bindGroupLayouts = &bgl_;
pl_desc.label = svFromCStr("ifcviewer-wgpu.axis_pipeline_layout");
layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
WGPUBindGroupEntry bge = {};
bge.binding = 0;
bge.buffer = uniform_buffer_;
bge.offset = 0;
bge.size = kAxisUniformSlot;
WGPUBindGroupDescriptor bg_desc = {};
bg_desc.layout = bgl_;
bg_desc.entryCount = 1;
bg_desc.entries = &bge;
bg_desc.label = svFromCStr("ifcviewer-wgpu.axis_bind_group");
bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
WGPUShaderSourceWGSL wgsl = {};
wgsl.chain.sType = WGPUSType_ShaderSourceWGSL;
wgsl.code = svFromCStr(AXIS_WGSL.c_str());
WGPUShaderModuleDescriptor sm_desc = {};
sm_desc.nextInChain = &wgsl.chain;
sm_desc.label = svFromCStr("ifcviewer-wgpu.axis_wgsl");
shader_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
// Vertex layout: start vec3, end 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;
// Pivot: inside the main MSAA pass, depth-tested against the scene but
// never writing depth. Two passes — LessEqual for the visible part,
// GreaterEqual for the dim x-ray showing through geometry.
auto build_pivot = [&](WGPUCompareFunction cmp, const char* label,
WGPURenderPipeline& out) {
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;
WGPUDepthStencilState depth = {};
depth.format = WGPUTextureFormat_Depth32Float;
depth.depthWriteEnabled = WGPUOptionalBool_False;
depth.depthCompare = cmp;
depth.stencilFront.compare = WGPUCompareFunction_Always;
depth.stencilBack.compare = WGPUCompareFunction_Always;
WGPURenderPipelineDescriptor rp = {};
rp.layout = layout_;
rp.label = svFromCStr(label);
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;
out = wgpuDeviceCreateRenderPipeline(device_, &rp);
};
build_pivot(WGPUCompareFunction_LessEqual,
"ifcviewer-wgpu.axis_pivot_pipeline", pivot_pipeline_);
build_pivot(WGPUCompareFunction_GreaterEqual,
"ifcviewer-wgpu.axis_pivot_xray_pipeline", pivot_xray_pipeline_);
// Corner: resolved surface, no depth, sampleCount=1.
{
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;
WGPURenderPipelineDescriptor rp = {};
rp.layout = layout_;
rp.label = svFromCStr("ifcviewer-wgpu.axis_corner_pipeline");
rp.vertex.module = shader_;
rp.vertex.entryPoint = svFromCStr("vs_main");
rp.vertex.bufferCount = 1;
rp.vertex.buffers = &vbl;
rp.fragment = &frag;
rp.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp.primitive.cullMode = WGPUCullMode_None;
rp.multisample.count = 1;
rp.multisample.mask = 0xFFFFFFFFu;
corner_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp);
}
return pivot_pipeline_ && pivot_xray_pipeline_ && corner_pipeline_;
}
void AxisIndicatorRenderer::encodePivot(WGPURenderPassEncoder pass,
const OverlayFrame& f, bool visible) {
if (!visible || !pivot_pipeline_ || !pivot_xray_pipeline_) return;
if (f.viewport_h_px <= 0) return;
// Arm length = 30 logical px projected into world at the pivot's distance.
const float fovy_rad = f.camera_fov_y_deg * kPiF / 180.0f;
const float world_per_pixel = f.camera_distance * std::tan(fovy_rad * 0.5f)
* 2.0f / float(f.viewport_h_px);
const float arm_pixels = 30.0f * float(f.device_pixel_ratio);
const float arm_world = arm_pixels * world_per_pixel;
const float dpr = float(f.device_pixel_ratio);
const float line_w = 2.5f * dpr;
const float vw = float(f.viewport_w_px);
const float vh = float(f.viewport_h_px);
uint8_t slot_visible[kAxisUniformSlot];
uint8_t slot_xray[kAxisUniformSlot];
packAxisUniform(slot_visible, f.view_proj, f.camera_target, arm_world,
1.00f, line_w, vw, vh);
packAxisUniform(slot_xray, f.view_proj, f.camera_target, arm_world,
0.30f, line_w, vw, vh);
const uint32_t visible_off = kSlotPivot * kAxisUniformSlot;
const uint32_t xray_off = kSlotPivotXray * kAxisUniformSlot;
wgpuQueueWriteBuffer(queue_, uniform_buffer_, visible_off,
slot_visible, sizeof(slot_visible));
wgpuQueueWriteBuffer(queue_, uniform_buffer_, xray_off,
slot_xray, sizeof(slot_xray));
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, vertex_buffer_, 0, WGPU_WHOLE_SIZE);
wgpuRenderPassEncoderSetPipeline(pass, pivot_xray_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &xray_off);
wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
wgpuRenderPassEncoderSetPipeline(pass, pivot_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &visible_off);
wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
}
void AxisIndicatorRenderer::encodeCornerAxis(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& f) {
if (!corner_pipeline_ || !surface_view) return;
const int dpr = std::max(1, f.device_pixel_ratio);
const uint32_t gizmo_size = uint32_t(110 * dpr);
const uint32_t margin = uint32_t(10 * dpr);
if (gizmo_size == 0 || f.viewport_w_px <= 0 || f.viewport_h_px <= 0) return;
// Bottom-left in WebGPU framebuffer space (y down).
const uint32_t fb_h = uint32_t(f.viewport_h_px);
if (gizmo_size + margin > fb_h) return;
const uint32_t y = fb_h - margin - gizmo_size;
// Independent ortho projection from the camera's direction. Near the
// poles the up axis collapses against the look direction, so swap to
// Y-up there — mirrors buildViewProj's identical fix on the viewport.
const float yaw_rad = f.camera_yaw_deg * kPiF / 180.0f;
const float pitch_rad = f.camera_pitch_deg * kPiF / 180.0f;
const Eigen::Vector3f eye_dir(std::cos(pitch_rad) * std::cos(yaw_rad),
std::cos(pitch_rad) * std::sin(yaw_rad),
std::sin(pitch_rad));
const Eigen::Vector3f world_up = (std::abs(f.camera_pitch_deg) >= 89.0f)
? Eigen::Vector3f(0.0f, 1.0f, 0.0f)
: Eigen::Vector3f(0.0f, 0.0f, 1.0f);
const Eigen::Matrix4f gv = lookAtRH(eye_dir * 3.0f, Eigen::Vector3f::Zero(), world_up);
const Eigen::Matrix4f gp = orthoGL(-1.4f, 1.4f, -1.4f, 1.4f, 0.1f, 10.0f);
Eigen::Matrix4f z_remap = Eigen::Matrix4f::Identity();
z_remap(2, 2) = 0.5f;
z_remap(2, 3) = 0.5f;
const Eigen::Matrix4f mvp = z_remap * gp * gv;
uint8_t slot[kAxisUniformSlot];
const float line_w = 2.5f * float(dpr);
packAxisUniform(slot, mvp, Eigen::Vector3f(0, 0, 0), 1.0f, 1.0f, line_w,
float(gizmo_size), float(gizmo_size));
const uint32_t slot_offset = kSlotCorner * kAxisUniformSlot;
wgpuQueueWriteBuffer(queue_, uniform_buffer_, slot_offset, slot, sizeof(slot));
WGPURenderPassColorAttachment color = {};
color.view = surface_view;
color.loadOp = WGPULoadOp_Load;
color.storeOp = WGPUStoreOp_Store;
color.clearValue = { 0.0, 0.0, 0.0, 1.0 };
color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
WGPURenderPassDescriptor pass_desc = {};
pass_desc.colorAttachmentCount = 1;
pass_desc.colorAttachments = &color;
pass_desc.label = svFromCStr("ifcviewer-wgpu.corner_axis_pass");
WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
wgpuRenderPassEncoderSetViewport(pass, float(margin), float(y),
float(gizmo_size), float(gizmo_size),
0.0f, 1.0f);
wgpuRenderPassEncoderSetPipeline(pass, corner_pipeline_);
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, vertex_buffer_, 0, WGPU_WHOLE_SIZE);
wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &slot_offset);
wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
wgpuRenderPassEncoderEnd(pass);
wgpuRenderPassEncoderRelease(pass);
}
void AxisIndicatorRenderer::destroy() {
if (pivot_pipeline_) { wgpuRenderPipelineRelease(pivot_pipeline_); pivot_pipeline_ = nullptr; }
if (pivot_xray_pipeline_) { wgpuRenderPipelineRelease(pivot_xray_pipeline_); pivot_xray_pipeline_ = nullptr; }
if (corner_pipeline_) { wgpuRenderPipelineRelease(corner_pipeline_); corner_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; }
}
+84
View File
@@ -0,0 +1,84 @@
/********************************************************************************
* *
* 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 AXISINDICATORRENDERER_H
#define AXISINDICATORRENDERER_H
#include <webgpu/webgpu.h>
#include <Eigen/Dense>
#include "OverlayFrame.h"
// Qt-free renderer for the RGB axis indicator, in its two guises:
//
// - the corner gizmo: a fixed 110x110 px triad in the viewport's
// bottom-left corner, drawn on the resolved surface with its own ortho
// projection so only the camera's direction moves it;
// - the pivot indicator: the same triad drawn in world space at the orbit
// target while the user is navigating, depth-tested against the scene
// with a dim x-ray pass behind it.
//
// Lifted out of the Qt-coupled OverlayRenderer so BOTH the desktop and web
// builds draw one identical indicator from a single place (ViewportCore::render
// calls it on both) — same move SectionGizmoRenderer made.
class AxisIndicatorRenderer {
public:
AxisIndicatorRenderer() = default;
~AxisIndicatorRenderer();
AxisIndicatorRenderer(const AxisIndicatorRenderer&) = delete;
AxisIndicatorRenderer& operator=(const AxisIndicatorRenderer&) = delete;
// Create the shared triad VBO, the uniform buffer (three dynamic-offset
// slots: corner / pivot / pivot-xray), and the three pipelines.
// `color_format` is the render target's format; `sample_count` the MSAA
// count of the main pass the pivot draws into (the corner gizmo always
// targets the resolved, single-sampled surface). 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 corner_pipeline_ != nullptr; }
// Orbit pivot indicator, drawn into the already-open main MSAA pass so it
// shares depth with the scene. `visible` is the viewport's UI gate (orbit /
// pan drag, wheel-zoom afterglow); when false this is a cheap no-op.
void encodePivot(WGPURenderPassEncoder pass, const OverlayFrame& f,
bool visible);
// Corner axis gizmo (bottom-left, 110x110 px). Opens its own load-op pass
// on the resolved surface, so it must run after the main pass has resolved.
void encodeCornerAxis(WGPUCommandEncoder enc, WGPUTextureView surface_view,
const OverlayFrame& f);
private:
WGPUDevice device_ = nullptr;
WGPUQueue queue_ = nullptr;
WGPUShaderModule shader_ = nullptr;
WGPUBindGroupLayout bgl_ = nullptr;
WGPUPipelineLayout layout_ = nullptr;
WGPUBindGroup bind_group_ = nullptr;
WGPUBuffer vertex_buffer_ = nullptr;
WGPUBuffer uniform_buffer_ = nullptr;
WGPURenderPipeline pivot_pipeline_ = nullptr;
WGPURenderPipeline pivot_xray_pipeline_ = nullptr;
WGPURenderPipeline corner_pipeline_ = nullptr;
};
#endif // AXISINDICATORRENDERER_H
+2
View File
@@ -139,6 +139,7 @@ endif()
#
# Keep this list explicit (no glob) — the boundary is the whole point.
set(IFCVIEWER_CORE_SOURCES
AxisIndicatorRenderer.cpp
BufferPool.cpp
ChunkPlanner.cpp
InstanceCompose.cpp
@@ -187,6 +188,7 @@ if(EMSCRIPTEN)
# below which would mangle these absolute paths; added via target_sources.
endif()
set(IFCVIEWER_CORE_HEADERS
AxisIndicatorRenderer.h
BufferPool.h
CameraMath.h
ChunkPlanner.h
+3 -1
View File
@@ -319,7 +319,9 @@ struct ModelGpuData {
// (Module.__ifcvSources[id] = a picked File or a remote URL) this model's
// chunk + element metadata reads pull from. Lets several federated models stream
// from different files at once, mirroring the desktop per-model path.
int web_source_id = 0;
// -1 when the model came from somewhere else (a path read on desktop, the
// embedded sample) — source id 0 is a real source, so it can't mean "none".
int web_source_id = -1;
// v15 element metadata (web, on-demand). The IFC element metadata
// (elements + string_table — names/GUIDs, for UI/picking, never
-364
View File
@@ -19,15 +19,12 @@
#include "OverlayRenderer.h"
#include "CameraMath.h"
#include <QFont>
#include <QFontMetrics>
#include <QImage>
#include <QPainter>
#include <QSet>
#include <QStringList>
#include <QtMath>
#include <algorithm>
#include <array>
@@ -49,44 +46,6 @@ WGPUStringView svFromCStr(const char* s) {
return v;
}
// Populate `attribs[5]` with the standard thick-line vertex layout:
// loc 0: start (vec3 @ 0) loc 1: end (vec3 @ 12)
// loc 2: col (vec3 @ 24) loc 3: t (f32 @ 36)
// loc 4: side (f32 @ 40)
// Returns a WGPUVertexBufferLayout aliasing the caller-owned `attribs`.
WGPUVertexBufferLayout thickLineVertexLayout(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;
return vbl;
}
// Pack the axis uniform's 256-byte slot. Layout matches WGSL AxisUniforms:
// mat4 + vec3 + f32 + f32 + f32 + vec2 = 96 B used, padded to 256.
void packAxisUniform(uint8_t* dst,
const Eigen::Matrix4f& mvp, const Eigen::Vector3f& origin,
float arm, float alpha, float line_width_px,
float viewport_w, float viewport_h) {
std::memset(dst, 0, 256);
std::memcpy(dst, mvp.data(), 16 * sizeof(float));
float ox = origin.x(), oy = origin.y(), oz = origin.z();
std::memcpy(dst + 64, &ox, sizeof(float));
std::memcpy(dst + 68, &oy, sizeof(float));
std::memcpy(dst + 72, &oz, sizeof(float));
std::memcpy(dst + 76, &arm, sizeof(float));
std::memcpy(dst + 80, &alpha, sizeof(float));
std::memcpy(dst + 84, &line_width_px, sizeof(float));
std::memcpy(dst + 88, &viewport_w, sizeof(float));
std::memcpy(dst + 92, &viewport_h, sizeof(float));
}
} // namespace
// -----------------------------------------------------------------------------
@@ -126,35 +85,6 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
}
)WGSL";
static const std::string AXIS_WGSL = std::string(THICK_LINE_HELPERS_WGSL) + R"WGSL(
struct AxisUniforms {
mvp: mat4x4<f32>,
origin: vec3<f32>,
arm: f32,
alpha: f32,
line_width_px: f32,
viewport_size: vec2<f32>,
};
@group(0) @binding(0) var<uniform> u: AxisUniforms;
@vertex
fn vs_main(@location(0) start: vec3<f32>,
@location(1) end: vec3<f32>,
@location(2) col: vec3<f32>,
@location(3) t: f32,
@location(4) side: f32) -> VsOut {
let p_start = u.mvp * vec4<f32>(u.origin + start * u.arm, 1.0);
let p_end = u.mvp * vec4<f32>(u.origin + end * u.arm, 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.alpha);
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>,
@@ -383,7 +313,6 @@ bool OverlayRenderer::init(WGPUInstance instance, WGPUDevice device,
queue_ = queue;
surface_format_ = surface_format;
sample_count_ = sample_count;
if (!buildAxisIndicator()) return false;
// Section-plane gizmos moved to the shared SectionGizmoRenderer (ViewportCore).
if (!buildMarquee()) return false;
if (!buildOverlayLines()) return false;
@@ -394,17 +323,6 @@ bool OverlayRenderer::init(WGPUInstance instance, WGPUDevice device,
}
void OverlayRenderer::destroy() {
// Axis indicator
if (axis_bind_group_) { wgpuBindGroupRelease(axis_bind_group_); axis_bind_group_ = nullptr; }
if (axis_pivot_pipeline_) { wgpuRenderPipelineRelease(axis_pivot_pipeline_); axis_pivot_pipeline_ = nullptr; }
if (axis_pivot_xray_pipeline_){ wgpuRenderPipelineRelease(axis_pivot_xray_pipeline_); axis_pivot_xray_pipeline_ = nullptr; }
if (axis_corner_pipeline_) { wgpuRenderPipelineRelease(axis_corner_pipeline_); axis_corner_pipeline_ = nullptr; }
if (axis_shader_module_) { wgpuShaderModuleRelease(axis_shader_module_); axis_shader_module_ = nullptr; }
if (axis_pipeline_layout_) { wgpuPipelineLayoutRelease(axis_pipeline_layout_); axis_pipeline_layout_ = nullptr; }
if (axis_bgl_) { wgpuBindGroupLayoutRelease(axis_bgl_); axis_bgl_ = nullptr; }
if (axis_uniform_buffer_) { wgpuBufferRelease(axis_uniform_buffer_); axis_uniform_buffer_ = nullptr; }
if (axis_vertex_buffer_) { wgpuBufferRelease(axis_vertex_buffer_); axis_vertex_buffer_ = nullptr; }
// Section visualizer
// Marquee
@@ -466,288 +384,6 @@ void OverlayRenderer::destroy() {
hud_text_.clear();
}
// -----------------------------------------------------------------------------
// Axis indicator
// -----------------------------------------------------------------------------
bool OverlayRenderer::buildAxisIndicator() {
// Bonsai decorator palette (src/bonsai/bonsai/bim/ui.py:593+):
// decorator_color_error = (1.000, 0.200, 0.322) — red → +X
// decorator_color_selected = (0.545, 0.863, 0.000) — green → +Y
// decorator_color_special = (0.157, 0.565, 1.000) — blue → +Z
// Same palette is reused for the section gizmo + marquee so all overlay
// colours come from one canonical source.
static const float axis_verts[] = {
// start end color (RGB — Bonsai decorators) t side
// ---- +X red ----
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, +1.f,
// ---- +Y green ----
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, +1.f,
// ---- +Z blue ----
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, +1.f,
};
{
WGPUBufferDescriptor bdesc = {};
bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
bdesc.size = sizeof(axis_verts);
bdesc.label = svFromCStr("ifcviewer-wgpu.axis_vbo");
axis_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
wgpuQueueWriteBuffer(queue_, axis_vertex_buffer_, 0, axis_verts, sizeof(axis_verts));
}
{
WGPUBufferDescriptor bdesc = {};
bdesc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
bdesc.size = 3u * kAxisUniformSlotSize;
bdesc.label = svFromCStr("ifcviewer-wgpu.axis_uniforms");
axis_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 = 96;
WGPUBindGroupLayoutDescriptor bgl_desc = {};
bgl_desc.entryCount = 1;
bgl_desc.entries = &entry;
bgl_desc.label = svFromCStr("ifcviewer-wgpu.axis_bgl");
axis_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
}
{
WGPUPipelineLayoutDescriptor pl_desc = {};
pl_desc.bindGroupLayoutCount = 1;
pl_desc.bindGroupLayouts = &axis_bgl_;
pl_desc.label = svFromCStr("ifcviewer-wgpu.axis_pipeline_layout");
axis_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
}
{
WGPUBindGroupEntry entry = {};
entry.binding = 0;
entry.buffer = axis_uniform_buffer_;
entry.offset = 0;
entry.size = kAxisUniformSlotSize;
WGPUBindGroupDescriptor bg_desc = {};
bg_desc.layout = axis_bgl_;
bg_desc.entryCount = 1;
bg_desc.entries = &entry;
bg_desc.label = svFromCStr("ifcviewer-wgpu.axis_bind_group");
axis_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
}
{
WGPUShaderSourceWGSL wgsl_src = {};
wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL;
wgsl_src.code = svFromCStr(AXIS_WGSL.c_str());
WGPUShaderModuleDescriptor sm_desc = {};
sm_desc.nextInChain = &wgsl_src.chain;
sm_desc.label = svFromCStr("ifcviewer-wgpu.axis_wgsl");
axis_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;
auto build_pivot = [&](WGPUCompareFunction cmp, const char* label,
WGPURenderPipeline& out) {
WGPUColorTargetState ct = {};
ct.format = surface_format_;
ct.blend = &blend;
ct.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = axis_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 = cmp;
depth.stencilFront.compare = WGPUCompareFunction_Always;
depth.stencilBack.compare = WGPUCompareFunction_Always;
WGPURenderPipelineDescriptor rp_desc = {};
rp_desc.layout = axis_pipeline_layout_;
rp_desc.label = svFromCStr(label);
rp_desc.vertex.module = axis_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;
out = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
};
build_pivot(WGPUCompareFunction_LessEqual,
"ifcviewer-wgpu.axis_pivot_pipeline",
axis_pivot_pipeline_);
build_pivot(WGPUCompareFunction_GreaterEqual,
"ifcviewer-wgpu.axis_pivot_xray_pipeline",
axis_pivot_xray_pipeline_);
// Corner: resolved surface, no depth, sampleCount=1.
{
WGPUColorTargetState ct = {};
ct.format = surface_format_;
ct.blend = &blend;
ct.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = axis_shader_module_;
frag.entryPoint = svFromCStr("fs_main");
frag.targetCount = 1;
frag.targets = &ct;
WGPURenderPipelineDescriptor rp_desc = {};
rp_desc.layout = axis_pipeline_layout_;
rp_desc.label = svFromCStr("ifcviewer-wgpu.axis_corner_pipeline");
rp_desc.vertex.module = axis_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.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp_desc.primitive.cullMode = WGPUCullMode_None;
rp_desc.multisample.count = 1;
rp_desc.multisample.mask = 0xFFFFFFFFu;
axis_corner_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
}
return axis_pivot_pipeline_ && axis_pivot_xray_pipeline_
&& axis_corner_pipeline_;
}
void OverlayRenderer::encodePivot(WGPURenderPassEncoder pass,
const OverlayFrame& f,
bool visible) {
if (!visible || !axis_pivot_pipeline_ || !axis_pivot_xray_pipeline_) return;
if (f.viewport_h_px <= 0) return;
// Arm length = 30 logical px projected into world at the pivot's distance.
const float fovy_rad = qDegreesToRadians(f.camera_fov_y_deg);
const float world_per_pixel = f.camera_distance * std::tan(fovy_rad * 0.5f)
* 2.0f / float(f.viewport_h_px);
const float arm_pixels = 30.0f * float(f.device_pixel_ratio);
const float arm_world = arm_pixels * world_per_pixel;
const float dpr = float(f.device_pixel_ratio);
const float line_w = 2.5f * dpr;
const float vw = float(f.viewport_w_px);
const float vh = float(f.viewport_h_px);
uint8_t slot_visible[256];
uint8_t slot_xray[256];
packAxisUniform(slot_visible, f.view_proj, f.camera_target, arm_world,
1.00f, line_w, vw, vh);
packAxisUniform(slot_xray, f.view_proj, f.camera_target, arm_world,
0.30f, line_w, vw, vh);
const uint32_t visible_off = 1u * kAxisUniformSlotSize;
const uint32_t xray_off = 2u * kAxisUniformSlotSize;
wgpuQueueWriteBuffer(queue_, axis_uniform_buffer_, visible_off,
slot_visible, sizeof(slot_visible));
wgpuQueueWriteBuffer(queue_, axis_uniform_buffer_, xray_off,
slot_xray, sizeof(slot_xray));
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, axis_vertex_buffer_, 0,
WGPU_WHOLE_SIZE);
wgpuRenderPassEncoderSetPipeline(pass, axis_pivot_xray_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, axis_bind_group_, 1, &xray_off);
wgpuRenderPassEncoderDraw(pass, 18, 1, 0, 0);
wgpuRenderPassEncoderSetPipeline(pass, axis_pivot_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, axis_bind_group_, 1, &visible_off);
wgpuRenderPassEncoderDraw(pass, 18, 1, 0, 0);
}
void OverlayRenderer::encodeCornerAxis(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& f) {
if (!axis_corner_pipeline_ || !surface_view) return;
const int dpr = std::max(1, f.device_pixel_ratio);
const uint32_t gizmo_size = uint32_t(110 * dpr);
const uint32_t margin = uint32_t(10 * dpr);
if (gizmo_size == 0 || f.viewport_w_px <= 0 || f.viewport_h_px <= 0) return;
// Bottom-left in WebGPU framebuffer space (y down).
const uint32_t fb_h = uint32_t(f.viewport_h_px);
if (gizmo_size + margin > fb_h) return;
const uint32_t y = fb_h - margin - gizmo_size;
// Independent ortho projection from the camera's direction. Near the
// poles the up axis collapses against the look direction, so swap to
// Y-up there — mirrors buildViewProj's identical fix on the viewport.
const float yaw_rad = qDegreesToRadians(f.camera_yaw_deg);
const float pitch_rad = qDegreesToRadians(f.camera_pitch_deg);
const Eigen::Vector3f eye_dir(std::cos(pitch_rad) * std::cos(yaw_rad),
std::cos(pitch_rad) * std::sin(yaw_rad),
std::sin(pitch_rad));
const Eigen::Vector3f world_up = (std::abs(f.camera_pitch_deg) >= 89.0f)
? Eigen::Vector3f(0.0f, 1.0f, 0.0f)
: Eigen::Vector3f(0.0f, 0.0f, 1.0f);
const Eigen::Matrix4f gv = lookAtRH(eye_dir * 3.0f, Eigen::Vector3f::Zero(), world_up);
const Eigen::Matrix4f gp = orthoGL(-1.4f, 1.4f, -1.4f, 1.4f, 0.1f, 10.0f);
Eigen::Matrix4f z_remap = Eigen::Matrix4f::Identity();
z_remap(2, 2) = 0.5f;
z_remap(2, 3) = 0.5f;
const Eigen::Matrix4f mvp = z_remap * gp * gv;
uint8_t slot[256];
const float line_w = 2.5f * float(dpr);
packAxisUniform(slot, mvp, Eigen::Vector3f(0, 0, 0), 1.0f, 1.0f, line_w,
float(gizmo_size), float(gizmo_size));
const uint32_t slot_offset = 0u;
wgpuQueueWriteBuffer(queue_, axis_uniform_buffer_, slot_offset, slot, sizeof(slot));
WGPURenderPassColorAttachment color = {};
color.view = surface_view;
color.loadOp = WGPULoadOp_Load;
color.storeOp = WGPUStoreOp_Store;
color.clearValue = { 0.0, 0.0, 0.0, 1.0 };
color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
WGPURenderPassDescriptor pass_desc = {};
pass_desc.colorAttachmentCount = 1;
pass_desc.colorAttachments = &color;
pass_desc.label = svFromCStr("ifcviewer-wgpu.corner_axis_pass");
WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
wgpuRenderPassEncoderSetViewport(pass, float(margin), float(y),
float(gizmo_size), float(gizmo_size),
0.0f, 1.0f);
wgpuRenderPassEncoderSetPipeline(pass, axis_corner_pipeline_);
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, axis_vertex_buffer_, 0,
WGPU_WHOLE_SIZE);
wgpuRenderPassEncoderSetBindGroup(pass, 0, axis_bind_group_, 1, &slot_offset);
wgpuRenderPassEncoderDraw(pass, 18, 1, 0, 0);
wgpuRenderPassEncoderEnd(pass);
wgpuRenderPassEncoderRelease(pass);
}
// -----------------------------------------------------------------------------
// Marquee
// -----------------------------------------------------------------------------
+14 -33
View File
@@ -33,10 +33,14 @@
#include "OverlayFrame.h"
#include "SectionPlane.h"
// All viewport overlays in one place: axis indicator (corner + pivot),
// section plane gizmos, and the marquee drag rect. Mirrors GL's
// OverlayRenderer split so ViewportWindow.cpp doesn't have to
// carry ~1.5k lines of pipeline plumbing.
// The Qt-coupled viewport overlays: the marquee drag rect, measure-tool
// lines / points / highlight patches, and the QPainter-rasterised labels
// and HUD. Mirrors GL's OverlayRenderer split so ViewportWindow.cpp
// doesn't have to carry ~1.5k lines of pipeline plumbing.
//
// The Qt-free overlays live in their own shared renderers so the web build
// gets them too: SectionGizmoRenderer and AxisIndicatorRenderer (corner
// axis gizmo + orbit pivot), both driven by ViewportCore::render.
//
// Lifecycle: init() once after the device is up, destroy() before the
// device dies. Pipelines are immutable after init; only per-frame
@@ -56,16 +60,11 @@ public:
void destroy();
// ---- Inside the main MSAA pass, after geometry ----
// Both share depth with the scene so they're correctly occluded.
// These share depth with the scene so they're correctly occluded.
// Orbit pivot indicator. `visible` is the viewport's UI gate (orbit
// drag / wheel-zoom afterglow). When false this is a cheap no-op.
void encodePivot(WGPURenderPassEncoder pass,
const OverlayFrame& f,
bool visible);
// Section-plane gizmos moved to the shared SectionGizmoRenderer (drawn by
// ViewportCore for both desktop + web).
// Section-plane gizmos moved to the shared SectionGizmoRenderer, and the
// orbit pivot to AxisIndicatorRenderer (both drawn by ViewportCore for
// desktop + web).
// Replace the highlight-triangle list. `world_xyz` is 3 floats per
// vertex, 3 vertices per triangle, in world space (post-composed-
@@ -148,12 +147,8 @@ public:
const OverlayFrame& f);
// ---- After the edge silhouette pass, on the resolved surface ----
// Corner axis gizmo (bottom-left, 110×110 px). Independent ortho
// projection — only the camera direction matters.
void encodeCornerAxis(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& f);
// (The corner axis gizmo also draws here — from ViewportCore, via
// AxisIndicatorRenderer.)
// Marquee box-select drag rect (translucent fill + thick outline).
// No-op when `active` is false.
@@ -170,7 +165,6 @@ public:
static constexpr int kMaxSectionPlanes = 6;
private:
bool buildAxisIndicator();
bool buildMarquee();
bool buildOverlayLines();
bool buildOverlayPoints();
@@ -200,19 +194,6 @@ private:
WGPUTextureFormat surface_format_ = WGPUTextureFormat_Undefined;
int sample_count_ = 1;
// ---- Axis indicator (shared shape, three pipelines) ----
// Slot 0 = corner gizmo. Slots 1/2 = pivot visible/x-ray.
WGPUShaderModule axis_shader_module_ = nullptr;
WGPUBindGroupLayout axis_bgl_ = nullptr;
WGPUPipelineLayout axis_pipeline_layout_ = nullptr;
WGPURenderPipeline axis_pivot_pipeline_ = nullptr;
WGPURenderPipeline axis_pivot_xray_pipeline_ = nullptr;
WGPURenderPipeline axis_corner_pipeline_ = nullptr;
WGPUBuffer axis_vertex_buffer_ = nullptr;
WGPUBuffer axis_uniform_buffer_ = nullptr;
WGPUBindGroup axis_bind_group_ = nullptr;
static constexpr uint32_t kAxisUniformSlotSize = 256;
// ---- Marquee (fill + outline pipelines, one uniform buffer) ----
WGPUShaderModule marquee_shader_module_ = nullptr;
WGPUBindGroupLayout marquee_bgl_ = nullptr;
+61 -13
View File
@@ -576,6 +576,21 @@ void ViewportCore::dollyBy(float notches) {
host_->requestFrame();
}
void ViewportCore::setPivotIndicatorVisible(bool visible, int hide_after_ms) {
pivot_indicator_visible_ = visible;
pivot_indicator_hide_ms_ = hide_after_ms;
if (visible && hide_after_ms > 0) pivot_indicator_timer_.start();
else pivot_indicator_timer_.invalidate();
host_->requestFrame();
}
bool ViewportCore::pivotIndicatorVisible() const {
if (!pivot_indicator_visible_) return false;
// No armed afterglow means a drag is holding it up.
if (!pivot_indicator_timer_.isValid()) return true;
return pivot_indicator_timer_.elapsed() < pivot_indicator_hide_ms_;
}
void ViewportCore::flyMove(bool fwd, bool back, bool right, bool left,
bool up, bool down, bool boost, float dt_seconds) {
if (dt_seconds <= 0.0f) return;
@@ -1297,6 +1312,10 @@ bool ViewportCore::buildPipelines() {
// 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);
// Corner axis gizmo + orbit pivot indicator (shared desktop + web).
// Also optional: a failure costs the indicator, not the viewport.
axis_indicator_.init(device_, queue_, surface_view_format_, kViewportSampleCount);
return true;
}
@@ -1913,6 +1932,7 @@ void ViewportCore::shutdown() {
if (main_pipeline_no_cull_) { wgpuRenderPipelineRelease(main_pipeline_no_cull_); main_pipeline_no_cull_ = nullptr; }
if (main_pipeline_transparent_) { wgpuRenderPipelineRelease(main_pipeline_transparent_); main_pipeline_transparent_ = nullptr; }
section_gizmo_.destroy();
axis_indicator_.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; }
@@ -3878,10 +3898,24 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
return;
}
// Mint the session model id HERE, synchronously, rather than at the end of
// the read chain below. Session ids are what orders the scene's models —
// modelIdsInLoadOrder sorts by them, and every per-model slot a host sees
// (modelProgress's index, ElementRef::model_index) is a rank in that order.
// Minting on completion made that rank the order the models' network reads
// happened to finish in, so with several federated models in flight the
// slots came out shuffled against the order the host added them and a pick
// was attributed to the wrong file. Requesting order is the order the host
// asked for, which is the order it can reason about. A load that fails
// partway simply abandons its id — the ranks compact over whatever models
// made it into the scene, exactly as before.
const std::uint32_t session_model_id = next_session_model_id_++;
// Head (v16): [header 12][geom_bytes 8]. The two compressed metadata blocks
// follow the compressed geometry at SIDECAR_HEAD_BYTES + geom_bytes.
webReadRangesAsync(source_id, 0, {{0, SIDECAR_HEAD_BYTES}},
[this, fsize, source_id, source_label, on_loaded = std::move(on_loaded)]
[this, fsize, source_id, source_label, session_model_id,
on_loaded = std::move(on_loaded)]
(bool ok, std::vector<std::uint8_t>&& head) mutable {
std::uint64_t geom_bytes = 0;
if (!ok || !parseSidecarHead(head.data(), head.size(), geom_bytes)) {
@@ -3895,7 +3929,7 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
}
// 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,
[this, fsize, meta_off, source_id, source_label, session_model_id,
on_loaded = std::move(on_loaded)]
(bool ok2, std::vector<std::uint8_t>&& h) {
if (!ok2 || h.size() < 16) {
@@ -3914,7 +3948,7 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
{{geometry_metadata_off, geometry_metadata_comp}},
[this, geometry_metadata_off, geometry_metadata_comp,
geometry_metadata_raw, source_id, source_label,
on_loaded = std::move(on_loaded)]
session_model_id, on_loaded = std::move(on_loaded)]
(bool ok3, std::vector<std::uint8_t>&& cz) {
if (!ok3) {
Log::warn() << "loadSidecarMetadataWeb: geometry metadata read failed";
@@ -3951,7 +3985,7 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
geometry_metadata_off + geometry_metadata_comp;
webReadRangesAsync(source_id, 0, {{element_metadata_hdr_off, 16}},
[this, sc = std::move(sc), element_metadata_hdr_off,
source_id, source_label,
source_id, source_label, session_model_id,
on_loaded = std::move(on_loaded)]
(bool ok4, std::vector<std::uint8_t>&& dh) mutable {
if (ok4 && dh.size() >= 16) {
@@ -3969,7 +4003,6 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
const std::size_t n_meshes = sc.meta.meshes.size();
const std::size_t n_instances = sc.meta.instances.size();
const std::uint32_t session_model_id = next_session_model_id_++;
applyCachedModel(session_model_id, std::move(sc));
// Mark web-streamed + set the source IMMEDIATELY — the
// model now has non-resident chunks and the RAF loop's
@@ -4071,11 +4104,14 @@ void ViewportCore::logSelectedObjectGuidWeb(std::uint32_t object_id) {
}
Log::info() << "pick: object " << object_id << " GUID " << e.guid;
// Surface the selection to JS so host pages can react (e.g. show the
// GUID + model). Fires Module.__ifcvOnSelect(object_id, guid, modelIndex);
// model_index is the load-order slot, matching the JS model list.
// GUID + model). Fires
// Module.__ifcvOnSelect(object_id, guid, modelIndex, sourceId).
// modelIndex is the load-order slot; sourceId is the byte-source the
// host added the model from, which is the one that cannot shift.
EM_ASM({
if (Module.__ifcvOnSelect) Module.__ifcvOnSelect($0, UTF8ToString($1), $2);
}, object_id, e.guid.c_str(), e.model_index);
if (Module.__ifcvOnSelect)
Module.__ifcvOnSelect($0, UTF8ToString($1), $2, $3);
}, object_id, e.guid.c_str(), e.model_index, e.source_id);
});
}
#endif // __EMSCRIPTEN__
@@ -4137,6 +4173,7 @@ ViewportCore::ElementRef makeElementRef(const ModelGpuData& m, int model_index,
ViewportCore::ElementRef ref;
ref.object_id = e.object_id;
ref.model_index = model_index;
ref.source_id = m.web_source_id;
ref.guid = str(e.guid_offset, e.guid_length);
ref.name = str(e.name_offset, e.name_length);
ref.type = str(e.type_offset, e.type_length);
@@ -7606,8 +7643,15 @@ void ViewportCore::render() {
section_gizmo_.encode(pass, vp_this_frame, section_planes_,
viewport_w_px, viewport_h_px, dpr_int, section_selected_index_);
// Remaining in-pass overlays (highlight triangles, pivot, overlay
// lines/points). QtViewportHost forwards to overlays_.X(); web host no-ops.
// Orbit pivot indicator — same shared-renderer story. Drawn while the host
// has it gated on (drag) or an afterglow is still running; in the latter
// case keep frames coming so the one that clears it actually lands.
const bool pivot_visible = pivotIndicatorVisible();
axis_indicator_.encodePivot(pass, overlay_frame, pivot_visible);
if (pivot_visible && pivot_indicator_timer_.isValid()) host_->requestFrame();
// Remaining in-pass overlays (highlight triangles, overlay lines/points).
// QtViewportHost forwards to overlays_.X(); the web host no-ops.
host_->encodeOverlaysInMainPass(pass, overlay_frame);
wgpuRenderPassEncoderEnd(pass);
@@ -7626,8 +7670,12 @@ void ViewportCore::render() {
int hiz_submitted_slot = -1;
if (hiz_enabled_) hiz_submitted_slot = encodeHizResolve(enc);
// Post-main overlays (corner axis, marquee, labels) on the resolved
// surface. QtViewportHost forwards to overlays_.X().
// Corner axis gizmo on the resolved surface — shared renderer, ahead of the
// host's own post-main overlays so marquee / labels still stack on top.
axis_indicator_.encodeCornerAxis(enc, view, overlay_frame);
// Remaining post-main overlays (marquee, labels) on the resolved surface.
// QtViewportHost forwards to overlays_.X(); the web host no-ops.
host_->encodeOverlaysPostMain(enc, view, overlay_frame);
// Optional capture: encode copy on the same command buffer.
+41 -10
View File
@@ -47,6 +47,7 @@
#include <utility>
#include <vector>
#include "AxisIndicatorRenderer.h"
#include "BufferPool.h"
#include "InstanceCompose.h"
#include "InstancedGeometry.h"
@@ -55,6 +56,7 @@
#include "SectionPlane.h"
#include "SelectionState.h"
#include "SidecarCache.h"
#include "Stopwatch.h"
#include "StreamingLoader.h"
#include "StreamingThread.h"
#include "ViewportHost.h"
@@ -281,9 +283,9 @@ public:
//
// Pixel-delta camera moves, shared by every host (Qt desktop + web).
// Hosts translate raw pointer/wheel events into these calls and own
// their own UI concerns (drag promotion, pivot indicator, cursor
// capture); the orbit math lives here so it can't drift between
// platforms. Each schedules a frame via the host.
// their own UI concerns (drag promotion, cursor capture); the orbit
// math lives here so it can't drift between platforms. Each schedules
// a frame via the host.
//
// orbitBy: drag-right yaws the world right (yaw -= dx), drag-down
// tilts the camera up (pitch += dy). 0.4 deg/px matches GL.
@@ -296,6 +298,18 @@ public:
void panBy(float dx_px, float dy_px, int viewport_height_px);
void dollyBy(float notches);
// ---- Pivot indicator ----------------------------------------------------
//
// The RGB triad drawn at the orbit target while the user navigates, so it's
// obvious what the camera is turning around. Hosts gate it: (true) when an
// orbit / pan drag starts, (false) when it ends. `hide_after_ms` > 0 arms an
// afterglow instead — the wheel path uses it so a zoom without a held drag
// still shows the pivot for a moment. State lives here (not in the host) so
// desktop and web behave identically; render() consults it each frame and
// keeps requesting frames until an armed afterglow expires.
void setPivotIndicatorVisible(bool visible, int hide_after_ms = 0);
bool pivotIndicatorVisible() const;
// ---- First-person / fly navigation --------------------------------------
//
// Shared fly-camera math (desktop + web). The HOST owns the fly-mode flag,
@@ -530,8 +544,10 @@ public:
// Per-model progress for a federation loading UI. count() is how many
// models have metadata (are in the scene); progress(idx,…) gives the
// idx-th model's resident/total chunks, ordered by session_model_id (= load order)
// so each model keeps a stable UI slot as it streams.
// idx-th model's resident/total chunks, ordered by session_model_id — which
// is minted when a load is REQUESTED, so this is the order the host asked
// for its models, not the order their reads finished. Each model keeps a
// stable UI slot as it streams.
int streamingModelCount() const;
void streamingModelProgress(int idx, int& resident_chunks,
int& total_chunks) const;
@@ -542,11 +558,17 @@ public:
int modelLoadIndex(std::uint32_t session_model_id) const;
// One row of the element table: the IFC identity behind a rendered
// object_id. `model_index` is the load-order slot (modelLoadIndex), so a
// host UI can attribute an object to the file it came from.
// object_id, plus which model it came from, said two ways.
//
// `model_index` is the load-order slot (modelLoadIndex) — a POSITION, so it
// shifts if an earlier model fails to load. `source_id` is the JS byte-source
// the model was added from (-1 when it came from somewhere else), which the
// host minted itself and which never moves. Prefer the latter for
// attributing an object to a file; the index is for UI slots.
struct ElementRef {
std::uint32_t object_id = 0;
int model_index = -1;
int source_id = -1;
std::string guid;
std::string name;
std::string type;
@@ -1030,9 +1052,10 @@ public:
private:
bool createPool();
// The scene's models in load order (ascending session_model_id). Every
// per-model API indexes against this, so a model keeps a stable UI slot
// instead of hopping with unordered_map iteration order.
// The scene's models in load order (ascending session_model_id, minted at
// request time — see loadSidecarMetadataWeb). Every per-model API indexes
// against this, so a model keeps a stable UI slot instead of hopping with
// unordered_map iteration order.
std::vector<std::uint32_t> modelIdsInLoadOrder() const;
public:
@@ -1092,6 +1115,14 @@ private:
// Lifted out of the Qt-coupled OverlayRenderer so one identical gizmo draws
// everywhere; the desktop's OverlayRenderer no longer draws it.
SectionGizmoRenderer section_gizmo_;
// Corner axis gizmo + orbit pivot indicator, likewise shared by desktop +
// web. Same lift out of the Qt-coupled OverlayRenderer.
AxisIndicatorRenderer axis_indicator_;
bool pivot_indicator_visible_ = false;
// Only running while an afterglow is armed; a drag-held indicator leaves it
// invalid so the triad stays up until the host clears it.
Stopwatch pivot_indicator_timer_;
int pivot_indicator_hide_ms_ = 0;
// HiZ occlusion-cull pipeline group. Downsamples MSAA depth into a
// mip pyramid; consumed by next-frame cull.
+18 -34
View File
@@ -435,14 +435,14 @@ void ViewportWindow::onFrameStats(const FrameStats& stats) {
void ViewportWindow::encodeOverlaysInMainPass(WGPURenderPassEncoder pass,
const OverlayFrame& frame) {
// Section gizmos, highlight triangles, pivot, overlay lines / points
// — 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.)
// NB: section-plane gizmos now draw from ViewportCore::render via the shared
// SectionGizmoRenderer (desktop + web), so they are NOT drawn here.
// Highlight triangles + overlay lines / points — drawn inside the MSAA
// pass so depth-test correctly hides them behind closer geometry.
// (Marquee / labels run on the resolved surface; see
// encodeOverlaysPostMain.)
// NB: section-plane gizmos and the pivot indicator now draw from
// ViewportCore::render via their shared renderers (desktop + web), so
// they are NOT drawn here.
overlays_.encodeHighlightTriangles(pass, frame);
overlays_.encodePivot(pass, frame, pivot_indicator_visible_);
overlays_.encodeOverlayLines(pass, frame);
overlays_.encodeOverlayPoints(pass, frame);
}
@@ -450,7 +450,8 @@ void ViewportWindow::encodeOverlaysInMainPass(WGPURenderPassEncoder pass,
void ViewportWindow::encodeOverlaysPostMain(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& frame) {
overlays_.encodeCornerAxis(enc, surface_view, frame);
// NB: the corner axis gizmo draws from ViewportCore::render (shared
// AxisIndicatorRenderer), just before this hook.
overlays_.encodeMarquee(enc, surface_view, frame,
box_select_start_pos_,
box_select_current_pos_,
@@ -907,25 +908,8 @@ bool ViewportWindow::initWgpu() {
// encodeEdgePass moved to ViewportCore (#84-s).
// -----------------------------------------------------------------------------
void ViewportWindow::setPivotIndicatorVisible(bool visible, int hide_after_ms) {
if (!pivot_indicator_hide_timer_) {
pivot_indicator_hide_timer_ = new QTimer(this);
pivot_indicator_hide_timer_->setSingleShot(true);
QObject::connect(pivot_indicator_hide_timer_, &QTimer::timeout, this,
[this]() {
pivot_indicator_visible_ = false;
requestUpdate();
});
}
pivot_indicator_visible_ = visible;
if (visible && hide_after_ms > 0) {
pivot_indicator_hide_timer_->start(hide_after_ms);
} else {
pivot_indicator_hide_timer_->stop();
}
requestUpdate();
}
// setPivotIndicatorVisible moved to ViewportCore (drawn by the shared
// AxisIndicatorRenderer, so the visibility gate lives there too).
// releaseEdgeResources moved to ViewportCore (#84-s).
// -----------------------------------------------------------------------------
@@ -1585,11 +1569,11 @@ void ViewportWindow::mousePressEvent(QMouseEvent* event) {
if (event->button() == orbit_button_
&& (mods & Qt::KeyboardModifierMask) == orbit_mods_) {
nav_drag_kind_ = NavDrag::Orbit;
setPivotIndicatorVisible(true); // hidden again on release
core_.setPivotIndicatorVisible(true); // hidden again on release
} else if (event->button() == pan_button_
&& (mods & Qt::KeyboardModifierMask) == pan_mods_) {
nav_drag_kind_ = NavDrag::Pan;
setPivotIndicatorVisible(true);
core_.setPivotIndicatorVisible(true);
} else if (event->button() == select_button_
&& !section_tool_active_
&& tool_mode_ != ToolMode::Area
@@ -1683,7 +1667,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
}
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
setPivotIndicatorVisible(false);
core_.setPivotIndicatorVisible(false);
return;
}
@@ -1700,7 +1684,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
emit surfacePickedInTool(px, py, int(event->modifiers()));
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
setPivotIndicatorVisible(false);
core_.setPivotIndicatorVisible(false);
return;
}
@@ -1715,7 +1699,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
emit surfacePickedInTool(px, py, int(event->modifiers()));
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
setPivotIndicatorVisible(false);
core_.setPivotIndicatorVisible(false);
return;
}
@@ -1800,7 +1784,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
// Drag is over — hide the pivot indicator without afterglow.
setPivotIndicatorVisible(false);
core_.setPivotIndicatorVisible(false);
}
}
@@ -2050,7 +2034,7 @@ void ViewportWindow::wheelEvent(QWheelEvent* event) {
core_.dollyBy(notches);
// Pivot afterglow on wheel — visible for 600 ms so the user can see
// what they're zooming around without holding a drag.
setPivotIndicatorVisible(true, 600);
core_.setPivotIndicatorVisible(true, 600);
}
void ViewportWindow::shutdown() {
+7 -16
View File
@@ -21,7 +21,6 @@
#define WGPUVIEWPORTWINDOW_H
#include <QWindow>
#include <QTimer>
#include <string>
#include <unordered_set>
@@ -306,12 +305,9 @@ private:
bool buildHizPipeline();
bool buildEdgePipeline();
void encodeEdgePass(WGPUCommandEncoder enc, WGPUTextureView surface_view);
// Show/hide the pivot indicator. hide_after_ms > 0 starts the
// single-shot auto-hide timer used by the wheel-zoom afterglow;
// drag callers pass 0 and toggle manually on press/release. The
// actual gizmo rendering lives in OverlayRenderer — this just
// manages the UI-side visibility timer.
void setPivotIndicatorVisible(bool visible, int hide_after_ms = 0);
// setPivotIndicatorVisible moved to ViewportCore — the indicator is drawn
// by the shared AxisIndicatorRenderer now, so its visibility (afterglow
// included) lives next to the drawing for desktop + web alike.
// releaseEdgeResources / buildPickPipeline / ensurePickAttachments /
// releasePickResources moved to ViewportCore (#84-s, #84-t).
@@ -670,15 +666,10 @@ private:
WGPUBindGroup& edge_bind_group_;
bool& edges_enabled_;
// Pivot visibility state — the gizmo itself lives in overlays_.
// The timer auto-hides the pivot after a wheel-zoom afterglow.
bool pivot_indicator_visible_ = false;
QTimer* pivot_indicator_hide_timer_ = nullptr;
// All viewport overlays (axis indicator, section gizmos, marquee
// rect) — pipelines + shaders + buffers + encoders. The viewport
// builds a OverlayFrame each frame and asks the renderer to
// encode each overlay; see OverlayRenderer.h.
// The Qt-coupled viewport overlays (marquee rect, measure lines /
// points / labels, highlight triangles) — pipelines + shaders +
// buffers + encoders. The viewport builds a OverlayFrame each frame
// and asks the renderer to encode each overlay; see OverlayRenderer.h.
OverlayRenderer overlays_;
// Active measurement tool. setToolMode() / setSelection mutations