docs: rewrite viewport architecture page + add .ifcview format reference

Rewrite the BonsaiViewer viewport architecture page to match the current
renderer (updated type names, streaming/sidecar flow). Add a dedicated
.ifcview sidecar format reference page and link it from the ifcopenshell
formats toctree, and polish the Bonsai intro copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-03 19:26:45 +10:00
parent 66d558ec2d
commit a7f6aaa725
4 changed files with 777 additions and 243 deletions
+402 -241
View File
@@ -1,277 +1,438 @@
Viewport architecture
=====================
This page documents the IFC viewer renderer that lives at
``src/ifcviewer/`` and underpins both BonsaiViewer and the standalone
``IfcViewerMinimal`` testbed.
This page documents the WebGPU IFC viewer renderer in ``src/ifcviewer``. The
same renderer is used by BonsaiViewer, IfcViewerMinimal, and the web build. It
explains the path from an IFC model or ``.ifcview`` cache to rendered triangles,
including the optimisation stages that decide what is loaded, what is resident
in GPU memory, and what is drawn.
Consumers
---------
Overview
--------
``src/ifcviewer/`` is a static library (``IfcViewer``) — it does not
ship as an executable on its own. Two consumers link against it:
* **BonsaiViewer** (``src/bonsaiviewer/``) — full project shell with
side panels, federation tree, settings dialog, connector picker,
ribbon. The end-user product.
* **IfcViewerMinimal** (``src/ifcviewer-minimal/``) — single-file
testbed that opens an IFC, a ``.ifcview`` sidecar, or a federation
``.ifcfed`` and shows just the viewport. Used for screenshot
regression tests, benchmarks, and isolating renderer-only issues
from the Bonsai Viewer shell.
Stack
-----
* **wgpu-native v29** for rendering, fetched as a pre-built binary in
``src/ifcviewer/CMakeLists.txt``. WebGPU API on top of Vulkan
(Linux/Windows) or Metal (macOS, via the ``MetalSurface_mac``
Cocoa bridge).
* **Qt6** for windowing (a raw ``QWindow``, not ``QOpenGLWidget``)
and the surface integration with the OS. Qt is also the source of
``QSettings`` for persisted user preferences (``AppSettings``),
the event loop driving render scheduling, and the timer/elapsed
primitives used in instrumentation.
* **IfcOpenShell C++ libs** (IfcParse / IfcGeom) for IFC parsing and
geometry generation, plus **IfcUtil** for the schema-agnostic
helpers (``Unit``, ``Geolocation``, ``Placement``).
* **Eigen3** for 4×4 matrices and small linear algebra.
* **meshoptimizer** at sidecar-build time only — decimates each
unique mesh into an LOD1 slice. Not pulled at runtime.
Five core ideas
---------------
The renderer is built around five decisions worth knowing about
before reading the code:
**1. Unique-mesh GPU instancing.** IFC scenes are dominated by
repeated geometry (identical doors, windows, studs, pipes, …). The
``IfcGeom::Iterator`` surfaces representation identity, so each
unique mesh is uploaded once into a per-model vertex buffer slice
and every placement becomes a tiny ``InstanceCpu`` record
(transform + ``object_id`` + optional colour override). On a 1 M-
placement BIM scene this collapses tens of millions of duplicate
vertices into a few hundred MB of unique meshes.
**2. Quantized 12-byte vertex format.** Each vertex is exactly 12
bytes:
The renderer is built around a small number of data transformations:
.. code-block:: text
offset 0 pos 3 × uint16 normalised → mix(mesh.aabb_min, mesh.aabb_max, t)
offset 6 normal 2 × int8 normalised → [-1,1]; octahedral-decoded
offset 8 color 4 × uint8 normalised → [0,1]; alpha is real (see "Transparency")
IFC / .ifcview input
-> SceneLoader
-> GeometryStreamer or sidecar metadata reader
-> SidecarData-shaped model metadata
-> ViewportCore::applyCachedModel()
-> per-model chunks, mesh table, instance table
-> cullModelCpuCompute()
-> visible_draws + prefix_sums
-> driveStreamingLoads()
-> applyStreamedChunk()
-> WebGPU draw() calls
-> WGSL expands visible draws into indexed triangles
The dequantisation basis is per-mesh, uploaded once in a
``MeshGpu`` SSBO at binding 2; the vertex shader reads it via the
mesh index that comes through the visible-draws table. ``int8``
normals carry ~1.4° worst-case angular error, invisible for the
overwhelmingly axis-aligned faces (walls, floors, slabs) BIM models
produce.
The important split is between **metadata** and **geometry bytes**. Metadata
describes meshes, instances, bounds, transforms, element ids, and chunk layout.
Geometry bytes are the quantised vertices and indices. A sidecar load reads
metadata first and leaves geometry chunks non-resident until the camera needs
them. A direct IFC load builds the same structures from the geometry iterator,
then uploads all chunks at finalisation.
**3. Streaming chunked storage backed by a probed VRAM pool.** Per-
model state isn't loaded all at once. The sidecar's vertex/index
data is split into spatially-coherent chunks (Morton-sorted at bake
time, packed greedily); ``ViewportWindow`` keeps each chunk's bytes
resident in a single GPU buffer pool whose size is determined at
startup by ``wgpuDevicePushErrorScope(OutOfMemory)`` probing. As
the camera moves, chunks page in and out via the
``StreamingThread`` background worker; the render thread never
issues a blocking disk read.
Main components
---------------
**4. Sidecar cache as the fast path.** Loading an IFC the first
time runs the ``IfcGeom::Iterator`` (expensive); the result is
serialised to a ``.ifcview`` file (``SIDECAR_VERSION = 13``) next
to the source. Subsequent opens go straight from disk to GPU
buffers — no iteration, no geometry-engine cost. The sidecar
format embeds chunked vertex sections with a byte-range table of
contents so the streaming thread can fetch any chunk by ``pread``
without scanning the file.
``SceneLoader``
Orchestrates model loading. It first tries to read ``.ifcview`` metadata
when sidecar reads are enabled. If that fails, it starts the geometry
streamer. It also keeps the ``ifcopenshell::file`` used by property and UI
code.
**5. Event-driven rendering.** No render timer. Frames are
scheduled only via ``QWindow::requestUpdate()`` when something
actually changes — camera motion, streaming chunk arrival, hover,
selection, settings edits. When the camera is idle, the cull pass
short-circuits and the main thread blocks in the Qt event loop;
the viewer costs zero CPU/GPU on a static scene.
``GeometryStreamer``
Runs ``IfcGeom::Iterator`` on a worker thread for raw IFC loads. It emits a
``StreamedMesh`` once for each unique representation mesh and a
``StreamedInstance`` for each placed occurrence.
Per-frame pipeline
------------------
``SidecarBuilder``
Optionally mirrors the streamer's output while a raw IFC is loading. It
writes a new ``.ifcview`` cache after the stream finishes, so the next open
can use the sidecar fast path.
Each call to ``ViewportWindow::render()`` runs the same six phases:
``ViewportWindow``
The Qt-facing window wrapper. It owns UI/window integration and forwards
renderer work to ``ViewportCore``.
``ViewportCore``
The renderer core. It owns the WebGPU device-facing model state, pipelines,
chunk residency, culling, streaming, picking, screenshot capture, and render
loop.
``ModelGpuData``
Per-model runtime state: mesh metadata, instances, chunks, GPU buffers,
per-chunk visibility scratch, object lookup maps, and CPU-side caches used
by tools.
``BufferPool``
Sub-allocates chunk vertex and index bytes from one or more WebGPU buffers.
Chunks enter and leave this pool as the streaming system pages geometry in
and out.
``StreamingThread`` and web range loading
Desktop sidecar streaming uses a background worker to read and decompress
chunk frames. The web build uses asynchronous byte-range reads from a
registered file or URL source.
Load path 1: sidecar hit
------------------------
The fast path starts when ``SceneLoader`` finds a readable ``.ifcview`` cache.
The reader validates the sidecar header, skips the compressed geometry section,
and reads the metadata blocks.
For desktop loading, ``readSidecarMetadataOnly()`` returns a
``StreamingSidecar`` containing:
- the sidecar file path
- the geometry section offset
- mesh records
- instance records
- georeferencing state
- the baked chunk table of contents
- element metadata
For web loading, ``ViewportCore::loadSidecarMetadataWeb()`` reads only the
header and critical metadata before creating the model. It records the location
of the deferred element metadata block and fetches that later only when UI code
needs it.
``ViewportCore::applyCachedModel()`` then converts sidecar metadata into
runtime model state:
1. It creates the chunk list. If the sidecar contains a baked
``SidecarChunk`` table, that table is authoritative. Each chunk is a
consecutive mesh range with compressed vertex and index frame locations. If
the model came from a direct in-memory load and has no table, the chunk plan
is derived from mesh centroids with Morton sorting and greedy packing.
2. It creates per-chunk scratch buffers: ``visible_draws``, ``prefix_sums``,
and a small uniform with draw counts.
3. It uploads the per-mesh quantisation table, ``MeshGpu``.
4. It uploads the per-instance GPU table, ``InstanceGpu``.
5. It builds CPU lookup tables: object id to instance, instance to chunk,
mesh-local offsets inside each chunk, chunk AABBs, and chunk instance lists.
6. It leaves the actual chunk vertex and index bytes non-resident. They are
fetched later by ``driveStreamingLoads()``.
At this point the viewer knows the model's structure and bounds, but a sidecar
model has not necessarily uploaded any triangles yet.
Load path 2: direct IFC stream
------------------------------
When no usable sidecar exists, ``SceneLoader`` starts ``GeometryStreamer``.
Streamer signals are queued from the worker thread into the UI/render thread:
``StreamedMesh``
A unique mesh in transfer form. Vertices are seven floats per vertex:
position, normal, and packed colour.
``StreamedInstance``
One placed occurrence of a mesh, with object id, colour override,
placement transform, and world AABB.
During the stream, ``ViewportCore::uploadStreamedMesh()`` and
``ViewportCore::uploadStreamedInstance()`` stage the data in a ``SidecarData``
shape. Mesh vertices are converted to the renderer's packed 12-byte format,
indices are stored, and instance records are accumulated. The viewport does not
incrementally draw these raw streamed mesh records one by one.
When the streamer finishes, ``SceneLoader::onStreamerFinished()`` calls
``ViewportCore::finalizeModel()``. Finalisation wraps the staged data in a
``StreamingSidecar`` object and calls the same ``applyCachedModel()`` path used
by sidecar loads. It then gathers the already-staged vertex and index bytes per
chunk and calls ``applyStreamedChunk()`` for each chunk. Direct IFC loads
therefore become resident after finalisation rather than streaming from disk.
If sidecar writes are enabled, ``SidecarBuilder`` finalises its mirrored copy,
builds LOD1 where available, reorders geometry into streaming chunk order, and
writes the ``.ifcview`` file for the next open.
Mesh and vertex representation
------------------------------
IFC geometry is represented as unique meshes plus instances. Repeated IFC
representations are uploaded once, and every placement becomes an instance that
references a mesh.
The GPU vertex format is 12 bytes per vertex:
.. code-block:: text
offset 0 position uint16[3] quantised against the mesh local AABB
offset 6 normal int8[2] octahedral encoded
offset 8 colour uint8[4] RGBA
The mesh's ``MeshGpu`` record stores ``aabb_min`` and ``aabb_max``. The vertex
shader reconstructs local positions by mapping the three 16-bit coordinates
back into that local AABB. The instance transform then moves the vertex to
world space.
Indices are ``uint32``. Each mesh has an LOD0 index range and may have an LOD1
index range. LOD1 reuses the same vertices with a smaller index list generated
at sidecar-build time.
Chunk layout and residency
--------------------------
Chunks are the unit of streaming, residency, culling priority, and rendering
bind groups. A chunk owns:
- a list of mesh ids
- a list of instance ids
- a world AABB
- compressed sidecar frame locations, for sidecar-backed models
- local vertex and index offsets for meshes inside the chunk
- GPU pool slices when resident
- cull output buffers
For sidecar-backed models, chunks start non-resident. They have metadata and
small cull buffers, but no vertex/index pool slices. A chunk becomes resident
when ``applyStreamedChunk()`` receives its decompressed vertex and index bytes.
``applyStreamedChunk()``:
1. Allocates vertex and index slices from ``BufferPool``.
2. Uploads chunk bytes with ``wgpuQueueWriteBuffer``.
3. Builds the chunk bind group.
4. Marks the chunk resident.
5. Scans alpha bytes so culling can route transparent meshes to the transparent
pass.
6. Computes mesh-local volumes and triangle CPU shadows used by measurement
tools.
``unloadChunk()`` releases the chunk's bind group and returns its pool slices
to ``BufferPool``. Metadata and cull scratch remain allocated so the chunk can
be loaded again later.
Per-frame render pipeline
-------------------------
Each frame in ``ViewportCore::render()`` follows this order:
.. code-block:: text
render():
1. fpsIntegrate() ← advance fly-mode camera by wall-clock dt
2. drainHizReadbacks() ← absorb HiZ readback completions
3. uploadSelectionFlagsIfDirty()
4. cullModelCpuCompute() ← parallel per-model frustum+contrib+HiZ+LOD
cullModelCpuUpload() ← writeBuffer visible-draws + prefix-sums
5. driveStreamingLoads() ← enqueue chunk fetches, apply completions
6. encode passes:
opaque main (depthWriteEnabled=True, no blend)
transparent main (depthWriteEnabled=True, SrcAlpha/InvSrcAlpha blend)
edge silhouette (samples depth buffer)
overlay (HUD, labels, gizmos, measurements, marquee)
wgpuSurfacePresent()
drain completed HiZ readbacks
upload selection flags if dirty
acquire surface texture
update frame uniforms
cull visible instances and upload visible draw tables
drive chunk streaming and eviction
encode opaque main pass
encode transparent main pass
encode in-pass overlays
encode edge silhouette pass
encode HiZ resolve if enabled
encode post-main overlays
submit command buffer
present surface
The two-pass alpha split (#6) routes each visible instance into
either the opaque half or the transparent half of the per-chunk
``visible_draws_scratch`` based on a per-mesh ``has_alpha`` flag +
the instance's ``color_override_rgba8`` alpha byte. The transparent
pass uses the same buffers, just with ``firstVertex`` offset to the
opaque half's end. ``Alt+X`` forces every instance through the
transparent pass via the ``xray_alpha_cap`` frame uniform, giving a
free X-ray mode.
Streaming runs after culling. That means a chunk that becomes resident during
``driveStreamingLoads()`` is usually first visible on a later frame. The
renderer requests a short burst of follow-up frames while streaming work is
settling so an event-driven render loop does not stop before newly resident
chunks are drawn.
Cull
~~~~
Culling
-------
CPU-side, runs parallel per model via ``std::async``. The cascade
per instance is:
Culling is CPU-side and model-parallel on desktop. The web build currently uses
the serial path because the WebAssembly build is not wired for pthread-backed
``std::async``.
1. **Chunk-level frustum cull** — each chunk's AABB is tested first;
off-frustum chunks skip every instance inside them in one shot.
2. **Per-instance frustum cull** — for chunks that pass.
3. **Contribution cull** — instances whose sphere projects below
``viewport/min_pixel_radius`` (stationary) or
``viewport/motion_min_pixel_radius`` (during motion) get dropped.
4. **HiZ occlusion** — projected AABB tested against the previous
frame's depth pyramid, when the VP matches. The pyramid is
captured at end-of-frame, downsampled, read back to CPU, and
queried with conservative all-fine-texels-agree semantics. Off
by default; ``WGPU_HIZ=1`` re-enables.
5. **LOD selection** — instances with sub-``viewport/lod1_pixel_threshold``
projected size and an available LOD1 slice route through the
LOD1 index range. Same vertex buffer, different ``firstIndex``.
``cullModelCpuCompute()`` is chunk-driven:
Survivors are appended to the chunk's ``visible_draws_scratch`` and
cumulative ``prefix_sums_scratch`` — the vertex shader uses a
binary search on prefix_sums to translate ``vertex_index`` into a
``(draw_index, vertex_in_draw)`` pair.
1. Reset each chunk's per-frame scratch and counters.
2. Frustum-test the chunk AABB. If the chunk is outside the frustum, every
instance inside it is skipped.
3. For frustum-passing chunks, process each instance in the chunk.
4. Skip hidden object ids.
5. Frustum-test the instance AABB.
6. Estimate projected pixel radius and projected AABB area.
7. Accumulate projected AABB area into the chunk's streaming priority.
8. Apply contribution culling. Instances below the current pixel-radius
threshold are dropped.
9. Mark the chunk contribution-visible. This is the signal that
``driveStreamingLoads()`` uses to decide whether a non-resident chunk is
worth fetching.
10. Optionally apply HiZ occlusion using the previous frame's depth pyramid.
11. Choose LOD0 or LOD1 based on projected size and LOD1 availability.
12. Classify the draw as opaque or transparent.
13. Append a ``VisibleDrawGpu`` record and extend the chunk prefix sum.
Streaming
~~~~~~~~~
The contribution threshold is higher while the camera is moving when
``viewport/motion_min_pixel_radius`` exceeds the still threshold. This drops
more sub-pixel work during navigation and reduces cull and streaming pressure.
The buffer pool (``BufferPool``) is a free-list sub-allocator over
one or more wgpu buffers, sized at startup by probing for
``WGPUErrorType_OutOfMemory`` on a series of growing allocations.
``StreamingThread`` is one worker thread that owns disk I/O:
``driveStreamingLoads()`` posts requests for the highest-priority
non-resident chunks and drains completions. The pool's eviction
policy is "evict the lowest-priority resident chunk whose priority
is below ``candidate_priority / EVICT_PRIORITY_RATIO``" — strict
enough to prevent thrash, lax enough that a single panning frame
doesn't refuse the move.
HiZ occlusion is optional. When enabled, the renderer uses a depth pyramid from
a previous compatible view-projection matrix. Contribution culling runs before
HiZ because it is much cheaper and reduces the number of HiZ tests.
Priorities come from a per-chunk screen-space score computed during
cull (chunk AABB projected to a screen-rect area). Spatial sort at
bake time means screen-adjacent chunks are byte-adjacent on disk,
so the typical fetch is a single ``pread`` range, not scatter-
gather.
Visible draw tables
-------------------
Overlay / picking / section cuts
--------------------------------
Culling does not issue draw calls directly. It writes compact per-chunk tables:
* ``OverlayRenderer`` runs after the main passes. Bundles the HUD
text, world-anchored labels (measurement readouts), the
marquee-select rect, the corner axis gizmo, the orbit pivot
indicator, and section-plane outline rendering. Has its own
pipelines with the usual ``SrcAlpha/OneMinusSrcAlpha`` blend.
* **Picking** is a second render pass with a dedicated pipeline
writing object IDs into an ``R32UInt`` framebuffer. Clicking
triggers a ``wgpuQueueOnSubmittedWorkDone`` + ``mapAsync``
readback of one pixel. No CPU-side raycasting.
* **Section cuts** (the ``K`` tool) push up to six clipping planes
into the frame uniform; ``is_section_clipped()`` in WGSL discards
fragments on the positive side. The plane gizmo and visible
cross-section outline come from OverlayRenderer.
``visible_draws``
One record per visible instance draw. It stores mesh id, instance index,
first index, and base vertex.
Federation
----------
``prefix_sums``
A monotonic array that maps a flat vertex id inside one chunk draw call to
a visible draw record.
``Federation`` (in ``IfcViewer``, not Bonsai) is the multi-model
data model. Each model in a federation has a fed_id, optional
group membership, an explicit ``ModelTransformation``, a
``CoordinateOperation`` (from the IFC's IfcMapConversion), and a
visibility flag. The viewport composes a single
``transform_meters`` per instance as:
``per_chunk_uniform``
Four counters: total visible draws, total visible vertices, opaque visible
vertices, and opaque visible draws.
After culling, ``cullModelCpuUpload()`` uploads those arrays to the chunk's GPU
buffers. Chunks with zero visible draws have their uniform zeroed and are
skipped by the render pass.
Triangle rendering
------------------
The main render uses one WebGPU ``draw()`` per resident visible chunk, not one
draw per IFC element. The CPU has already compacted the visible instances into
the chunk's ``visible_draws`` table.
The vertex shader receives a flat ``vertex_index`` from the draw call. It:
1. Binary-searches ``prefix_sums`` to find the visible draw record.
2. Computes the local vertex within that draw.
3. Reads the mesh-local index from the chunk index buffer.
4. Adds the draw's base vertex to get the packed vertex record.
5. Decodes quantised position, octahedral normal, and colour.
6. Reads the instance transform and object id.
7. Transforms the local vertex to world and clip space.
The fragment shader applies lighting, selection/active-object highlighting,
x-ray alpha cap, section clipping, and transparency output. Section clipping is
also used by picking so selected section planes match what the user sees.
Opaque and transparent passes
-----------------------------
Culling partitions visible draws into opaque and transparent halves. Opaque
draws are written first in each chunk's arrays; transparent draws are appended
after them.
The main pass then renders:
1. Opaque pipeline with depth writes and no blending, drawing
``opaque_visible_vertices``.
2. Transparent pipeline with blending, drawing the remaining vertices starting
at ``opaque_visible_vertices``.
Transparency is chosen from the baked mesh alpha flag, an instance colour
override alpha, or forced x-ray mode. The transparent pass uses the same chunk
bind group and buffers as the opaque pass.
Streaming and eviction
----------------------
``driveStreamingLoads()`` decides which non-resident sidecar chunks should be
loaded and which resident chunks may be evicted.
Each frame it:
1. Drains completed desktop worker reads and applies successful chunks.
2. Updates resident chunk visibility history.
3. Builds candidates from non-resident chunks that passed contribution
visibility in the current cull.
4. Sorts candidates by current projected-area priority.
5. Loads up to a fixed number of chunks per frame.
6. Ensures the buffer pool can fit the chunk before issuing the load.
7. Evicts lower-value resident chunks when the pool is full.
The first eviction pass drops the least-recently-visible resident chunk that
was not visible this frame. If every resident chunk is currently visible, a
second pass may evict the lowest-priority resident chunk, but only when the
candidate has meaningfully higher priority. This hysteresis prevents simple
swap loops while still allowing a saturated pool to follow the camera.
Desktop sidecar chunks are read by ``StreamingThread``. Each request contains
the sidecar path, geometry section offset, compressed vertex frame location,
and compressed index frame location. The worker reads and decompresses those
frames, then ``driveStreamingLoads()`` applies the result on the render thread.
Web sidecar chunks are fetched asynchronously by byte range. Vertex and index
frames are requested separately and joined before decompression and upload.
The web path caps concurrent chunk downloads so the highest-priority chunks can
arrive and render progressively instead of sharing bandwidth across the entire
visible set.
Sidecar format relationship
---------------------------
The ``.ifcview`` file exists to feed this renderer. Its critical metadata block
contains enough data to build ``ModelGpuData`` without reading geometry:
- mesh table
- instance table
- georeferencing cache
- chunk table of contents
The geometry section contains one compressed vertex frame and one compressed
index frame per chunk. The chunk table lets the streaming system jump directly
to the frames for a visible chunk without scanning the file or reading
unrelated geometry.
The sidecar version documented by the current code is version 16.
Picking, overlays, and tools
----------------------------
Picking is a GPU pass, not CPU raycasting. The pick pipeline uses the same
``visible_draws`` and chunk buffers as the main pass and writes object ids to
an integer texture. A click reads back the selected pixel. The section tool also
uses a normal and world-position pick target so it can create planes from the
actual rendered surface.
Overlay rendering is host-provided. ``ViewportCore`` encodes the shared section
gizmo and calls host hooks for in-pass and post-main overlays. The Qt host
forwards those hooks to the desktop overlay renderer; the web host can no-op or
provide its own implementation.
Edges are drawn after the main pass using the depth buffer. HiZ resolve, when
enabled, also happens after the main pass so a later frame can use the captured
depth pyramid for occlusion culling.
Federation and transforms
-------------------------
Federated models compose several transforms before upload:
.. code-block:: text
federated_false_origin · model_transformation · coord_op · placement
federated_false_origin
* model_transformation
* coordinate_operation
* placement_transformation
``federated_false_origin`` is shared across all models in the
session and cancels the bulk of the big surveyor coordinates BIM
files carry, so float32 vertex math stays well within precision.
The first-added model's first geometry point auto-seeds the false
origin via ``ViewportView::guessFederatedFalseOriginFromFirstModel``
when the add-model command arms the guess; see that function for
why the arm/consume mechanism exists (it replaced a stack-
overflowing slot-emit-in-slot recursion).
The sidecar stores double-precision placement transforms so large IFC
coordinates can be combined with coordinate operation and false-origin matrices
before being narrowed to the float transform used by the GPU. This protects
rendering precision for survey-coordinate models.
Files map
---------
What to remember
----------------
Render core (the wgpu side):
* ``ViewportWindow.{h,cpp}`` — the main render window. Owns the
wgpu surface, device, queue, all pipelines, the per-model
``ModelGpuData``, the cull pass, the frame uniforms. ~7500 lines;
the bulk of the renderer.
* ``ModelGpuData.h`` — per-model GPU state (chunks, instances,
meshes, pool slices, alpha flags, scratch buffers used by cull).
* ``InstancedGeometry.h`` — wire structs shared by the streamer
and the viewport (``MeshInfo``, ``InstanceCpu``, ``InstanceGpu``,
``MeshChunk``, ``InstanceChunk``, vertex layout constants).
* ``VertexQuantization.h`` — pack/unpack helpers for the 12-byte
vertex format.
* ``BufferPool.{h,cpp}`` — VRAM sub-allocator with multi-sub-buffer
growth.
* ``StreamingThread.{h,cpp}`` — background ``pread`` worker.
* ``StreamingLoader.{h,cpp}`` — chunk-priority planner driving the
thread; owns the eviction policy and the residency map.
* ``OverlayRenderer.{h,cpp}`` — HUD / labels / gizmos / section-cut
outline / marquee. Separate pipelines + WGSL shader strings.
* ``MetalSurface_mac.{h,mm}`` — Cocoa bridge for the CAMetalLayer
surface attach on macOS. Compiled only on Apple via the
``OBJCXX`` language CMake enables.
* ``SelectionState.h``, ``VisibilityState.h`` — pure CPU state
machines for the selection set and the hidden-objects set;
header-only; covered by ``ifcviewer/tests/test_selection.cpp``
and ``test_visibility.cpp``.
* ``AreaMeasurement.{h,cpp}``, ``LengthMeasurement.{h,cpp}`` — the
measurement tools (triangle-area accumulation; world-space
polyline distance).
IFC ingestion and on-disk format:
* ``GeometryStreamer.{h,cpp}`` — wraps ``IfcGeom::Iterator`` on a
background thread; emits ``MeshChunk`` (unique geometry) and
``InstanceChunk`` (one per placement) to ``SceneLoader``.
* ``SceneLoader.{h,cpp}`` — orchestrates load. Owns the per-model
``ifcopenshell::file`` for property lookup; detects sidecar
presence; serialises queue of pending loads.
* ``SidecarCache.{h,cpp}`` — binary read/write of ``.ifcview``
files. Magic ``IFVW``, version 13 (see the per-version comment
block at the top of the header for the schema evolution).
* ``SidecarBuilder.{h,cpp}`` — live-load sidecar writer. Accumulates
the data as the streamer emits it, writes the sidecar after
``finalizeModel``.
* ``LodBuilder.{h,cpp}`` — meshoptimizer wrapper that builds each
mesh's LOD1 index slice at sidecar-bake time. Gated behind
``WITH_MESH_OPTIMIZER``; otherwise the LOD1 slot is left empty
and the renderer always uses LOD0.
Federation + settings:
* ``Federation.{h,cpp}`` — multi-model session: groups, model
transforms, coordinate operations, the federated false origin.
Persisted as ``.ifcfed``. Covered by
``ifcviewer/tests/test_federation.cpp``.
* ``AppSettings.{h,cpp}````QSettings``-backed preferences:
``viewport/min_pixel_radius``, ``viewport/lod1_pixel_threshold``,
``viewport/hiz_enabled``, ``viewport/nav_preset``, etc. See
:doc:`env-vars` for the full key list.
- Raw IFC streaming produces mesh and instance chunks, but the WebGPU viewport
does not draw each emitted mesh immediately. It stages them, finalises a model,
then uses the same chunk path as sidecar loads.
- Sidecar loads create a renderable model from metadata first. Geometry becomes
visible only as chunks become resident.
- Culling produces compact visible draw tables per chunk.
- The renderer draws one flat vertex stream per visible chunk. The shader
expands that stream back into indexed, instanced triangles using
``visible_draws`` and ``prefix_sums``.
- Streaming priority comes from the current view. Chunks are fetched only when
their instances are large enough on screen to be worth drawing.
- The buffer pool is finite, so residency is dynamic. The renderer keeps the
chunks that matter most for the current view and evicts lower-value chunks
under memory pressure.
+4 -2
View File
@@ -1,5 +1,7 @@
Bonsai
======
Bonsai lets you analyse, create, and modify OpenBIM with Blender. For more
information, visit the `Bonsai website <https://bonsaibim.org>`_.
Bonsai is a native IFC authoring platform to analyse, create, and modify
OpenBIM with Blender.
For more information, visit the `Bonsai website <https://bonsaibim.org>`_.
@@ -101,6 +101,11 @@ without repeating tessellation, packing, quantisation, and instance rebuilds.
It is not a general interchange format or a full semantic storage backend. For
model data, pair it with the original IFC file or with RocksDB.
.. toctree::
:maxdepth: 1
ifcview_format
RDBVIEW
-------
@@ -0,0 +1,366 @@
IfcView format
==============
``.ifcview`` is a lossy binary geometry cache used by the Bonsai Viewer and
IfcViewer. It is a geometry cache, not an IFC exchange format. It stores
quantised, compressed, spatially sorted streamable chunks of tessellated
geometry, instancing, LODs, georeferencing, and basic element identifiers.
The format is optimised for three requirements:
- fast load and reuse of model geometry for analysis or visualisation
- direct upload to a viewer's GPU-facing mesh and instance layout
- compressed chunked streaming, especially for web loading by byte range
It intentionally does not store non-geometric IFC data or original IFC
parametric geometry. It is recommended to be combined with the original
``.ifc`` file, a ``.ifcdb``/``.rdb`` model store, or an ``.rdbview`` package
when additional data is needed.
The format writes native C++ structs directly for several tables. This makes it
compact and cheap to load, but it also means that ``.ifcview`` should not be
treated as a stable, language-neutral interchange specification.
The source of truth for the format is the C++ sidecar implementation in
``src/ifcviewer``. The current on-disk version is version 17.
Top-level layout
----------------
.. csv-table::
:header: "Section", "Description"
"SidecarHeader", "Versioning and format metadata"
"uint64 geometry_section_size", "Number of bytes from start of geometry section to byte immediately before the geometry metadata block. May be used by streaming readers to skip bulk geometry and first fetch metadata."
"Geometry section", "Streamable compressed chunks of geometry"
"Geometry metadata block", "Mesh records, instance records, georeferencing data, and chunk frame offsets"
"Element metadata block", "Element IDs, GUIDs, names, and IFC classes"
Both metadata blocks use the same compressed block wrapper:
.. code-block:: text
uint64 compressed_size
uint64 raw_size
byte[compressed_size] zstd_frame
The geometry section is also compressed with zstd, but it is not one large
frame. Each streaming chunk has its own vertex frame and index frame so a
loader can fetch and decompress only the chunks it needs.
For web streaming, the loader would read:
1. the 20-byte head, consisting of the 12-byte header and 8-byte geometry size
2. the geometry metadata block header and compressed payload
3. the element metadata block header, so it can remember where the UI metadata
lives
After that, visible chunks are loaded asynchronously by byte range. The
element metadata payload is fetched only when UI code asks for element
metadata.
Sidecar header
--------------
``SidecarHeader`` is 12 bytes:
.. code-block:: c
struct SidecarHeader {
uint32 magic;
uint32 version;
uint32 endian;
};
The current values are:
.. list-table::
:header-rows: 1
* - Field
- Value
- Purpose
* - Magic
- ``0x49465657``
- Identifies an ``IFVW`` sidecar.
* - Version
- ``17``
- Selects the current layout version. Versions may not be compatible.
* - Endian marker
- ``0x01020304``
- Rejects files written with a different byte order.
If any of these values do not match, the ifcview cache must be rejected.
Geometry section
----------------
The geometry section is the streamable part of the file. It contains, for each
chunk, two zstd frames:
.. code-block:: text
chunk 0 vertex frame
chunk 0 index frame
chunk 1 vertex frame
chunk 1 index frame
...
The metadata does not discover these frames by scanning. Instead, the geometry
metadata block stores the ``SidecarChunk`` table that locates each chunk's
compressed vertex and index frames.
Inside a decompressed chunk, data is chunk-local:
.. code-block:: text
vertices for mesh first_mesh
vertices for mesh first_mesh + 1
...
LOD0 indices for mesh first_mesh
LOD0 indices for mesh first_mesh + 1
...
LOD1 indices for mesh first_mesh
LOD1 indices for mesh first_mesh + 1
...
This order matches the renderer's streamed chunk upload path. Vertex offsets
and index offsets in ``MeshInfo`` describe the whole-model logical layout, but
the chunk upload path computes chunk-local offsets when it builds runtime chunk
state.
Vertex format
-------------
Each stored vertex is 12 bytes:
.. list-table::
:header-rows: 1
* - Byte offset
- Type
- Meaning
* - ``0``
- ``uint16[3]``
- Quantised local position.
* - ``6``
- ``int8[2]``
- Octahedral-encoded normal.
* - ``8``
- ``uint8[4]``
- RGBA colour.
The position is quantised against the mesh's local axis-aligned bounding box,
stored in ``MeshInfo.local_aabb_min`` and ``MeshInfo.local_aabb_max``. This
bounding box is the quantisation basis. In other words, the format stores each
coordinate as a normalised integer within the mesh-local min/max range, rather
than storing the original float coordinate.
Conceptually, encoding does this per axis:
.. code-block:: text
t = (position - local_aabb_min) / (local_aabb_max - local_aabb_min)
stored = round(clamp(t, 0, 1) * 65535)
Decoding does the inverse:
.. code-block:: text
t = stored / 65535
position = mix(local_aabb_min, local_aabb_max, t)
The implementation stores ``extent_recip`` while encoding, which is simply
``1 / (local_aabb_max - local_aabb_min)`` for each axis. Degenerate axes use
``0`` so all coordinates on that axis quantise to the same value.
Normals use two signed bytes with octahedral encoding. This is less precise
than storing three floats, but is small and adequate for typical BIM geometry,
which is dominated by planar and axis-aligned surfaces. Colour is stored as the
four bytes used by the viewer's packed RGBA path.
Geometry metadata block
-----------------------
The geometry metadata block is the minimum metadata required to create the
runtime model and begin painting geometry. Its raw, decompressed order is:
.. code-block:: text
vector<MeshInfo> meshes
vector<InstanceInfo> instances
uint32 has_coordinate_operation
double[16] coordinate_operation_meters
double project_length_to_meters
double map_unit_to_meters
vector<SidecarChunk> chunks
``MeshInfo`` is 56 bytes and describes one reusable mesh:
.. list-table::
:header-rows: 1
* - Field
- Meaning
* - ``vbo_byte_offset``
- Byte offset of the mesh's vertices in the logical whole-model vertex buffer.
* - ``vertex_count``
- Number of 12-byte vertices.
* - ``ebo_byte_offset``
- Byte offset of the mesh's LOD0 indices in the logical index buffer.
* - ``index_count``
- Number of LOD0 ``uint32`` indices.
* - ``local_aabb_min`` / ``local_aabb_max``
- Mesh-local bounds and the quantisation basis for vertex positions.
* - ``first_instance`` / ``instance_count``
- Range of instances that reference this mesh after sidecar layout.
* - ``lod1_ebo_byte_offset`` / ``lod1_index_count``
- Optional decimated index range for LOD1. A count of ``0`` means LOD1 is unavailable.
``InstanceInfo`` is 232 bytes and records one placed occurrence of a mesh. The
important fields are:
.. list-table::
:header-rows: 1
* - Field
- Meaning
* - ``mesh_id``
- Index into the mesh table.
* - ``object_id``
- Viewer object identifier used for selection and lookup.
* - ``color_override_rgba8``
- Optional per-instance colour override. ``0`` means use the baked vertex colour.
* - ``model_id``
- Source model identifier within the viewer.
* - ``placement_transformation``
- Double-precision placement emitted by the geometry streamer, before final federation and false-origin composition.
* - ``transform``
- Float render transform for the default stage state. Loaders may recompute it from ``placement_transformation`` and current stage matrices.
* - ``world_aabb_min`` / ``world_aabb_max``
- World-space instance bounds used for chunk bounds, culling, and view fitting.
Both transform forms are stored for precision and reuse. The double placement
keeps large IFC coordinates intact until the viewer has applied coordinate
operation, model transformation, and false-origin matrices. The float transform
is the GPU-facing result for the default composition.
The georeferencing fields cache enough of the model's coordinate operation and
unit scale to load a sidecar without reparsing the IFC source solely to recover
map conversion state. If the source map conversion changes, the sidecar must be
deleted and rebuilt.
``SidecarChunk`` is 56 bytes and records one streamable range of meshes. Each
chunk maps to two compressed geometry frames in the geometry section:
.. list-table::
:header-rows: 1
* - Field
- Meaning
* - ``first_mesh``
- First mesh index covered by the chunk.
* - ``mesh_count``
- Number of consecutive meshes covered by the chunk.
* - ``v_comp_off`` / ``v_comp_size``
- Byte offset and compressed byte size of the chunk's vertex frame, relative to the start of the geometry section.
* - ``v_raw_size``
- Decompressed byte size of the chunk's vertex data.
* - ``i_comp_off`` / ``i_comp_size``
- Byte offset and compressed byte size of the chunk's index frame, relative to the start of the geometry section.
* - ``i_raw_size``
- Decompressed byte size of the chunk's index data.
The chunk table is part of the geometry metadata because the viewer needs it
before it can request geometry. On the web path, the loader reads the header
and geometry metadata block, creates a model with non-resident chunks, and then
starts fetching visible chunk frames by byte range.
Element metadata block
-----------------------
The element metadata block stores non-geometric element lookup data used by UI
features such as picking, tree display, object labels, and search.
Its raw, decompressed order is:
.. code-block:: text
vector<ElementTableRecord> elements
uint32 string_table_bytes
char[string_table_bytes] string_table
Each ``ElementTableRecord`` is a fixed-size 36-byte record:
.. code-block:: c
struct ElementTableRecord {
uint32 object_id;
uint32 model_id;
int32 ifc_id;
uint32 guid_offset;
uint32 guid_length;
uint32 name_offset;
uint32 name_length;
uint32 type_offset;
uint32 type_length;
};
Strings are stored once in the string table and referenced by offset and
length.
Splitting this block from geometry metadata is important for first paint. A
large model can have substantial names, GlobalIds, and type strings. The web
viewer can show geometry after the geometry metadata block is available, then
fetch this metadata later when the UI needs it.
Chunk planning
--------------
Chunks are spatial groups of meshes. During sidecar creation, the builder:
1. Computes a centroid for each mesh from the average of its instance
world-AABB centres.
2. Sorts mesh ids by a 3D Morton code, also known as Z-order.
3. Greedily packs the sorted meshes into 4 MiB vertex-byte chunks. A single
mesh larger than that limit is kept whole in an oversized chunk. Meshes are
not split.
4. Reorders meshes, vertices, indices, and instances so each chunk is a
consecutive mesh range.
5. Writes these offsets into the ``SidecarChunk`` table so that loaders can
directly jump to the compressed chunk as needed.
How the file is produced
------------------------
When no usable sidecar is available, the viewer falls back to the geometry
streamer. The streamer emits:
- ``StreamedMesh`` once for each unique representation mesh
- ``StreamedInstance`` for every placed occurrence of a mesh
- ``ElementInfo`` records for viewer metadata
The sidecar builder consumes those streams. Mesh chunks are converted from the
streamer transfer layout, which is seven floats per vertex
(``position.xyz``, ``normal.xyz``, packed colour), into the 12-byte quantised
vertex layout. Instance chunks become ``InstanceInfo`` records. Element info
records become ``ElementTableRecord`` plus string table entries.
At finalisation, the builder adds LOD1 index buffers where useful, caches
georeferencing state, lays out meshes in streaming chunk order, builds the chunk
table, and writes the file.
Binary conventions
------------------
The current writer uses these conventions:
- Multi-byte scalar values are written in native byte order and validated by
the endian marker.
- Counted vectors are written as ``uint32 count`` followed by
``count * sizeof(T)`` bytes of raw table entries.
- Metadata blocks are zstd-compressed as complete raw metadata buffers.
- Geometry chunks store vertex bytes and index bytes as separate zstd frames.
- Offsets stored in the chunk table are relative to the start of the geometry
section, not relative to the start of the file.