Skip to content

feat(viewer): three.js viewer with Navisworks-style navigation, sections and merged-chunk rendering - #8

Merged
mahach666 merged 40 commits into
mainfrom
feat/viewer-threejs
Nov 12, 2026
Merged

mahach666 merged 40 commits into
mainfrom
feat/viewer-threejs

Conversation

@mahach666

@mahach666 mahach666 commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

What changes and why

Replaces the hand-written WebGL viewer template with a new viewer built on three.js 0.186.1. The aim is a viewer that a Navisworks user can use right away and that stays responsive on very large utility-network models. The generated HTML is still a single file with no external dependencies that works offline.

Viewer

  • Floating panels:
    • model tree by category with search, properties, views, sections, clashes, 4D and linked files;
    • clashes, 4D and links appear only when the document has that data.
  • A toolbar with labelled groups and a status bar showing selected and total counts.
  • A context menu: properties, hide, isolate, show all, zoom to object, section box around the object, copy Id.
  • Navisworks-style mouse:
    • orbit around the point under the cursor;
    • pan with the middle or right button;
    • zoom towards the cursor; the zoom step follows the wheel or trackpad delta;
    • Ctrl+click to add or remove an object;
    • Shift+drag box selection: left to right is a window, right to left is a crossing selection.
  • Hotkeys H, I, Shift+H, F and Esc. The up axis is +Z.
  • A view cube drawn from the camera orientation, with faces, 90° arrows and a home button.
  • Sections: up to six planes moved and rotated with an in-scene gizmo, or a section box with these features:
    • keep inside or cut inside;
    • face handles and a centre handle;
    • a box around the selection.
  • Distance measurement; display modes (materials, textures, white, wireframe); grid lines; global opacity; perspective or orthographic camera; fullscreen.
  • Saved viewpoints and a viewpoint tour.
  • A clash list: clicking a clash flies the camera to it and selects both elements.
  • A 4D schedule player and linked-file visibility.
  • Russian or English interface, chosen from the browser language.
  • On-page messages instead of a blank page when WebGL 2 is missing, the model data cannot be read, the WebGL context is lost, or the model has more objects than the GPU can address.
  • Removed: the turntable (automatic 360° rotation).

