Compare commits

..

1 Commits

Author SHA1 Message Date
Bruno Postle 5d0f9bb9fa ifcviewer: support linking system-packaged wgpu-native and zstd
Add a WGPU_NATIVE_USE_SYSTEM option that resolves wgpu-native via
pkg-config instead of FetchContent-ing upstream's prebuilt binary
release, for distros (e.g. Fedora) that ship it as a system package.
Also fall back to pkg-config for zstd when the CONFIG package isn't
available, and skip the wgpu-native runtime install step when the
system package already owns it.
2026-08-19 22:06:43 +01:00
17 changed files with 514 additions and 892 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ jobs:
max-size: 5000MB
- name: Set up Python for connector build
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: '3.12'
+1 -13
View File
@@ -253,10 +253,6 @@ 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;
}
@@ -301,10 +297,6 @@ 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) {
@@ -359,7 +351,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, -1); });
EM_ASM({ if (Module.__ifcvOnSelect) Module.__ifcvOnSelect(0, '', -1); });
}
app->host.requestFrame();
});
@@ -381,9 +373,6 @@ 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
}
@@ -835,7 +824,6 @@ extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_request_objects_c(int token) {
first = false;
json += "{\"objectId\":" + std::to_string(e.object_id)
+ ",\"model\":" + std::to_string(e.model_index)
+ ",\"sourceId\":" + std::to_string(e.source_id)
+ ",\"guid\":" + jsonString(e.guid)
+ ",\"name\":" + jsonString(e.name)
+ ",\"type\":" + jsonString(e.type) + '}';
-99
View File
@@ -1,99 +0,0 @@
// 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);
});
@@ -1,84 +0,0 @@
import { test, expect } from '@playwright/test';
// Which file did this object come from? Every host page answers that by taking
// the `model` index the viewer reports and looking it up in its own list of
// models, in the order it added them — the mapping the API documents. The
// index is only worth anything if it survives federated models finishing their
// loads out of order, which is exactly what happens over a real network.
//
// The two georef fixtures carry fixed GUIDs, so an object can be attributed to
// its file here without trusting the very index under test.
const GUIDS = {
'georef-a': ['13r0IXtWf5pf18Q1EGzHXl', '22CLYZYiz8ZhbpLaYDVIu6'],
'georef-b': ['3DkP2KRu5AIRxhhAz$DcQH', '2ueyz_jIr2QgMKs4v0fWl2'],
};
test('model index follows add order when the first model loads last', async ({ page }) => {
const errors = [];
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
await page.goto('/scripting.html');
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
{ timeout: 30_000 });
// georef-a is added first but served slowly, so every one of its range reads
// lands after georef-b's. Without a stable ordering the core hands out its
// load-order slots in completion order and the two models come back swapped.
const sourceIds = await page.evaluate(async () => {
const a = await window.viewer.addUrl('/georef-a.ifcview?delay=120', { replace: true });
const b = await window.viewer.addUrl('/georef-b.ifcview');
return [a, b];
});
expect(sourceIds[0]).toBeLessThan(sourceIds[1]);
await page.waitForFunction(() => window.viewer.modelCount() === 2, null, { timeout: 30_000 });
const objects = await page.evaluate(() => window.viewer.getObjects());
const rowFor = (guid) => objects.find((o) => o.guid === guid) || {};
for (const guid of GUIDS['georef-a']) {
expect(rowFor(guid).model, `${guid} belongs to georef-a, added first`).toBe(0);
expect(rowFor(guid).sourceId, `${guid} came from georef-a's source`).toBe(sourceIds[0]);
}
for (const guid of GUIDS['georef-b']) {
expect(rowFor(guid).model, `${guid} belongs to georef-b, added second`).toBe(1);
expect(rowFor(guid).sourceId, `${guid} came from georef-b's source`).toBe(sourceIds[1]);
}
expect(errors, errors.join('\n')).toEqual([]);
});
test('a pick reports the source the model was added from', async ({ page }) => {
const errors = [];
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
await page.goto('/scripting.html');
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
{ timeout: 30_000 });
await page.evaluate(async () => {
await window.viewer.addUrl('/georef-a.ifcview?delay=120', { replace: true });
await window.viewer.addUrl('/georef-b.ifcview');
});
await page.waitForFunction(() => window.viewer.modelCount() === 2, null, { timeout: 30_000 });
// The pick payload is built from the element table, so make sure it is
// resident and take the same table to check the answer against.
const objects = await page.evaluate(() => window.viewer.getObjects());
await page.evaluate(() => window.viewer.viewAll());
await page.waitForTimeout(800);
// Whichever box the click lands on is fine — what is under test is that the
// pick and the object table agree about which file the object came from.
await page.evaluate(() => {
window.__pick = new Promise((resolve) => window.viewer.onSelect(resolve));
});
const box = await page.locator('#viewer-canvas').boundingBox();
// Web preset: RMB selects (LMB orbits).
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' });
const detail = await page.evaluate(() => window.__pick);
expect(detail.guid, 'click hit empty space').toBeTruthy();
const row = objects.find((o) => o.guid === detail.guid);
expect(row, 'picked a GUID that is not in the object table').toBeTruthy();
expect(detail.sourceId, 'pick and object table disagree on the source').toBe(row.sourceId);
expect(detail.modelIndex).toBe(row.model);
expect(detail.sourceId).not.toBeNull();
expect(errors, errors.join('\n')).toEqual([]);
});
-6
View File
@@ -31,12 +31,6 @@ 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);
+2 -4
View File
@@ -12,10 +12,8 @@
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. 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;
/* Log overlay sits bottom-left and never eats pointer events. */
#status { position: fixed; bottom: 8px; left: 12px;
max-width: min(60vw, 680px); max-height: 28vh; overflow-y: auto;
font-size: 11px;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
+12 -25
View File
@@ -11,7 +11,7 @@
// await viewer.addFile(file, { replace: true });
// await viewer.addUrl('/model.ifcview'); // appends (federation)
//
// const objects = await viewer.getObjects(); // [{objectId, guid, name, type, model, sourceId}]
// const objects = await viewer.getObjects(); // [{objectId, guid, name, type, model}]
// viewer.setSelection(['3vB2YO$MX4xv5uCqZZG05x']);
// viewer.setColor(objects.filter(o => o.type === 'IfcWall'), '#ff8800');
// viewer.setCamera({ yaw: 45, pitch: 30 });
@@ -21,14 +21,6 @@
// 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
@@ -205,17 +197,15 @@
// 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, -1) means the
// The wasm calls this on every single-object pick; (0, '', -1) means the
// selection was cleared. modelIndex is the picked object's model in load
// 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) {
// 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) {
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); }
@@ -289,8 +279,8 @@
// ---- Events ----------------------------------------------------------
// Single-object picks (click). Fires with
// {objectId, guid, modelIndex, sourceId}. Returns an unsubscribe function.
// Single-object picks (click). Fires with {objectId, guid, modelIndex}.
// Returns an unsubscribe function.
onSelect: function (cb) {
selectListeners.push(cb);
return function () {
@@ -431,14 +421,11 @@
// ---- Objects ---------------------------------------------------------
// 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.
// 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.
getObjects: function () {
const token = ++objectsToken;
return new Promise(function (resolve) {
-423
View File
@@ -1,423 +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/>. *
* *
********************************************************************************/
#include "AxisIndicatorRenderer.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <string>
#include "CameraMath.h"
namespace {
constexpr uint32_t kAxisUniformSlot = 256; // dynamic-offset slot stride
constexpr uint32_t kAxisVertexCount = 18; // 3 arms x 2 triangles x 3 verts
// Uniform slots in the shared buffer.
constexpr uint32_t kSlotCorner = 0;
constexpr uint32_t kSlotPivot = 1;
constexpr uint32_t kSlotPivotXray = 2;
WGPUStringView svFromCStr(const char* s) {
WGPUStringView v;
v.data = s;
v.length = s ? std::strlen(s) : 0;
return v;
}
// Thick-line rendering helper (shared shape with the other overlays) + the
// axis vertex shader. Each arm is expanded to a screen-space-thick,
// anti-aliased quad.
static const std::string AXIS_WGSL = std::string(R"WGSL(
struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) side_t: f32,
};
fn thick_line_clip(p_start: vec4<f32>, p_end: vec4<f32>,
t: f32, side: f32,
viewport_size: vec2<f32>,
line_width_px: f32) -> vec4<f32> {
let p_here = mix(p_start, p_end, t);
let s_start = (p_start.xy / p_start.w) * viewport_size * 0.5;
let s_end = (p_end.xy / p_end.w ) * viewport_size * 0.5;
let dir = normalize(s_end - s_start);
let perp = vec2<f32>(-dir.y, dir.x);
let off_pixels = perp * (line_width_px * 0.5) * side;
let off_ndc = off_pixels * 2.0 / viewport_size;
return vec4<f32>(p_here.xy + off_ndc * p_here.w, p_here.zw);
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let d = abs(in.side_t);
let aa = fwidth(in.side_t);
let coverage = 1.0 - smoothstep(1.0 - aa, 1.0, d);
return vec4<f32>(in.color.xyz, in.color.w * coverage);
}
struct AxisUniforms {
mvp: mat4x4<f32>,
origin: vec3<f32>,
arm: f32,
alpha: f32,
line_width_px: f32,
viewport_size: vec2<f32>,
};
@group(0) @binding(0) var<uniform> u: AxisUniforms;
@vertex
fn vs_main(@location(0) start: vec3<f32>,
@location(1) end: vec3<f32>,
@location(2) col: vec3<f32>,
@location(3) t: f32,
@location(4) side: f32) -> VsOut {
let p_start = u.mvp * vec4<f32>(u.origin + start * u.arm, 1.0);
let p_end = u.mvp * vec4<f32>(u.origin + end * u.arm, 1.0);
var out: VsOut;
out.clip_pos = thick_line_clip(p_start, p_end, t, side,
u.viewport_size, u.line_width_px);
out.color = vec4<f32>(col, u.alpha);
out.side_t = side;
return out;
}
)WGSL");
// Pack the axis uniform's 256-byte slot. Layout matches WGSL AxisUniforms:
// mat4 + vec3 + f32 + f32 + f32 + vec2 = 96 B used, padded to 256.
void packAxisUniform(uint8_t* dst,
const Eigen::Matrix4f& mvp, const Eigen::Vector3f& origin,
float arm, float alpha, float line_width_px,
float viewport_w, float viewport_h) {
std::memset(dst, 0, kAxisUniformSlot);
std::memcpy(dst, mvp.data(), 16 * sizeof(float));
float ox = origin.x(), oy = origin.y(), oz = origin.z();
std::memcpy(dst + 64, &ox, sizeof(float));
std::memcpy(dst + 68, &oy, sizeof(float));
std::memcpy(dst + 72, &oz, sizeof(float));
std::memcpy(dst + 76, &arm, sizeof(float));
std::memcpy(dst + 80, &alpha, sizeof(float));
std::memcpy(dst + 84, &line_width_px, sizeof(float));
std::memcpy(dst + 88, &viewport_w, sizeof(float));
std::memcpy(dst + 92, &viewport_h, sizeof(float));
}
} // namespace
AxisIndicatorRenderer::~AxisIndicatorRenderer() { destroy(); }
bool AxisIndicatorRenderer::init(WGPUDevice device, WGPUQueue queue,
WGPUTextureFormat color_format, int sample_count) {
device_ = device;
queue_ = queue;
if (!device_ || !queue_) return false;
// Bonsai decorator palette (src/bonsai/bonsai/bim/ui.py:593+):
// decorator_color_error = (1.000, 0.200, 0.322) — red → +X
// decorator_color_selected = (0.545, 0.863, 0.000) — green → +Y
// decorator_color_special = (0.157, 0.565, 1.000) — blue → +Z
// Same palette is reused for the section gizmo + marquee so all overlay
// colours come from one canonical source.
static const float axis_verts[] = {
// start end color (RGB — Bonsai decorators) t side
// ---- +X red ----
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, +1.f,
// ---- +Y green ----
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, +1.f,
// ---- +Z blue ----
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, +1.f,
};
WGPUBufferDescriptor vb = {};
vb.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
vb.size = sizeof(axis_verts);
vb.label = svFromCStr("ifcviewer-wgpu.axis_vbo");
vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &vb);
wgpuQueueWriteBuffer(queue_, vertex_buffer_, 0, axis_verts, sizeof(axis_verts));
WGPUBufferDescriptor ub = {};
ub.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
ub.size = 3u * kAxisUniformSlot;
ub.label = svFromCStr("ifcviewer-wgpu.axis_uniforms");
uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &ub);
WGPUBindGroupLayoutEntry ble = {};
ble.binding = 0;
ble.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
ble.buffer.type = WGPUBufferBindingType_Uniform;
ble.buffer.hasDynamicOffset = 1;
ble.buffer.minBindingSize = 96;
WGPUBindGroupLayoutDescriptor bgl_desc = {};
bgl_desc.entryCount = 1;
bgl_desc.entries = &ble;
bgl_desc.label = svFromCStr("ifcviewer-wgpu.axis_bgl");
bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
WGPUPipelineLayoutDescriptor pl_desc = {};
pl_desc.bindGroupLayoutCount = 1;
pl_desc.bindGroupLayouts = &bgl_;
pl_desc.label = svFromCStr("ifcviewer-wgpu.axis_pipeline_layout");
layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
WGPUBindGroupEntry bge = {};
bge.binding = 0;
bge.buffer = uniform_buffer_;
bge.offset = 0;
bge.size = kAxisUniformSlot;
WGPUBindGroupDescriptor bg_desc = {};
bg_desc.layout = bgl_;
bg_desc.entryCount = 1;
bg_desc.entries = &bge;
bg_desc.label = svFromCStr("ifcviewer-wgpu.axis_bind_group");
bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
WGPUShaderSourceWGSL wgsl = {};
wgsl.chain.sType = WGPUSType_ShaderSourceWGSL;
wgsl.code = svFromCStr(AXIS_WGSL.c_str());
WGPUShaderModuleDescriptor sm_desc = {};
sm_desc.nextInChain = &wgsl.chain;
sm_desc.label = svFromCStr("ifcviewer-wgpu.axis_wgsl");
shader_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
// Vertex layout: start vec3, end vec3, col vec3, t f32, side f32.
WGPUVertexAttribute attribs[5] = {};
attribs[0].format = WGPUVertexFormat_Float32x3; attribs[0].offset = 0; attribs[0].shaderLocation = 0;
attribs[1].format = WGPUVertexFormat_Float32x3; attribs[1].offset = 12; attribs[1].shaderLocation = 1;
attribs[2].format = WGPUVertexFormat_Float32x3; attribs[2].offset = 24; attribs[2].shaderLocation = 2;
attribs[3].format = WGPUVertexFormat_Float32; attribs[3].offset = 36; attribs[3].shaderLocation = 3;
attribs[4].format = WGPUVertexFormat_Float32; attribs[4].offset = 40; attribs[4].shaderLocation = 4;
WGPUVertexBufferLayout vbl = {};
vbl.arrayStride = 44;
vbl.stepMode = WGPUVertexStepMode_Vertex;
vbl.attributeCount = 5;
vbl.attributes = attribs;
WGPUBlendState blend = {};
blend.color.srcFactor = WGPUBlendFactor_SrcAlpha;
blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.color.operation = WGPUBlendOperation_Add;
blend.alpha.srcFactor = WGPUBlendFactor_One;
blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.alpha.operation = WGPUBlendOperation_Add;
// Pivot: inside the main MSAA pass, depth-tested against the scene but
// never writing depth. Two passes — LessEqual for the visible part,
// GreaterEqual for the dim x-ray showing through geometry.
auto build_pivot = [&](WGPUCompareFunction cmp, const char* label,
WGPURenderPipeline& out) {
WGPUColorTargetState ct = {};
ct.format = color_format;
ct.blend = &blend;
ct.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = shader_;
frag.entryPoint = svFromCStr("fs_main");
frag.targetCount = 1;
frag.targets = &ct;
WGPUDepthStencilState depth = {};
depth.format = WGPUTextureFormat_Depth32Float;
depth.depthWriteEnabled = WGPUOptionalBool_False;
depth.depthCompare = cmp;
depth.stencilFront.compare = WGPUCompareFunction_Always;
depth.stencilBack.compare = WGPUCompareFunction_Always;
WGPURenderPipelineDescriptor rp = {};
rp.layout = layout_;
rp.label = svFromCStr(label);
rp.vertex.module = shader_;
rp.vertex.entryPoint = svFromCStr("vs_main");
rp.vertex.bufferCount = 1;
rp.vertex.buffers = &vbl;
rp.fragment = &frag;
rp.depthStencil = &depth;
rp.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp.primitive.cullMode = WGPUCullMode_None;
rp.multisample.count = uint32_t(sample_count);
rp.multisample.mask = 0xFFFFFFFFu;
out = wgpuDeviceCreateRenderPipeline(device_, &rp);
};
build_pivot(WGPUCompareFunction_LessEqual,
"ifcviewer-wgpu.axis_pivot_pipeline", pivot_pipeline_);
build_pivot(WGPUCompareFunction_GreaterEqual,
"ifcviewer-wgpu.axis_pivot_xray_pipeline", pivot_xray_pipeline_);
// Corner: resolved surface, no depth, sampleCount=1.
{
WGPUColorTargetState ct = {};
ct.format = color_format;
ct.blend = &blend;
ct.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = shader_;
frag.entryPoint = svFromCStr("fs_main");
frag.targetCount = 1;
frag.targets = &ct;
WGPURenderPipelineDescriptor rp = {};
rp.layout = layout_;
rp.label = svFromCStr("ifcviewer-wgpu.axis_corner_pipeline");
rp.vertex.module = shader_;
rp.vertex.entryPoint = svFromCStr("vs_main");
rp.vertex.bufferCount = 1;
rp.vertex.buffers = &vbl;
rp.fragment = &frag;
rp.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp.primitive.cullMode = WGPUCullMode_None;
rp.multisample.count = 1;
rp.multisample.mask = 0xFFFFFFFFu;
corner_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp);
}
return pivot_pipeline_ && pivot_xray_pipeline_ && corner_pipeline_;
}
void AxisIndicatorRenderer::encodePivot(WGPURenderPassEncoder pass,
const OverlayFrame& f, bool visible) {
if (!visible || !pivot_pipeline_ || !pivot_xray_pipeline_) return;
if (f.viewport_h_px <= 0) return;
// Arm length = 30 logical px projected into world at the pivot's distance.
const float fovy_rad = f.camera_fov_y_deg * kPiF / 180.0f;
const float world_per_pixel = f.camera_distance * std::tan(fovy_rad * 0.5f)
* 2.0f / float(f.viewport_h_px);
const float arm_pixels = 30.0f * float(f.device_pixel_ratio);
const float arm_world = arm_pixels * world_per_pixel;
const float dpr = float(f.device_pixel_ratio);
const float line_w = 2.5f * dpr;
const float vw = float(f.viewport_w_px);
const float vh = float(f.viewport_h_px);
uint8_t slot_visible[kAxisUniformSlot];
uint8_t slot_xray[kAxisUniformSlot];
packAxisUniform(slot_visible, f.view_proj, f.camera_target, arm_world,
1.00f, line_w, vw, vh);
packAxisUniform(slot_xray, f.view_proj, f.camera_target, arm_world,
0.30f, line_w, vw, vh);
const uint32_t visible_off = kSlotPivot * kAxisUniformSlot;
const uint32_t xray_off = kSlotPivotXray * kAxisUniformSlot;
wgpuQueueWriteBuffer(queue_, uniform_buffer_, visible_off,
slot_visible, sizeof(slot_visible));
wgpuQueueWriteBuffer(queue_, uniform_buffer_, xray_off,
slot_xray, sizeof(slot_xray));
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, vertex_buffer_, 0, WGPU_WHOLE_SIZE);
wgpuRenderPassEncoderSetPipeline(pass, pivot_xray_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &xray_off);
wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
wgpuRenderPassEncoderSetPipeline(pass, pivot_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &visible_off);
wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
}
void AxisIndicatorRenderer::encodeCornerAxis(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& f) {
if (!corner_pipeline_ || !surface_view) return;
const int dpr = std::max(1, f.device_pixel_ratio);
const uint32_t gizmo_size = uint32_t(110 * dpr);
const uint32_t margin = uint32_t(10 * dpr);
if (gizmo_size == 0 || f.viewport_w_px <= 0 || f.viewport_h_px <= 0) return;
// Bottom-left in WebGPU framebuffer space (y down).
const uint32_t fb_h = uint32_t(f.viewport_h_px);
if (gizmo_size + margin > fb_h) return;
const uint32_t y = fb_h - margin - gizmo_size;
// Independent ortho projection from the camera's direction. Near the
// poles the up axis collapses against the look direction, so swap to
// Y-up there — mirrors buildViewProj's identical fix on the viewport.
const float yaw_rad = f.camera_yaw_deg * kPiF / 180.0f;
const float pitch_rad = f.camera_pitch_deg * kPiF / 180.0f;
const Eigen::Vector3f eye_dir(std::cos(pitch_rad) * std::cos(yaw_rad),
std::cos(pitch_rad) * std::sin(yaw_rad),
std::sin(pitch_rad));
const Eigen::Vector3f world_up = (std::abs(f.camera_pitch_deg) >= 89.0f)
? Eigen::Vector3f(0.0f, 1.0f, 0.0f)
: Eigen::Vector3f(0.0f, 0.0f, 1.0f);
const Eigen::Matrix4f gv = lookAtRH(eye_dir * 3.0f, Eigen::Vector3f::Zero(), world_up);
const Eigen::Matrix4f gp = orthoGL(-1.4f, 1.4f, -1.4f, 1.4f, 0.1f, 10.0f);
Eigen::Matrix4f z_remap = Eigen::Matrix4f::Identity();
z_remap(2, 2) = 0.5f;
z_remap(2, 3) = 0.5f;
const Eigen::Matrix4f mvp = z_remap * gp * gv;
uint8_t slot[kAxisUniformSlot];
const float line_w = 2.5f * float(dpr);
packAxisUniform(slot, mvp, Eigen::Vector3f(0, 0, 0), 1.0f, 1.0f, line_w,
float(gizmo_size), float(gizmo_size));
const uint32_t slot_offset = kSlotCorner * kAxisUniformSlot;
wgpuQueueWriteBuffer(queue_, uniform_buffer_, slot_offset, slot, sizeof(slot));
WGPURenderPassColorAttachment color = {};
color.view = surface_view;
color.loadOp = WGPULoadOp_Load;
color.storeOp = WGPUStoreOp_Store;
color.clearValue = { 0.0, 0.0, 0.0, 1.0 };
color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
WGPURenderPassDescriptor pass_desc = {};
pass_desc.colorAttachmentCount = 1;
pass_desc.colorAttachments = &color;
pass_desc.label = svFromCStr("ifcviewer-wgpu.corner_axis_pass");
WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
wgpuRenderPassEncoderSetViewport(pass, float(margin), float(y),
float(gizmo_size), float(gizmo_size),
0.0f, 1.0f);
wgpuRenderPassEncoderSetPipeline(pass, corner_pipeline_);
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, vertex_buffer_, 0, WGPU_WHOLE_SIZE);
wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &slot_offset);
wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
wgpuRenderPassEncoderEnd(pass);
wgpuRenderPassEncoderRelease(pass);
}
void AxisIndicatorRenderer::destroy() {
if (pivot_pipeline_) { wgpuRenderPipelineRelease(pivot_pipeline_); pivot_pipeline_ = nullptr; }
if (pivot_xray_pipeline_) { wgpuRenderPipelineRelease(pivot_xray_pipeline_); pivot_xray_pipeline_ = nullptr; }
if (corner_pipeline_) { wgpuRenderPipelineRelease(corner_pipeline_); corner_pipeline_ = nullptr; }
if (layout_) { wgpuPipelineLayoutRelease(layout_); layout_ = nullptr; }
if (bgl_) { wgpuBindGroupLayoutRelease(bgl_); bgl_ = nullptr; }
if (bind_group_) { wgpuBindGroupRelease(bind_group_); bind_group_ = nullptr; }
if (vertex_buffer_) { wgpuBufferRelease(vertex_buffer_); vertex_buffer_ = nullptr; }
if (uniform_buffer_) { wgpuBufferRelease(uniform_buffer_); uniform_buffer_ = nullptr; }
if (shader_) { wgpuShaderModuleRelease(shader_); shader_ = nullptr; }
}
-84
View File
@@ -1,84 +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/>. *
* *
********************************************************************************/
#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
+27 -9
View File
@@ -32,13 +32,21 @@ endif()
# everywhere a 4x4 transform shows up. Header-only, works under Emscripten.
find_package(Eigen3 REQUIRED)
# wgpu-native — fetched as a pre-built binary release from upstream.
# Under Emscripten this whole block is skipped; the web build links
# against Dawn's webgpu.h via the emdawnwebgpu port instead. The
# wgpu-native — fetched as a pre-built binary release from upstream, or
# taken from a system package via pkg-config when WGPU_NATIVE_USE_SYSTEM
# is set. Under Emscripten this whole block is skipped; the web build
# links against Dawn's webgpu.h via the emdawnwebgpu port instead. The
# `wgpu_native` link target is created as an INTERFACE in that branch
# (see the end of this block) so consumers' target_link_libraries lines
# work uniformly.
if(NOT EMSCRIPTEN)
option(WGPU_NATIVE_USE_SYSTEM "Link against a system-packaged wgpu-native instead of FetchContent-ing upstream's prebuilt binary release" OFF)
if(NOT EMSCRIPTEN AND WGPU_NATIVE_USE_SYSTEM)
find_package(PkgConfig REQUIRED)
pkg_check_modules(WGPU_NATIVE REQUIRED IMPORTED_TARGET wgpu-native)
add_library(wgpu_native ALIAS PkgConfig::WGPU_NATIVE)
elseif(NOT EMSCRIPTEN)
# Pin the version with WGPU_NATIVE_VERSION; bump to pull a newer release.
set(WGPU_NATIVE_VERSION "v29.0.0.0" CACHE STRING "wgpu-native release tag")
@@ -139,7 +147,6 @@ 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
@@ -188,7 +195,6 @@ 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
@@ -246,8 +252,15 @@ if(EMSCRIPTEN)
target_sources(IfcViewerCore PRIVATE ${ZSTD_DEC_SRC})
target_include_directories(IfcViewerCore PRIVATE ${ZSTD_DEC_DIR})
else()
find_package(zstd CONFIG REQUIRED)
target_link_libraries(IfcViewerCore PUBLIC zstd::libzstd_static)
find_package(zstd CONFIG QUIET)
if(TARGET zstd::libzstd_static)
target_link_libraries(IfcViewerCore PUBLIC zstd::libzstd_static)
else()
# No zstd CONFIG package on this system — fall back to pkg-config.
find_package(PkgConfig REQUIRED)
pkg_check_modules(ZSTD REQUIRED IMPORTED_TARGET libzstd)
target_link_libraries(IfcViewerCore PUBLIC PkgConfig::ZSTD)
endif()
endif()
install(TARGETS IfcViewerCore EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
@@ -338,7 +351,12 @@ install(FILES ${IFCVIEWER_H_FILES}
# INSTALL_RPATH is set to @executable_path/../Frameworks — together
# they resolve at launch without depending on macdeployqt to follow
# non-Qt @rpath references.
if(WIN32)
#
# None of this applies with WGPU_NATIVE_USE_SYSTEM: the shared library
# already lives on the system linker path, owned by its own package.
if(WGPU_NATIVE_USE_SYSTEM)
# nothing to install; system package owns libwgpu_native
elseif(WIN32)
install(FILES "${wgpu_native_SOURCE_DIR}/lib/${_wgpu_runtime}" DESTINATION bin)
elseif(APPLE AND BUILD_BONSAIVIEWER)
install(FILES "${wgpu_native_SOURCE_DIR}/lib/${_wgpu_lib}"
+1 -3
View File
@@ -319,9 +319,7 @@ 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.
// -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;
int web_source_id = 0;
// v15 element metadata (web, on-demand). The IFC element metadata
// (elements + string_table — names/GUIDs, for UI/picking, never
+364
View File
@@ -19,12 +19,15 @@
#include "OverlayRenderer.h"
#include "CameraMath.h"
#include <QFont>
#include <QFontMetrics>
#include <QImage>
#include <QPainter>
#include <QSet>
#include <QStringList>
#include <QtMath>
#include <algorithm>
#include <array>
@@ -46,6 +49,44 @@ 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
// -----------------------------------------------------------------------------
@@ -85,6 +126,35 @@ 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>,
@@ -313,6 +383,7 @@ 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;
@@ -323,6 +394,17 @@ 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
@@ -384,6 +466,288 @@ 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 -14
View File
@@ -33,14 +33,10 @@
#include "OverlayFrame.h"
#include "SectionPlane.h"
// 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.
// 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.
//
// Lifecycle: init() once after the device is up, destroy() before the
// device dies. Pipelines are immutable after init; only per-frame
@@ -60,11 +56,16 @@ public:
void destroy();
// ---- Inside the main MSAA pass, after geometry ----
// These share depth with the scene so they're correctly occluded.
// Both share depth with the scene so they're correctly occluded.
// Section-plane gizmos moved to the shared SectionGizmoRenderer, and the
// orbit pivot to AxisIndicatorRenderer (both drawn by ViewportCore for
// desktop + web).
// 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).
// Replace the highlight-triangle list. `world_xyz` is 3 floats per
// vertex, 3 vertices per triangle, in world space (post-composed-
@@ -147,8 +148,12 @@ public:
const OverlayFrame& f);
// ---- After the edge silhouette pass, on the resolved surface ----
// (The corner axis gizmo also draws here — from ViewportCore, via
// AxisIndicatorRenderer.)
// 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);
// Marquee box-select drag rect (translucent fill + thick outline).
// No-op when `active` is false.
@@ -165,6 +170,7 @@ public:
static constexpr int kMaxSectionPlanes = 6;
private:
bool buildAxisIndicator();
bool buildMarquee();
bool buildOverlayLines();
bool buildOverlayPoints();
@@ -194,6 +200,19 @@ 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;
+13 -61
View File
@@ -576,21 +576,6 @@ 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;
@@ -1312,10 +1297,6 @@ 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;
}
@@ -1932,7 +1913,6 @@ 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; }
@@ -3898,24 +3878,10 @@ 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, session_model_id,
on_loaded = std::move(on_loaded)]
[this, fsize, source_id, source_label, 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)) {
@@ -3929,7 +3895,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, session_model_id,
[this, fsize, meta_off, source_id, source_label,
on_loaded = std::move(on_loaded)]
(bool ok2, std::vector<std::uint8_t>&& h) {
if (!ok2 || h.size() < 16) {
@@ -3948,7 +3914,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,
session_model_id, on_loaded = std::move(on_loaded)]
on_loaded = std::move(on_loaded)]
(bool ok3, std::vector<std::uint8_t>&& cz) {
if (!ok3) {
Log::warn() << "loadSidecarMetadataWeb: geometry metadata read failed";
@@ -3985,7 +3951,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, session_model_id,
source_id, source_label,
on_loaded = std::move(on_loaded)]
(bool ok4, std::vector<std::uint8_t>&& dh) mutable {
if (ok4 && dh.size() >= 16) {
@@ -4003,6 +3969,7 @@ 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
@@ -4104,14 +4071,11 @@ 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, 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.
// GUID + model). Fires Module.__ifcvOnSelect(object_id, guid, modelIndex);
// model_index is the load-order slot, matching the JS model list.
EM_ASM({
if (Module.__ifcvOnSelect)
Module.__ifcvOnSelect($0, UTF8ToString($1), $2, $3);
}, object_id, e.guid.c_str(), e.model_index, e.source_id);
if (Module.__ifcvOnSelect) Module.__ifcvOnSelect($0, UTF8ToString($1), $2);
}, object_id, e.guid.c_str(), e.model_index);
});
}
#endif // __EMSCRIPTEN__
@@ -4173,7 +4137,6 @@ 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);
@@ -7643,15 +7606,8 @@ void ViewportCore::render() {
section_gizmo_.encode(pass, vp_this_frame, section_planes_,
viewport_w_px, viewport_h_px, dpr_int, section_selected_index_);
// 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.
// Remaining in-pass overlays (highlight triangles, pivot, overlay
// lines/points). QtViewportHost forwards to overlays_.X(); web host no-ops.
host_->encodeOverlaysInMainPass(pass, overlay_frame);
wgpuRenderPassEncoderEnd(pass);
@@ -7670,12 +7626,8 @@ void ViewportCore::render() {
int hiz_submitted_slot = -1;
if (hiz_enabled_) hiz_submitted_slot = encodeHizResolve(enc);
// 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.
// Post-main overlays (corner axis, marquee, labels) on the resolved
// surface. QtViewportHost forwards to overlays_.X().
host_->encodeOverlaysPostMain(enc, view, overlay_frame);
// Optional capture: encode copy on the same command buffer.
+10 -41
View File
@@ -47,7 +47,6 @@
#include <utility>
#include <vector>
#include "AxisIndicatorRenderer.h"
#include "BufferPool.h"
#include "InstanceCompose.h"
#include "InstancedGeometry.h"
@@ -56,7 +55,6 @@
#include "SectionPlane.h"
#include "SelectionState.h"
#include "SidecarCache.h"
#include "Stopwatch.h"
#include "StreamingLoader.h"
#include "StreamingThread.h"
#include "ViewportHost.h"
@@ -283,9 +281,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, 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, pivot indicator, 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.
@@ -298,18 +296,6 @@ 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,
@@ -544,10 +530,8 @@ 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 — 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.
// 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.
int streamingModelCount() const;
void streamingModelProgress(int idx, int& resident_chunks,
int& total_chunks) const;
@@ -558,17 +542,11 @@ public:
int modelLoadIndex(std::uint32_t session_model_id) const;
// One row of the element table: the IFC identity behind a rendered
// 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.
// object_id. `model_index` is the load-order slot (modelLoadIndex), so a
// host UI can attribute an object to the file it came from.
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;
@@ -1052,10 +1030,9 @@ public:
private:
bool createPool();
// 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.
// 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.
std::vector<std::uint32_t> modelIdsInLoadOrder() const;
public:
@@ -1115,14 +1092,6 @@ 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.
+34 -18
View File
@@ -435,14 +435,14 @@ void ViewportWindow::onFrameStats(const FrameStats& stats) {
void ViewportWindow::encodeOverlaysInMainPass(WGPURenderPassEncoder pass,
const OverlayFrame& frame) {
// 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.
// 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.
overlays_.encodeHighlightTriangles(pass, frame);
overlays_.encodePivot(pass, frame, pivot_indicator_visible_);
overlays_.encodeOverlayLines(pass, frame);
overlays_.encodeOverlayPoints(pass, frame);
}
@@ -450,8 +450,7 @@ void ViewportWindow::encodeOverlaysInMainPass(WGPURenderPassEncoder pass,
void ViewportWindow::encodeOverlaysPostMain(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& frame) {
// NB: the corner axis gizmo draws from ViewportCore::render (shared
// AxisIndicatorRenderer), just before this hook.
overlays_.encodeCornerAxis(enc, surface_view, frame);
overlays_.encodeMarquee(enc, surface_view, frame,
box_select_start_pos_,
box_select_current_pos_,
@@ -908,8 +907,25 @@ bool ViewportWindow::initWgpu() {
// encodeEdgePass moved to ViewportCore (#84-s).
// setPivotIndicatorVisible moved to ViewportCore (drawn by the shared
// AxisIndicatorRenderer, so the visibility gate lives there too).
// -----------------------------------------------------------------------------
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();
}
// releaseEdgeResources moved to ViewportCore (#84-s).
// -----------------------------------------------------------------------------
@@ -1569,11 +1585,11 @@ void ViewportWindow::mousePressEvent(QMouseEvent* event) {
if (event->button() == orbit_button_
&& (mods & Qt::KeyboardModifierMask) == orbit_mods_) {
nav_drag_kind_ = NavDrag::Orbit;
core_.setPivotIndicatorVisible(true); // hidden again on release
setPivotIndicatorVisible(true); // hidden again on release
} else if (event->button() == pan_button_
&& (mods & Qt::KeyboardModifierMask) == pan_mods_) {
nav_drag_kind_ = NavDrag::Pan;
core_.setPivotIndicatorVisible(true);
setPivotIndicatorVisible(true);
} else if (event->button() == select_button_
&& !section_tool_active_
&& tool_mode_ != ToolMode::Area
@@ -1667,7 +1683,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
}
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
core_.setPivotIndicatorVisible(false);
setPivotIndicatorVisible(false);
return;
}
@@ -1684,7 +1700,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
emit surfacePickedInTool(px, py, int(event->modifiers()));
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
core_.setPivotIndicatorVisible(false);
setPivotIndicatorVisible(false);
return;
}
@@ -1699,7 +1715,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
emit surfacePickedInTool(px, py, int(event->modifiers()));
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
core_.setPivotIndicatorVisible(false);
setPivotIndicatorVisible(false);
return;
}
@@ -1784,7 +1800,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.
core_.setPivotIndicatorVisible(false);
setPivotIndicatorVisible(false);
}
}
@@ -2034,7 +2050,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.
core_.setPivotIndicatorVisible(true, 600);
setPivotIndicatorVisible(true, 600);
}
void ViewportWindow::shutdown() {
+16 -7
View File
@@ -21,6 +21,7 @@
#define WGPUVIEWPORTWINDOW_H
#include <QWindow>
#include <QTimer>
#include <string>
#include <unordered_set>
@@ -305,9 +306,12 @@ private:
bool buildHizPipeline();
bool buildEdgePipeline();
void encodeEdgePass(WGPUCommandEncoder enc, WGPUTextureView surface_view);
// 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.
// 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);
// releaseEdgeResources / buildPickPipeline / ensurePickAttachments /
// releasePickResources moved to ViewportCore (#84-s, #84-t).
@@ -666,10 +670,15 @@ private:
WGPUBindGroup& edge_bind_group_;
bool& edges_enabled_;
// 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.
// 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.
OverlayRenderer overlays_;
// Active measurement tool. setToolMode() / setSelection mutations