mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-22 13:05:59 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4517784a7a | |||
| 2c1d445d5b | |||
| d86f89090b |
@@ -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'
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" "$@"
|
||||
@@ -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) + '}';
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user