Library (additive C# API)

  • Shared geometry: ViewerDocument.Geometries, ViewerGeometry, ViewerMesh.GeometryId, ViewerMesh.Transform and the Transform3d affine transform.
  • ViewerClashResult.Element1Id / Element2Id. Without them, the viewer selects an element by name only when that name is unique.
  • GlbViewerImporter keeps node transforms and shared meshes and converts glTF Y-up to Z-up, so GLB models no longer lie on their side.
  • GlbViewerImporter and ObjViewerImporter create one element per GLB node and per OBJ object or group, so converted models have a model tree and can be selected, hidden and isolated (fixes GLB and OBJ conversions produce no elements: empty tree, no selection, hide or isolate #11). Each OBJ mesh keeps only the vertices its faces use; a face that references a vertex not yet read throws InvalidDataException on load.
  • A document without elements (API-built or older JSON) gets stand-in elements in the HTML payload: one per mesh ElementId (grouping its meshes, matched by 4D tasks and clashes) and one per mesh without an ElementId (marked mesh: true, never matched by task or clash ids). The document itself is not changed.
  • Behaviour change: a GLB node hierarchy that is not a tree (a node referenced twice, or a cycle) now throws InvalidDataException.
  • Behaviour change: HtmlViewerExporter now throws InvalidDataException for indices outside the vertex range and other invalid references instead of writing broken geometry. Save checks this before opening the file, and the CLI reports it with exit code 3.
  • JsonViewerImporter fix: grid lines, 4D tasks (with attached ids and child tasks), clash results, element properties and element mesh indices were silently dropped on load and are now kept.
  • Saved viewpoints now export their projection (perspective or orthographic).
  • HtmlViewerOptions.MaxMeshVertices defaults to no limit (was 5,000,000). When a lower limit leaves meshes out, the viewer shows a banner instead of cutting the model silently.
  • HTML payload format 3:
    • one base64 binary block;
    • positions quantized to 2⁻¹⁰ m steps: error ≤ 0.49 mm, or ≤ 0.85 mm for rotated shared instances;
    • octahedral 16-bit normals;
    • geometry larger than about 64 m, and shared geometry that an instance scales up, stays 32-bit float;
    • the page is streamed to the output; the binary block is built in memory and must stay under 2 GB.

Renderer

  • Geometry is merged into spatial chunks per material. A per-object state texture holds hidden, selected and tint state, so hide, isolate, selection and 4D colouring never rebuild geometry.
  • Above 384 MiB of merged data, repeated shapes are drawn as instanced copies in spatially bounded cells.
  • Accepted deviation: transparent objects are sorted per chunk or cell, not per object.

Repository

  • THIRD-PARTY-NOTICES.md (three.js, MIT), included in both nupkgs and in the release archives.
  • "No external dependencies, works offline" replaces "zero-dependency" in the docs, package descriptions and CLI help.
  • CLI help: --title sets the browser tab title, and --no-properties excludes element properties only. The options themselves are unchanged.
  • A new CI job viewer: it rebuilds the bundle, fails when the committed dist/viewer.js differs from a fresh build, and runs the unit tests and the Playwright end-to-end tests on Chromium.
  • Dependabot also updates the viewer's npm packages.
  • CONTRIBUTING describes the Node 24 toolchain, including what to update when three.js is bumped.

Performance

Integrated 2-CU AMD GPU unless stated otherwise. "Before" is this branch's first three.js renderer (one draw call per instance), not 0.1.0. Both were measured with the same manual harness (npm run perf).

Model Metric Before After
200k elements / 19.2 M triangles Full-detail frame ~68 ms 30.3 ms
Orbit 15 fps at full detail (30 fps only with reduced detail) 30 fps at full detail
Click / hide / show all, time to full frame ~90 ms ~52 ms
Open – ~1.3 s
Orbit on RTX 4080 – 60 fps
GPU memory – ~327 MB
1M elements Open – ~2.2 s (peak memory on par with before)
Full frame 0.33 s 0.21 s
GPU memory – ~88 MB
Orbit, 95th percentile 29.9 fps 20 fps (regression)
Pick ~7 ms ~22 ms (regression)
Still-frame CPU time 4 ms 23 ms (regression)

The 1M regressions come from bounding the instanced cells spatially. That bound keeps picking and orbit stable from 400k to 1M elements. Culling individual instances inside a cell is a candidate follow-up.

HTML size, real 746 MB IFC water-supply model (via GLB, 16,206 meshes):

Version Page size
0.1.0 (f32 positions, no normals) 44.1 MB
This branch before quantization (f32 positions and normals) 57.5 MB
This PR (format 3: quantized positions, octahedral normals) 44.4 MB

Known issue

  • Non-finite numbers in the model (NaN or infinity in vertices, viewpoints, grid lines and similar values) are only rejected while the page is being written. In that case Save has already truncated an existing output file, which contradicts the new CHANGELOG line. Writing to a temporary file and moving it into place on success would fix it.

How it was checked

  • dotnet build IOViewer.slnx -c Release has no warnings
  • dotnet test IOViewer.slnx -c Release passes (79/79 on net8.0 and on net10.0)
  • Generated HTML and JSON for the same inputs are unchanged, or the differences are listed and intended: the HTML is a completely new viewer with a new payload (format 3), described above. JSON snapshots gain Geometries, GeometryId, Transform and Element1Id/Element2Id.
  • No customer models, personal data or vendor code is added; new third-party code is listed in THIRD-PARTY-NOTICES.md (three.js)
  • Also run: npm test 142/142; npm run e2e 108/108 on Chromium; dist/viewer.js matches a fresh build; dotnet pack contains THIRD-PARTY-NOTICES.md; dotnet docfx docfx.json --warningsAsErrors has 0 warnings.
  • Each task was reviewed separately (multi-lens review with adversarial verification), followed by a whole-branch review; its findings were fixed in the last commit.

Getter-only collections (element properties and mesh indices, grid lines, TimeLiner tasks, attached element ids and child tasks) were skipped by JsonViewerImporter; they are now populated, as ClashTest.Clashes already was.
The viewer is built from ES modules under src/IOViewer/Viewer/src with esbuild into dist/viewer.js (three.js bundled, committed so the .NET build needs no Node). ModelScene draws one BatchedMesh per material with per-element visibility and highlight colour; empty documents show a 'no geometry' message. The exporter escapes closing script/style tags in the inlined code. Adds node unit tests, Playwright smoke tests and synthetic building/empty fixtures exported through the CLI.
A .NET file-based app that builds the synthetic building and empty documents and writes them with JsonViewerExporter; it reproduces the committed fixtures.
BatchedMesh cannot flip the front face per instance, so with DoubleSide an instance with a negative determinant showed its outer faces as back faces and lit them with inward normals. Such instances now draw a reversed-winding copy of their geometry, added once per geometry and bucket. The fixture gains a column and its mirrored twin; an e2e test compares their lit faces and unit tests cover the determinant check and the winding copy.
Left icon rail with one floating panel at a time (model tree, views, clashes,
4D, links; data panels only when the document has data), grouped bottom toolbar,
virtualized model tree with search, right-click menu, H/I/Shift+H/F hotkeys,
status bar and truncation banner. Adds the escaping fixture and a generated
20 000-element model for the tree virtualization test.
…olbar behaviour

Scrolling inside the UI panels no longer closes the menu, so revealing the
right-clicked element in the tree keeps it open. A press outside the menu only
dismisses it. Esc closes an open menu without touching the selection. Refused
fullscreen and failed Id copies show a message instead of being swallowed.
The settings button is removed.
tools/SyntheticModel writes a seeded pipe network (20 shared geometries,
~100 triangles per element, --unique for per-mesh geometry) as an exported
page; npm run perf (headed Playwright, outside npm run e2e) measures load,
orbit fps, pick, selection, hide/isolate/show all, tree search, display
modes and 4D steps, and writes JSON results.
- Draw lists: each bucket fills its multi-draw list in one loop over typed
  arrays (visibility, frustum test on precomputed bounding spheres) in place
  of three's per-instance objects; transparent buckets sort back to front
  with one numeric sort; no whole-mesh bounds walk at first render.
- Motion detail: frames are drawn within a cost budget (draws and
  triangles), leaving out what is smallest on screen, and the full model is
  drawn 200 ms after the last change; the budget adapts to the frame rate
  seen while the camera moves, so fast GPUs keep full detail.
- BIN is read from its text node in pieces (a block over the JS string
  limit loaded as empty, so models of ~380 MB binary could not open),
  decoded with Uint8Array.setFromBase64 where available, and released
  once the scene holds its copies.
- Box select projects element boxes without allocations and builds the
  section planes once; the picker fills its id textures without a view
  per instance.

Integrated Radeon, 200 000 elements / 19.2 M triangles: orbit 15 -> 30 fps,
pick 6.5 -> 1.5 ms, hide/isolate/show all on screen in < 50 ms, open 0.6 s
(3.9 s with unshared geometry, which did not open before).
- Frames without camera motion (click, hide, isolate, show all, 4D step)
  always draw the full model; near/far changes from what is shown do not
  count as motion. Only frames where the camera moved leave detail out.
- The budget adapts on the cost of frames drawn while moving: the wait
  between the request and the animation frame plus the frame's script
  time, never the idle time between input events.
- The budget has a floor and the largest objects in view are always
  drawn; it grows only while detail is being left out and never past
  the cost of what is in view.
- The perf harness records per orbit frame the share of the instances in
  view that was drawn and the detail threshold, marks whether an
  operation's timed frame was full, and defaults to the OS temp folder
  (as does the generator's output).
PERF_LADDER generates and measures synthetic pages of several sizes and prints one table across them
(plus JSON); every run records a full-detail still frame, its draw calls, a full-detail orbit and
scene.memory() when the engine has it. PERF_VARIANTS adds rendering-only runs with a section box,
textures (generator option --textured) or IOVIEWER_TEST.noHideBranch.
…ormals

Geometry positions are written as u16 steps of 2^-10 m from a grid-aligned origin (META q = {o, s}) when
they span at most 65535 steps, else as f32 as before; normals as octahedral snorm16 pairs (new view type
i16). Meshes with their own vertices are placed at their snapped minimum corner, so vertices that coincide
in the world decode to the same grid point in every mesh. The viewer decodes both encodings and still reads
format 2. The C# public API is unchanged.
A mesh with its own vertices that is a translated copy of an earlier one (equal float32 positions
relative to the exact minimum corner, indices, normals and UVs, as format 2 compared them) reuses
that geometry with an exact, unsnapped translation. Only the first occurrence stays on the global
grid. The perf harness now regenerates ladder pages when the library, viewer or generator sources
change, not only the viewer bundle.
A shared geometry that any instance scales up (an axis longer than 1 + 1e-6) is written as f32: its
grid steps are in local units, so the scale would multiply the 0.49 mm bound. New tests decode the
placement of f32 geometry (georeferenced own mesh, its off-grid twin, translated shared geometry)
and of scaled and unscaled shared instances.
Per material, instances are split into chunks of up to 32768 vertices of
nearby instances (k-d split by vertex weight), baked once into world space
(u16 grid steps or f32 for elements over ~64 m, octahedral normals, verbatim
copy for translation-only format-3 instances) and drawn with one call each.
Every instance owns a slot; its colour and shown flag live in an sRGB slot
texture, so select, hide, isolate and 4D steps rewrite texels only. Slots in a
chunk are ordered by size: a frame draws the span from the first to the last
shown slot, and motion detail cuts that span as a prefix.

Picking renders slot + interpolated surface point into a float target, with
an RGBA8 slot + packed-depth fallback. The octahedral normal travels in the
`normal` attribute: without one three shades Lambert flat. A lost WebGL
context now shows a reload message (GPU buffers keep no CPU copy).
…context handling

- A chunk of exactly 65536 vertices used a u16 index whose value 0xFFFF is
  WebGL2's primitive restart, dropping every triangle and line through its
  last vertex: u32 from 65536 vertices, one rule for triangles and lines.
- Unit normals are decoded from the octahedral pairs only for transformed
  slots and released with their geometry's last chunk; translation-only
  slots copy the pairs (200k own-geometry model: -270 MB renderer peak).
- After a lost context, also when the browser restores it, nothing renders
  or picks any more (the buffers keep no CPU copy); the message stays.
- A model over the slot limit opens without geometry with a message.
- Instances with a non-finite box get no slot and stay out of the bounds;
  non-finite centres no longer lose or double instances in the split.
…and bootstrap errors

- HtmlViewerExporter.Save validates the data before it opens the file, so a
  failed export leaves an existing page as it was; the CLI reports
  InvalidDataException/ArgumentException from the export with exit code 3.
- The picker returns { element: -1, point } for geometry without an element:
  orbit pivot, wheel zoom target and the distance tool use the point, every
  selection path still ignores it.
- A press on the 3D canvas or the view cube takes focus from panel fields and
  toolbar buttons, so H, I, F and Esc work after using a panel.
- Wheel zoom scales with deltaY (lines and pages normalised to pixels):
  0.85 per 100 px, clamped to 0.5..2 per event; deltaY 0 does not zoom.
- Startup shows a localized message instead of a blank page when WebGL 2 is
  missing or the model data cannot be read.
…d sheared instance normals

- Write shared geometry relative to its own snapped origin and move that origin into
  each instance translation (in double), so world-coordinate vertices never reach
  float32 or the viewer's GPU decode as large numbers.
- GLB and OBJ importers create one element per node / object or group, so converted
  models have a tree and can be selected, hidden and isolated. OBJ meshes keep only
  the vertices their faces use; a face referencing an unread vertex fails on load.
- Reverse the triangle winding when baking a mirroring transform into own vertices.
- Transform instanced normals by the cofactor matrix, correct under shear.

Fixes #11
When a document has no Elements, the HTML payload gets stand-in elements for the
written meshes: one per mesh ElementId, grouping its meshes so 4D tasks and clashes
still match it, and one per mesh without an ElementId (marked "mesh": true, which
the viewer never matches against task or clash ids). Such documents can again be
listed, selected, hidden and isolated, as in 0.1.0. The document is not changed.
@mahach666
mahach666 merged commit ed1f629 into main Nov 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GLB and OBJ conversions produce no elements: empty tree, no selection, hide or isolate

1 participant