Repository navigation
feat(viewer): three.js viewer with Navisworks-style navigation, sections and merged-chunk rendering - #8
Merged
Conversation
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.
…ot-info copy after upload
… GPU's 30 fps frame
…, coalesced picks
…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.
This was referenced Nov 11, 2026
Open
…me on software-rendered CI
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Library (additive C# API)
ViewerDocument.Geometries,ViewerGeometry,ViewerMesh.GeometryId,ViewerMesh.Transformand theTransform3daffine transform.ViewerClashResult.Element1Id/Element2Id. Without them, the viewer selects an element by name only when that name is unique.GlbViewerImporterkeeps node transforms and shared meshes and converts glTF Y-up to Z-up, so GLB models no longer lie on their side.GlbViewerImporterandObjViewerImportercreate 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 throwsInvalidDataExceptionon load.ElementId(grouping its meshes, matched by 4D tasks and clashes) and one per mesh without anElementId(markedmesh: true, never matched by task or clash ids). The document itself is not changed.InvalidDataException.HtmlViewerExporternow throwsInvalidDataExceptionfor indices outside the vertex range and other invalid references instead of writing broken geometry.Savechecks this before opening the file, and the CLI reports it with exit code 3.JsonViewerImporterfix: 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.HtmlViewerOptions.MaxMeshVerticesdefaults 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.Renderer
Repository
THIRD-PARTY-NOTICES.md(three.js, MIT), included in both nupkgs and in the release archives.--titlesets the browser tab title, and--no-propertiesexcludes element properties only. The options themselves are unchanged.viewer: it rebuilds the bundle, fails when the committeddist/viewer.jsdiffers from a fresh build, and runs the unit tests and the Playwright end-to-end tests on Chromium.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).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):
Known issue
Savehas 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 Releasehas no warningsdotnet test IOViewer.slnx -c Releasepasses (79/79 on net8.0 and on net10.0)Geometries,GeometryId,TransformandElement1Id/Element2Id.THIRD-PARTY-NOTICES.md(three.js)npm test142/142;npm run e2e108/108 on Chromium;dist/viewer.jsmatches a fresh build;dotnet packcontainsTHIRD-PARTY-NOTICES.md;dotnet docfx docfx.json --warningsAsErrorshas 0 warnings.